diff --git a/.changeset/background-process-ports.md b/.changeset/background-process-ports.md deleted file mode 100644 index dfaaddcc676..00000000000 --- a/.changeset/background-process-ports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show detected ports for tracked background processes in the TUI sidebar and process detail dialog. diff --git a/.changeset/background-processes-cli.md b/.changeset/background-processes-cli.md deleted file mode 100644 index 6aed067705b..00000000000 --- a/.changeset/background-processes-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Support tracked background processes in the CLI and VS Code so agents can start long-running dev servers and clean them up when sessions change or end. The CLI also includes process management UI, status, and logs. diff --git a/.changeset/bright-windows-logo.md b/.changeset/bright-windows-logo.md deleted file mode 100644 index c0ed555e384..00000000000 --- a/.changeset/bright-windows-logo.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Use the fallback logo in old Windows terminal emulators while keeping the Unicode logo available over SSH. diff --git a/.changeset/calm-powershell-alerts.md b/.changeset/calm-powershell-alerts.md new file mode 100644 index 00000000000..ac702a7abc8 --- /dev/null +++ b/.changeset/calm-powershell-alerts.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Run Windows PowerShell tool commands without `-EncodedCommand` to reduce antivirus false positives. diff --git a/.changeset/chat-fim-fallback.md b/.changeset/chat-fim-fallback.md new file mode 100644 index 00000000000..73ff08611c5 --- /dev/null +++ b/.changeset/chat-fim-fallback.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/kilo-gateway": patch +--- + +Use the matching FIM model for chat autocomplete when Next Edit is selected. diff --git a/.changeset/create-plan-dir.md b/.changeset/create-plan-dir.md new file mode 100644 index 00000000000..4f8def38d8a --- /dev/null +++ b/.changeset/create-plan-dir.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Create the default `.kilo/plans` directory automatically when Plan mode starts. diff --git a/.changeset/custom-question-jetbrains.md b/.changeset/custom-question-jetbrains.md deleted file mode 100644 index 97a63d7f16c..00000000000 --- a/.changeset/custom-question-jetbrains.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Support typed custom responses to question prompts in the JetBrains plugin. diff --git a/.changeset/fix-command-template-serialization.md b/.changeset/fix-command-template-serialization.md deleted file mode 100644 index eb158322998..00000000000 --- a/.changeset/fix-command-template-serialization.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Fix JetBrains startup when command templates are returned as lazy objects. diff --git a/.changeset/jetbrains-clickable-prompt-mentions.md b/.changeset/jetbrains-clickable-prompt-mentions.md new file mode 100644 index 00000000000..afbcb1d4231 --- /dev/null +++ b/.changeset/jetbrains-clickable-prompt-mentions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Highlight rendered prompt file mentions and open them on click. diff --git a/.changeset/jetbrains-empty-mention-completion.md b/.changeset/jetbrains-empty-mention-completion.md new file mode 100644 index 00000000000..3fb5501937c --- /dev/null +++ b/.changeset/jetbrains-empty-mention-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains file mention suggestions immediately for empty `@` mentions and keep the completion popup stable while typing quickly. diff --git a/.changeset/jetbrains-file-mention-parity.md b/.changeset/jetbrains-file-mention-parity.md new file mode 100644 index 00000000000..0f989b16040 --- /dev/null +++ b/.changeset/jetbrains-file-mention-parity.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Hide raw file contents from mentioned files in JetBrains chat messages. diff --git a/.changeset/jetbrains-git-changes-data-url.md b/.changeset/jetbrains-git-changes-data-url.md new file mode 100644 index 00000000000..5085f5ee663 --- /dev/null +++ b/.changeset/jetbrains-git-changes-data-url.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix @git-changes mentions causing JetBrains chat sessions to fail. diff --git a/.changeset/jetbrains-logged-out-account-panel.md b/.changeset/jetbrains-logged-out-account-panel.md deleted file mode 100644 index 67599a66745..00000000000 --- a/.changeset/jetbrains-logged-out-account-panel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show the logged-out account status in the same rounded panel as the logged-in account overlay, with a "Not logged in" label, hidden picker/balance, and a profile icon to open settings. diff --git a/.changeset/jetbrains-login-dismiss.md b/.changeset/jetbrains-login-dismiss.md deleted file mode 100644 index 4df6bb9e3b3..00000000000 --- a/.changeset/jetbrains-login-dismiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Add a Dismiss button to the paid-model sign-in prompt so users can close it and choose a different model. diff --git a/.changeset/jetbrains-login-qr.md b/.changeset/jetbrains-login-qr.md deleted file mode 100644 index 2e3447eee2e..00000000000 --- a/.changeset/jetbrains-login-qr.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve JetBrains sign-in UI: step labels are left-aligned, the URL field selects all on click, copying the URL or device code shows a confirmation balloon, and the click-to-copy code card and balance card share the same themed background and border. diff --git a/.changeset/jetbrains-mention-icons-priority.md b/.changeset/jetbrains-mention-icons-priority.md new file mode 100644 index 00000000000..bf968c1855a --- /dev/null +++ b/.changeset/jetbrains-mention-icons-priority.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show file type icons and keep predefined mentions first in JetBrains mention completions. diff --git a/.changeset/jetbrains-paid-model-login.md b/.changeset/jetbrains-paid-model-login.md deleted file mode 100644 index f2ed31b2e8a..00000000000 --- a/.changeset/jetbrains-paid-model-login.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show a sign-in prompt in JetBrains sessions when a paid model requires login. diff --git a/.changeset/jetbrains-permission-compact-rows.md b/.changeset/jetbrains-permission-compact-rows.md deleted file mode 100644 index 495d1ded6cd..00000000000 --- a/.changeset/jetbrains-permission-compact-rows.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve JetBrains permission prompts with compact action rows and diff badges. diff --git a/.changeset/jetbrains-profile-polish.md b/.changeset/jetbrains-profile-polish.md deleted file mode 100644 index 5af1f098434..00000000000 --- a/.changeset/jetbrains-profile-polish.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Polish the JetBrains user profile settings layout with a compact account stack, copyable email, simplified organization names, and a refreshable balance card. diff --git a/.changeset/jetbrains-profile-settings.md b/.changeset/jetbrains-profile-settings.md deleted file mode 100644 index 7c715449215..00000000000 --- a/.changeset/jetbrains-profile-settings.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Add native Kilo profile settings page to JetBrains plugin. Settings > Tools > Kilo > User Profile shows login/logout, balance, personal/org account switching, and a dashboard link, and refreshes immediately after login, logout, or active account changes. A new Profile button in the tool window toolbar opens the page directly. The profile settings page now keeps its native UI mounted while login state and active account details change. diff --git a/.changeset/jetbrains-prompt-completion.md b/.changeset/jetbrains-prompt-completion.md new file mode 100644 index 00000000000..552dfa7ccc2 --- /dev/null +++ b/.changeset/jetbrains-prompt-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add `/` slash commands and `@` file/git-changes mentions to the JetBrains chat prompt with native completion. diff --git a/.changeset/jetbrains-prompt-hint.md b/.changeset/jetbrains-prompt-hint.md new file mode 100644 index 00000000000..a69a0e95f75 --- /dev/null +++ b/.changeset/jetbrains-prompt-hint.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a concise single-line hint in the JetBrains prompt placeholder. diff --git a/.changeset/jetbrains-prompt-mentions-undo.md b/.changeset/jetbrains-prompt-mentions-undo.md new file mode 100644 index 00000000000..ad71522d818 --- /dev/null +++ b/.changeset/jetbrains-prompt-mentions-undo.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix prompt undo/redo, clean mid-token mention completion, and show unresolved file mentions in the JetBrains chat prompt. diff --git a/.changeset/jetbrains-send-typed-mentions.md b/.changeset/jetbrains-send-typed-mentions.md new file mode 100644 index 00000000000..3921131f9b8 --- /dev/null +++ b/.changeset/jetbrains-send-typed-mentions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Attach hand-typed prompt file mentions when sending immediately. diff --git a/.changeset/jetbrains-session-account-overlay.md b/.changeset/jetbrains-session-account-overlay.md deleted file mode 100644 index 600bc055108..00000000000 --- a/.changeset/jetbrains-session-account-overlay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show account login, switching, and balance controls on the empty JetBrains session screen. diff --git a/.changeset/jetbrains-session-focus-restore.md b/.changeset/jetbrains-session-focus-restore.md new file mode 100644 index 00000000000..dee4790f7f6 --- /dev/null +++ b/.changeset/jetbrains-session-focus-restore.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Restore prompt focus after returning from session history in JetBrains. diff --git a/.changeset/jetbrains-slash-aliases.md b/.changeset/jetbrains-slash-aliases.md new file mode 100644 index 00000000000..bfc7284a57d --- /dev/null +++ b/.changeset/jetbrains-slash-aliases.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support VS Code slash-command aliases in the JetBrains prompt. diff --git a/.changeset/jetbrains-stable-mention-completion.md b/.changeset/jetbrains-stable-mention-completion.md new file mode 100644 index 00000000000..c4578ede044 --- /dev/null +++ b/.changeset/jetbrains-stable-mention-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains prompt mention suggestions stable while typing fuzzy file matches. diff --git a/.changeset/local-review-static-template.md b/.changeset/local-review-static-template.md deleted file mode 100644 index 0128278250f..00000000000 --- a/.changeset/local-review-static-template.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Support optional review focus for `/local-review` and `/local-review-uncommitted`, optional base selection for `/local-review`, and focus both prompts on high-confidence security, performance, business logic, deploy safety, duplication, and dead-code findings. diff --git a/.changeset/macos-network-sandbox.md b/.changeset/macos-network-sandbox.md new file mode 100644 index 00000000000..d36fc0c2c7f --- /dev/null +++ b/.changeset/macos-network-sandbox.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Block outbound network access from agent commands and in-process HTTP tools with the optional macOS sandbox, with a Sandboxing setting to allow network access when needed. diff --git a/.changeset/mingw-terminal-keyboard.md b/.changeset/mingw-terminal-keyboard.md deleted file mode 100644 index c6135cc3e10..00000000000 --- a/.changeset/mingw-terminal-keyboard.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Avoid leaving mouse and advanced keyboard modes enabled after exiting the TUI in mintty and MINGW terminals. diff --git a/.changeset/persist-index-updates.md b/.changeset/persist-index-updates.md new file mode 100644 index 00000000000..a35078a3c5c --- /dev/null +++ b/.changeset/persist-index-updates.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve unchanged codebase indexes when extension or VS Code updates interrupt an incremental scan. diff --git a/.changeset/quiet-auto-approved-permissions.md b/.changeset/quiet-auto-approved-permissions.md new file mode 100644 index 00000000000..c56014978c4 --- /dev/null +++ b/.changeset/quiet-auto-approved-permissions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep attention sounds silent for permission requests handled by auto-approve. diff --git a/.changeset/sandbox-agent-writes.md b/.changeset/sandbox-agent-writes.md new file mode 100644 index 00000000000..ea727050768 --- /dev/null +++ b/.changeset/sandbox-agent-writes.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS sandbox. diff --git a/.changeset/secure-worktree-sandboxes.md b/.changeset/secure-worktree-sandboxes.md new file mode 100644 index 00000000000..72418e02591 --- /dev/null +++ b/.changeset/secure-worktree-sandboxes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Confine sandboxed worktree sessions to their active worktree instead of allowing writes to sibling or primary checkouts. diff --git a/.changeset/session-expandable-defaults.md b/.changeset/session-expandable-defaults.md deleted file mode 100644 index 905b3adb933..00000000000 --- a/.changeset/session-expandable-defaults.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Start expandable session sections collapsed by default. diff --git a/.changeset/session-question-view-style.md b/.changeset/session-question-view-style.md deleted file mode 100644 index ca25c743d45..00000000000 --- a/.changeset/session-question-view-style.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve question-based session views so UI text uses editor-sized interface fonts, actions align consistently, and permission prompts show a header icon. diff --git a/.changeset/tidy-indexing-button.md b/.changeset/tidy-indexing-button.md new file mode 100644 index 00000000000..9b5fe509aaa --- /dev/null +++ b/.changeset/tidy-indexing-button.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let users hide the codebase indexing button while indexing is off. diff --git a/.changeset/xai-grok-oauth.md b/.changeset/xai-grok-oauth.md deleted file mode 100644 index ca333dfa9a5..00000000000 --- a/.changeset/xai-grok-oauth.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Support xAI Grok OAuth and device-code login for SuperGrok users. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..b6d8d73512f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.opencode +.sst +.turbo +.wrangler +node_modules +**/node_modules +**/.output +**/dist +**/.turbo +**/.vite +**/coverage diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9859174a2e3..5b44517ec51 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -23,7 +23,7 @@ runs: fi - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-download-url: ${{ steps.bun-url.outputs.url }} @@ -33,8 +33,9 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - - name: Cache Bun dependencies - uses: actions/cache@v4 + - name: Restore Bun dependencies + id: bun-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} @@ -56,3 +57,10 @@ runs: bun install ${{ inputs.install-flags }} fi shell: bash + + - name: Save Bun dependencies + if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml index 0474b670a6e..fa00f28b051 100644 --- a/.github/actions/setup-git-committer/action.yml +++ b/.github/actions/setup-git-committer/action.yml @@ -21,7 +21,7 @@ runs: steps: - name: Create app token id: apptoken - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: # kilocode_change start app-id: ${{ inputs['kilo-maintainer-app-id'] }} diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000000..a7b774a40b1 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,12 @@ +# kilocode_change - new file +name: Critical-only CodeQL config + +query-filters: + - include: + kind: + - problem + - path-problem + - alert + - path-alert + tags contain: security + security-severity: /^(9(\.[0-9])?|10(\.0)?)$/ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2b7e92a9d70..a8415564209 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,9 @@ +## Issue + + + +Fixes # + ## Context @@ -6,11 +12,13 @@ -## Screenshots +## Screenshots / Video + + | before | after | |---|---| @@ -18,18 +26,49 @@ Some description of HOW you achieved it. Perhaps give a high level description o ## How to Test +### Manual/local verification + + + +- + +### Reviewer test steps + + +- -A straightforward scenario of how to test your changes will help reviewers that are not familiar with the part of the code that you are changing but want to see it in action. This section can include a description or step-by-step instructions of how to get to the state of v2 that your change affects. +### Blocked checks and substitute verification -A "How To Test" section can look something like this: + -- Sign in with a user with tracks -- Activate `show_awesome_cat_gifs` feature (add `?feature.show_awesome_cat_gifs=1` to your URL) -- You should see a GIF with cats dancing +- + +## Checklist + +- [ ] Issue linked above, or exception explained +- [ ] Tests/verification described +- [ ] Screenshots/video included for visual changes, or marked N/A +- [ ] Changeset considered for user-facing changes +- [ ] I personally reviewed the diff and can explain the changes, including any AI-assisted work. + ## Get in Touch diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 514958ad494..f494bbb4c8e 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -38,6 +38,12 @@ jobs: fi # kilocode_change start + - name: Check Effect Promise facade allowlist + run: bun run script/check-opencode-promise-facades.ts + + - name: Check model tool network boundary + run: bun run script/check-model-tool-network.ts + - name: Check workflow allowlist run: bun run script/check-workflows.ts # kilocode_change end diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml deleted file mode 100644 index aba47e131f1..00000000000 --- a/.github/workflows/close-issues.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: close-issues - -on: - schedule: - - cron: "0 2 * * *" # Daily at 2:00 AM - workflow_dispatch: - -jobs: - close: - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - uses: actions/checkout@v6 # kilocode_change - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Close stale issues - env: - GITHUB_TOKEN: ${{ github.token }} - run: bun script/github/close-issues.ts diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml deleted file mode 100644 index 38d0d3627a5..00000000000 --- a/.github/workflows/close-stale-prs.yml +++ /dev/null @@ -1,236 +0,0 @@ -name: close-stale-prs # kilocode_change - -on: - workflow_dispatch: - inputs: - dryRun: - description: "Log actions without closing PRs" - type: boolean - default: false - schedule: - - cron: "0 6 * * *" - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-stale-prs: - if: github.repository == 'Kilo-Org/kilocode' - runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change - timeout-minutes: 15 - steps: - - name: Close inactive PRs - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const DAYS_INACTIVE = 60 - const MAX_RETRIES = 3 - - // Adaptive delay: fast for small batches, slower for large to respect - // GitHub's 80 content-generating requests/minute limit - const SMALL_BATCH_THRESHOLD = 10 - const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) - const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit - - const startTime = Date.now() - const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) - const { owner, repo } = context.repo - const dryRun = context.payload.inputs?.dryRun === "true" - - core.info(`Dry run mode: ${dryRun}`) - core.info(`Cutoff date: ${cutoff.toISOString()}`) - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) - } - - async function withRetry(fn, description = 'API call') { - let lastError - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - const result = await fn() - return result - } catch (error) { - lastError = error - const isRateLimited = error.status === 403 && - (error.message?.includes('rate limit') || error.message?.includes('secondary')) - - if (!isRateLimited) { - throw error - } - - // Parse retry-after header, default to 60 seconds - const retryAfter = error.response?.headers?.['retry-after'] - ? parseInt(error.response.headers['retry-after']) - : 60 - - // Exponential backoff: retryAfter * 2^attempt - const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) - - core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) - - await sleep(backoffMs) - } - } - core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) - throw lastError - } - - const query = ` - query($owner: String!, $repo: String!, $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequests(first: 100, states: OPEN, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - title - author { - login - } - createdAt - commits(last: 1) { - nodes { - commit { - committedDate - } - } - } - comments(last: 1) { - nodes { - createdAt - } - } - reviews(last: 1) { - nodes { - createdAt - } - } - } - } - } - } - ` - - const allPrs = [] - let cursor = null - let hasNextPage = true - let pageCount = 0 - - while (hasNextPage) { - pageCount++ - core.info(`Fetching page ${pageCount} of open PRs...`) - - const result = await withRetry( - () => github.graphql(query, { owner, repo, cursor }), - `GraphQL page ${pageCount}` - ) - - allPrs.push(...result.repository.pullRequests.nodes) - hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage - cursor = result.repository.pullRequests.pageInfo.endCursor - - core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) - - // Delay between pagination requests (use small batch delay for reads) - if (hasNextPage) { - await sleep(SMALL_BATCH_DELAY_MS) - } - } - - core.info(`Found ${allPrs.length} open pull requests`) - - const stalePrs = allPrs.filter((pr) => { - const dates = [ - new Date(pr.createdAt), - pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, - pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, - pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, - ].filter((d) => d !== null) - - const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] - - if (!lastActivity || lastActivity > cutoff) { - core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) - return false - } - - core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) - return true - }) - - if (!stalePrs.length) { - core.info("No stale pull requests found.") - return - } - - core.info(`Found ${stalePrs.length} stale pull requests`) - - // ============================================ - // Close stale PRs - // ============================================ - const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD - ? LARGE_BATCH_DELAY_MS - : SMALL_BATCH_DELAY_MS - - core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) - - let closedCount = 0 - let skippedCount = 0 - - for (const pr of stalePrs) { - const issue_number = pr.number - const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` - - if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) - continue - } - - try { - // Add comment - await withRetry( - () => github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }), - `Comment on PR #${issue_number}` - ) - - // Close PR - await withRetry( - () => github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }), - `Close PR #${issue_number}` - ) - - closedCount++ - core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) - - // Delay before processing next PR - await sleep(requestDelayMs) - } catch (error) { - skippedCount++ - core.error(`Failed to close PR #${issue_number}: ${error.message}`) - } - } - - const elapsed = Math.round((Date.now() - startTime) / 1000) - core.info(`\n========== Summary ==========`) - core.info(`Total open PRs found: ${allPrs.length}`) - core.info(`Stale PRs identified: ${stalePrs.length}`) - core.info(`PRs closed: ${closedCount}`) - core.info(`PRs skipped (errors): ${skippedCount}`) - core.info(`Elapsed time: ${elapsed}s`) - core.info(`=============================`) diff --git a/.github/workflows/codeql-kotlin.yml b/.github/workflows/codeql-kotlin.yml new file mode 100644 index 00000000000..5f0cec56c87 --- /dev/null +++ b/.github/workflows/codeql-kotlin.yml @@ -0,0 +1,62 @@ +# kilocode_change - new file +name: "CodeQL Kotlin" + +on: + push: + branches: ["main"] + paths: + - "packages/kilo-jetbrains/**" + - ".github/workflows/codeql-kotlin.yml" + pull_request: + branches: ["main"] + paths: + - "packages/kilo-jetbrains/**" + - ".github/workflows/codeql-kotlin.yml" + schedule: + - cron: "29 10 * * 5" + workflow_dispatch: + +jobs: + analyze: + name: Analyze (java-kotlin) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + config-file: ./.github/codeql/codeql-config.yml + + - name: Build Java/Kotlin + shell: bash + run: ./gradlew typecheck --rerun-tasks --no-build-cache + working-directory: packages/kilo-jetbrains + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:java-kotlin" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..86b7a69a053 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,81 @@ +# kilocode_change - new file +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "29 10 * * 5" + workflow_dispatch: + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index a261d6f5b3b..d8b3485035b 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -3,7 +3,7 @@ name: containers on: push: branches: - - dev + - main # kilocode_change paths: - packages/containers/** - .github/workflows/containers.yml @@ -31,13 +31,13 @@ jobs: - uses: ./.github/actions/setup-bun - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/disabled/close-issues.yml.disabled b/.github/workflows/disabled/close-issues.yml.disabled new file mode 100644 index 00000000000..b8a2e3f575d --- /dev/null +++ b/.github/workflows/disabled/close-issues.yml.disabled @@ -0,0 +1,24 @@ +name: close-issues + +on: + schedule: + - cron: "0 2 * * *" # Daily at 2:00 AM + workflow_dispatch: + +jobs: + close: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Close stale issues + env: + GITHUB_TOKEN: ${{ github.token }} + run: bun script/github/close-issues.ts diff --git a/.github/workflows/disabled/compliance-close.yml.disabled b/.github/workflows/disabled/compliance-close.yml.disabled index c3bcf9f686f..14e68701e57 100644 --- a/.github/workflows/disabled/compliance-close.yml.disabled +++ b/.github/workflows/disabled/compliance-close.yml.disabled @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close non-compliant issues and PRs after 2 hours - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const { data: items } = await github.rest.issues.listForRepo({ diff --git a/.github/workflows/disabled/duplicate-issues.yml.disabled b/.github/workflows/disabled/duplicate-issues.yml.disabled new file mode 100644 index 00000000000..ea2a67cb782 --- /dev/null +++ b/.github/workflows/disabled/duplicate-issues.yml.disabled @@ -0,0 +1,177 @@ +name: duplicate-issues + +on: + issues: + types: [opened, edited] + +jobs: + check-duplicates: + if: github.event.action == 'opened' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + + - uses: ./.github/actions/setup-bun + + - name: Install opencode + run: curl -fsSL https://kilo.ai/install | bash + + - name: Check duplicates and compliance + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KILO_PERMISSION: | + { + "bash": { + "*": "deny", + "gh issue*": "allow" + }, + "webfetch": "deny" + } + run: | + opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: + + Issue number: ${{ github.event.issue.number }} + + Lookup this issue with gh issue view ${{ github.event.issue.number }}. + + You have TWO tasks. Perform both, then post a SINGLE comment (if needed). + + --- + + TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK + + Check whether the issue follows our contributing guidelines and issue templates. + + This project has three issue templates that every issue MUST use one of: + + 1. Bug Report - requires a Description field with real content + 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: + 3. Question - requires the Question field with real content + + Additionally check: + - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) + - The issue has real content, not just template placeholder text left unchanged + - Bug reports should include some context about how to reproduce + - Feature requests should explain the problem or need + - We want to push for having the user provide system description & information + + Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. + + --- + + TASK 2: DUPLICATE CHECK + + Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates. + Consider: + 1. Similar titles or descriptions + 2. Same error messages or symptoms + 3. Related functionality or components + 4. Similar feature requests + + Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997. + + --- + + POSTING YOUR COMMENT: + + Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: + + If the issue is NOT compliant, start the comment with: + + Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance + + If duplicates were found, include a section about potential duplicates with links. + + If the issue mentions keybinds/keyboard shortcuts, include a note about #4997. + + If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all. + + Use this format for the comment: + + [If not compliant:] + + This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md). + + **What needs to be fixed:** + - [specific reasons] + + Please edit this issue to address the above within **2 hours**, or it will be automatically closed. + + [If duplicates found, add:] + --- + This issue might be a duplicate of existing issues. Please check: + - #[issue_number]: [brief description of similarity] + + [If keybind-related, add:] + For keybind-related issues, please also check our pinned keybinds documentation: #4997 + + [End with if not compliant:] + If you believe this was flagged incorrectly, please let a maintainer know. + + Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." + + recheck-compliance: + if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + + - uses: ./.github/actions/setup-bun + + - name: Install opencode + run: curl -fsSL https://kilo.ai/install | bash + + - name: Recheck compliance + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KILO_PERMISSION: | + { + "bash": { + "*": "deny", + "gh issue*": "allow" + }, + "webfetch": "deny" + } + run: | + opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. + + Lookup this issue with gh issue view ${{ github.event.issue.number }}. + + Re-check whether the issue now follows our contributing guidelines and issue templates. + + This project has three issue templates that every issue MUST use one of: + + 1. Bug Report - requires a Description field with real content + 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: + 3. Question - requires the Question field with real content + + Additionally check: + - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) + - The issue has real content, not just template placeholder text left unchanged + - Bug reports should include some context about how to reproduce + - Feature requests should explain the problem or need + - We want to push for having the user provide system description & information + + Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. + + If the issue is NOW compliant: + 1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance + 2. Find and delete the previous compliance comment (the one containing ) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id} + 3. Post a short comment thanking them for updating the issue. + + If the issue is STILL not compliant: + Post a comment explaining what still needs to be fixed. Keep the needs:compliance label." diff --git a/.github/workflows/disabled/notify-discord.yml.disabled b/.github/workflows/disabled/notify-discord.yml.disabled index 6bd1ab67153..1cfb52990ad 100644 --- a/.github/workflows/disabled/notify-discord.yml.disabled +++ b/.github/workflows/disabled/notify-discord.yml.disabled @@ -12,6 +12,6 @@ jobs: runs-on: depot-ubuntu-24.04 steps: - name: Send nicely-formatted embed to Discord - uses: SethCohen/github-releases-to-discord@v1 + uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/disabled/pr-management.yml.disabled b/.github/workflows/disabled/pr-management.yml.disabled index 1e331bd6f2e..b43c6af9bab 100644 --- a/.github/workflows/disabled/pr-management.yml.disabled +++ b/.github/workflows/disabled/pr-management.yml.disabled @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -78,7 +78,7 @@ jobs: issues: write steps: - name: Add Contributor Label - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const isPR = !!context.payload.pull_request; diff --git a/.github/workflows/disabled/pr-standards.yml.disabled b/.github/workflows/disabled/pr-standards.yml.disabled index 1edbd5d061d..06838089d35 100644 --- a/.github/workflows/disabled/pr-standards.yml.disabled +++ b/.github/workflows/disabled/pr-standards.yml.disabled @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Check PR standards - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; @@ -159,7 +159,7 @@ jobs: pull-requests: write steps: - name: Check PR template compliance - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/disabled/publish-github-action.yml.disabled b/.github/workflows/disabled/publish-github-action.yml.disabled index 3d8426a0dbc..e16d5f7919e 100644 --- a/.github/workflows/disabled/publish-github-action.yml.disabled +++ b/.github/workflows/disabled/publish-github-action.yml.disabled @@ -16,7 +16,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/disabled/release-github-action.yml.disabled b/.github/workflows/disabled/release-github-action.yml.disabled index ddfb7a7fca8..6c2987c8eba 100644 --- a/.github/workflows/disabled/release-github-action.yml.disabled +++ b/.github/workflows/disabled/release-github-action.yml.disabled @@ -16,7 +16,7 @@ jobs: release: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/disabled/review.yml.disabled b/.github/workflows/disabled/review.yml.disabled index b44d262df7c..7f7dc7e8b9b 100644 --- a/.github/workflows/disabled/review.yml.disabled +++ b/.github/workflows/disabled/review.yml.disabled @@ -29,7 +29,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/disabled/stats.yml.disabled b/.github/workflows/disabled/stats.yml.disabled index 3da92d4691a..b7e1f43b9f4 100644 --- a/.github/workflows/disabled/stats.yml.disabled +++ b/.github/workflows/disabled/stats.yml.disabled @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/disabled/storybook.yml.disabled b/.github/workflows/disabled/storybook.yml.disabled index 6d143a8a22f..1e652104d69 100644 --- a/.github/workflows/disabled/storybook.yml.disabled +++ b/.github/workflows/disabled/storybook.yml.disabled @@ -29,7 +29,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/disabled/sync-zed-extension.yml.disabled b/.github/workflows/disabled/sync-zed-extension.yml.disabled index f14487cde97..6e4b44083c1 100644 --- a/.github/workflows/disabled/sync-zed-extension.yml.disabled +++ b/.github/workflows/disabled/sync-zed-extension.yml.disabled @@ -10,7 +10,7 @@ jobs: name: Release Zed Extension runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/disabled/triage.yml.disabled b/.github/workflows/disabled/triage.yml.disabled new file mode 100644 index 00000000000..0b2c9d89a1c --- /dev/null +++ b/.github/workflows/disabled/triage.yml.disabled @@ -0,0 +1,37 @@ +name: triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Install opencode + run: curl -fsSL https://kilo.ai/install | bash + + - name: Triage issue + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + opencode run --agent triage "The following issue was just opened, triage it: + + Title: $ISSUE_TITLE + + $ISSUE_BODY" diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml deleted file mode 100644 index cf02d8e0a48..00000000000 --- a/.github/workflows/duplicate-issues.yml +++ /dev/null @@ -1,185 +0,0 @@ -name: duplicate-issues - -on: - issues: - types: [opened, edited] - -jobs: - check-duplicates: - if: false # kilocode_change - disabled: not needed in kilocode repo - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 # kilocode_change - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - # kilocode_change start - - name: Setup Kilo - uses: ./.github/actions/setup-kilo - # kilocode_change end - - - name: Check duplicates and compliance - env: - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} - KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - KILO_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow" - }, - "webfetch": "deny" - } - run: | - kilo run -m "kilo/anthropic/claude-haiku-4.5" "A new issue has been created: - - Issue number: ${{ github.event.issue.number }} - - Lookup this issue with gh issue view ${{ github.event.issue.number }}. - - You have TWO tasks. Perform both, then post a SINGLE comment (if needed). - - --- - - TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK - - Check whether the issue follows our contributing guidelines and issue templates. - - This project has three issue templates that every issue MUST use one of: - - 1. Bug Report - requires a Description field with real content - 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: - 3. Question - requires the Question field with real content - - Additionally check: - - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) - - The issue has real content, not just template placeholder text left unchanged - - Bug reports should include some context about how to reproduce - - Feature requests should explain the problem or need - - We want to push for having the user provide system description & information - - Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. - - --- - - TASK 2: DUPLICATE CHECK - - Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates. - Consider: - 1. Similar titles or descriptions - 2. Same error messages or symptoms - 3. Related functionality or components - 4. Similar feature requests - - Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997. - - --- - - POSTING YOUR COMMENT: - - Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - - If the issue is NOT compliant, start the comment with: - - Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance - - If duplicates were found, include a section about potential duplicates with links. - - If the issue mentions keybinds/keyboard shortcuts, include a note about #4997. - - If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all. - - Use this format for the comment: - - [If not compliant:] - - This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md). - - **What needs to be fixed:** - - [specific reasons] - - Please edit this issue to address the above within **2 hours**, or it will be automatically closed. - - [If duplicates found, add:] - --- - This issue might be a duplicate of existing issues. Please check: - - #[issue_number]: [brief description of similarity] - - [If keybind-related, add:] - For keybind-related issues, please also check our pinned keybinds documentation: #4997 - - [End with if not compliant:] - If you believe this was flagged incorrectly, please let a maintainer know. - - Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." - - recheck-compliance: - if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 # kilocode_change - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - # kilocode_change start - - name: Setup Kilo - uses: ./.github/actions/setup-kilo - # kilocode_change end - - - name: Recheck compliance - env: - # kilocode_change start - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} - KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} - # kilocode_change end - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - KILO_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow" - }, - "webfetch": "deny" - } - run: | - kilo run -m "kilo/anthropic/claude-haiku-4.5" "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. - - Lookup this issue with gh issue view ${{ github.event.issue.number }}. - - Re-check whether the issue now follows our contributing guidelines and issue templates. - - This project has three issue templates that every issue MUST use one of: - - 1. Bug Report - requires a Description field with real content - 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: - 3. Question - requires the Question field with real content - - Additionally check: - - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) - - The issue has real content, not just template placeholder text left unchanged - - Bug reports should include some context about how to reproduce - - Feature requests should explain the problem or need - - We want to push for having the user provide system description & information - - Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. - - If the issue is NOW compliant: - 1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance - 2. Find and delete the previous compliance comment (the one containing ) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id} - 3. Post a short comment thanking them for updating the issue. - - If the issue is STILL not compliant: - Post a comment explaining what still needs to be fixed. Keep the needs:compliance label." diff --git a/.github/workflows/kilo-auto-close.yml b/.github/workflows/kilo-auto-close.yml new file mode 100644 index 00000000000..b28a285f932 --- /dev/null +++ b/.github/workflows/kilo-auto-close.yml @@ -0,0 +1,366 @@ +# kilocode_change - new file +name: kilo-auto-close + +on: + workflow_dispatch: + inputs: + dryRun: + description: "Log actions without closing items" + type: boolean + default: true + schedule: + - cron: "0 6 * * *" + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 30 + steps: + - name: Close inactive PRs and issues + uses: actions/github-script@v8 + env: + KILO_AUTO_CLOSE_ENABLED: ${{ vars.KILO_AUTO_CLOSE_ENABLED }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const PR_DAYS_INACTIVE = 30 + const ISSUE_DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 items) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 items) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() + const prCutoff = new Date(Date.now() - PR_DAYS_INACTIVE * 24 * 60 * 60 * 1000) + const issueCutoff = new Date(Date.now() - ISSUE_DAYS_INACTIVE * 24 * 60 * 60 * 1000) + const { owner, repo } = context.repo + const dryRunInput = context.payload.inputs?.dryRun + const enabled = process.env.KILO_AUTO_CLOSE_ENABLED === "true" + const dryRun = dryRunInput === undefined + ? !enabled + : dryRunInput !== "false" + + core.info(`Dry run mode: ${dryRun}`) + core.info(`PR cutoff date: ${prCutoff.toISOString()}`) + core.info(`Issue cutoff date: ${issueCutoff.toISOString()}`) + + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + if (attempt === MAX_RETRIES - 1) { + break + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + author { + login + } + createdAt + commits(last: 1) { + nodes { + commit { + committedDate + } + } + } + comments(last: 1) { + nodes { + createdAt + } + } + reviews(last: 1) { + nodes { + createdAt + } + } + reviewThreads(last: 100) { + nodes { + comments(last: 1) { + nodes { + createdAt + } + } + } + } + } + } + } + } + ` + + const allPrs = [] + let cursor = null + let hasNextPage = true + let pageCount = 0 + + while (hasNextPage) { + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) + + allPrs.push(...result.repository.pullRequests.nodes) + hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage + cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } + } + + core.info(`Found ${allPrs.length} open pull requests`) + + function entry(source, date) { + if (!date) return null + + return { source, date: new Date(date) } + } + + function latest(items) { + return items.filter(Boolean).sort((a, b) => b.date.getTime() - a.date.getTime())[0] + } + + const stalePrs = allPrs.flatMap((pr) => { + const activity = latest([ + entry("created", pr.createdAt), + entry("commit", pr.commits.nodes[0]?.commit.committedDate), + entry("comment", pr.comments.nodes[0]?.createdAt), + entry("review", pr.reviews.nodes[0]?.createdAt), + ...pr.reviewThreads.nodes.map((t) => entry("review comment", t.comments.nodes[0]?.createdAt)), + ]) + const text = activity + ? `${activity.date.toISOString()} via ${activity.source}` + : "unknown" + + if (!activity || activity.date > prCutoff) { + core.info(`PR #${pr.number} is fresh (last activity: ${text})`) + return [] + } + + core.info(`PR #${pr.number} is STALE (last activity: ${text})`) + return [{ ...pr, activity }] + }) + + core.info(`Found ${stalePrs.length} stale pull requests`) + + const prDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${prDelayMs}ms delay between PR operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedPrCount = 0 + let skippedPrCount = 0 + + for (const pr of stalePrs) { + const issue_number = pr.number + const closeComment = `To stay organized pull requests are automatically closed after ${PR_DAYS_INACTIVE} days of inactivity. If the pull request is still relevant please reopen it or create a fresh new one.` + + if (dryRun) { + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'} (last activity: ${pr.activity.date.toISOString()} via ${pr.activity.source}): ${pr.title}`) + continue + } + + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) + + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) + + closedPrCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'} (last activity: ${pr.activity.date.toISOString()} via ${pr.activity.source}): ${pr.title}`) + + // Delay before processing next PR + await sleep(prDelayMs) + } catch (error) { + skippedPrCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } + } + + let page = 1 + let stop = false + const staleIssues = [] + + while (!stop) { + core.info(`Fetching page ${page} of open issues...`) + + const result = await withRetry( + () => github.rest.issues.listForRepo({ + owner, + repo, + state: "open", + sort: "updated", + direction: "asc", + per_page: 100, + page, + }), + `Issues page ${page}` + ) + + if (!result.data.length) { + break + } + + core.info(`Page ${page}: fetched ${result.data.length} issues`) + + for (const issue of result.data) { + const updated = new Date(issue.updated_at) + + if (updated >= issueCutoff) { + core.info(`Found fresh issue/PR #${issue.number} (${updated.toISOString()}), stopping issue scan`) + stop = true + break + } + + if (issue.pull_request) { + core.info(`Skipping PR #${issue.number} in issue scan`) + continue + } + + staleIssues.push({ ...issue, activity: updated }) + } + + if (!stop) { + page++ + await sleep(SMALL_BATCH_DELAY_MS) + } + } + + core.info(`Found ${staleIssues.length} stale issues`) + + const issueDelayMs = staleIssues.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${issueDelayMs}ms delay between issue operations (${staleIssues.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedIssueCount = 0 + let skippedIssueCount = 0 + + for (const issue of staleIssues) { + const closeComment = `To stay organized issues are automatically closed after ${ISSUE_DAYS_INACTIVE} days of no activity. If the issue is still relevant please reopen it or create a fresh new one.` + + if (dryRun) { + core.info(`[dry-run] Would close issue #${issue.number} (last activity: ${issue.activity.toISOString()} via updated): ${issue.title}`) + continue + } + + try { + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: closeComment, + }), + `Comment on issue #${issue.number}` + ) + + await withRetry( + () => github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }), + `Close issue #${issue.number}` + ) + + closedIssueCount++ + core.info(`Closed issue #${issue.number} (last activity: ${issue.activity.toISOString()} via updated): ${issue.title}`) + + await sleep(issueDelayMs) + } catch (error) { + skippedIssueCount++ + core.error(`Failed to close issue #${issue.number}: ${error.message}`) + } + } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedPrCount}`) + core.info(`PRs skipped (errors): ${skippedPrCount}`) + core.info(`Stale issues identified: ${staleIssues.length}`) + core.info(`Issues closed: ${closedIssueCount}`) + core.info(`Issues skipped (errors): ${skippedIssueCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`) diff --git a/.github/workflows/nix-eval.yml b/.github/workflows/nix-eval.yml index 51c467e1b24..ff4bc3462a2 100644 --- a/.github/workflows/nix-eval.yml +++ b/.github/workflows/nix-eval.yml @@ -21,10 +21,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Evaluate flake outputs (all systems) run: | diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index be48bb895e1..8d9980049f1 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -42,10 +42,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Compute node_modules hash id: hash diff --git a/.github/workflows/prepare-jetbrains-release.yml b/.github/workflows/prepare-jetbrains-release.yml new file mode 100644 index 00000000000..e9da7833bc4 --- /dev/null +++ b/.github/workflows/prepare-jetbrains-release.yml @@ -0,0 +1,64 @@ +# kilocode_change - new file +name: prepare-jetbrains-release + +on: + workflow_dispatch: + inputs: + kind: + description: "Release kind" + required: true + type: choice + options: + - rc + - stable + version: + description: "Version, e.g. 7.3.13-rc.1 or 7.3.13" + required: true + type: string + from_tag: + description: "Optional previous tag for changelog range" + required: false + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prepare-jetbrains-release-${{ inputs.version }} + cancel-in-progress: false + +jobs: + prepare: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Setup Git Committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + kilo-maintainer-app-id: ${{ secrets.KILO_MAINTAINER_APP_ID }} + kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }} + + - name: Create release tag and PR + run: | + args=(--kind "$KIND" --version "$VERSION") + if [[ -n "$FROM_TAG" ]]; then + args+=(--from-tag "$FROM_TAG") + fi + bun script/jetbrains-release-pr.ts "${args[@]}" + env: + GH_TOKEN: ${{ steps.committer.outputs.token }} + GH_REPO: ${{ github.repository }} + KIND: ${{ inputs.kind }} + VERSION: ${{ inputs.version }} + FROM_TAG: ${{ inputs.from_tag }} diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index 4db34d33be8..ffc83d070be 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -2,27 +2,65 @@ name: publish-jetbrains on: - push: - tags: - - "jetbrains/*" + pull_request: + types: + - closed + branches: + - main concurrency: - group: publish-jetbrains-${{ github.ref }} + group: publish-jetbrains-pr-${{ github.event.pull_request.number }} cancel-in-progress: false permissions: contents: write + pull-requests: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: publish: - if: github.repository == 'Kilo-Org/kilocode' + if: >- + github.repository == 'Kilo-Org/kilocode' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'jetbrains/release/') && + contains(github.event.pull_request.labels.*.name, 'jetbrains-release') && + github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout merged release PR + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Setup Bun for validation + uses: ./.github/actions/setup-bun + + - name: Validate release PR and tag + id: release + run: bun script/jetbrains-release-validate.ts --pr "$PR_NUMBER" + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + + - name: Save reviewed release metadata + run: | + cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md" + cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties" + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ steps.release.outputs.tag }} + + - name: Restore reviewed release metadata + run: | + cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md + cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties - name: Setup Node uses: actions/setup-node@v4 @@ -32,6 +70,9 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Install dependencies + run: bun install + - name: Setup Java uses: actions/setup-java@v4 with: @@ -46,40 +87,6 @@ jobs: sudo apt-get update sudo apt-get install -y patchelf zip - - name: Validate version tag - id: version - run: | - tag="$GITHUB_REF_NAME" - if [[ "$tag" != jetbrains/v* ]]; then - echo "Unsupported tag '$tag'. Expected jetbrains/v." >&2 - exit 1 - fi - - version="${tag#jetbrains/v}" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - { - echo "version=$version" - echo "kind=rc" - echo "marketplace_channel=eap" - echo "cli_channel=rc" - } >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - { - echo "version=$version" - echo "kind=stable" - echo "marketplace_channel=default" - echo "cli_channel=latest" - } >> "$GITHUB_OUTPUT" - echo "Stable JetBrains Marketplace publishing is implemented but intentionally disabled; use an rc tag such as jetbrains/v7.0.1-rc.1." >&2 - exit 1 - fi - - echo "Unsupported JetBrains plugin version '$version'. Expected jetbrains/vx.y.z-rc.n or jetbrains/vx.y.z." >&2 - exit 1 - - name: Validate publishing secrets run: | missing=0 @@ -100,8 +107,8 @@ jobs: working-directory: packages/kilo-jetbrains run: bun script/build.ts --production --prepare-cli env: - KILO_VERSION: ${{ steps.version.outputs.version }} - KILO_CHANNEL: ${{ steps.version.outputs.cli_channel }} + KILO_VERSION: ${{ steps.release.outputs.version }} + KILO_CHANNEL: ${{ steps.release.outputs.cli_channel }} GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -109,13 +116,26 @@ jobs: working-directory: packages/kilo-jetbrains run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" env: - CHANNEL: ${{ steps.version.outputs.marketplace_channel }} + VERSION: ${{ steps.release.outputs.version }} + CHANNEL: ${{ steps.release.outputs.marketplace_channel }} + + - name: Render release notes + working-directory: packages/kilo-jetbrains + run: | + if ! grep -Fq "## [$VERSION]" CHANGELOG.md; then + echo "Missing packages/kilo-jetbrains/CHANGELOG.md entry for $VERSION. Review and merge a release PR before publishing." >&2 + exit 1 + fi + ./gradlew getChangelog --project-version "$VERSION" --no-header --no-empty-sections --output-file=build/release-notes.md + env: + VERSION: ${{ steps.release.outputs.version }} - name: Publish to JetBrains Marketplace working-directory: packages/kilo-jetbrains run: ./gradlew publishPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" env: - CHANNEL: ${{ steps.version.outputs.marketplace_channel }} + VERSION: ${{ steps.release.outputs.version }} + CHANNEL: ${{ steps.release.outputs.marketplace_channel }} JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} @@ -145,23 +165,30 @@ jobs: - name: Upload to GitHub Release run: | - tag="$GITHUB_REF_NAME" + tag="$TAG" title="JetBrains $VERSION" - notes="JetBrains plugin $VERSION." + flags=(--title "$title" --notes-file "$NOTES") + if [[ "$KIND" == "rc" ]]; then + flags+=(--prerelease) + fi if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release upload "$tag" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY" + gh release edit "$tag" "${flags[@]}" --repo "$GITHUB_REPOSITORY" exit 0 fi - gh release create "$tag" "$ARCHIVE" --title "$title" --notes "$notes" --prerelease --repo "$GITHUB_REPOSITORY" + gh release create "$tag" "$ARCHIVE" "${flags[@]}" --repo "$GITHUB_REPOSITORY" env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + KIND: ${{ steps.release.outputs.kind }} ARCHIVE: ${{ steps.archive.outputs.path }} + NOTES: packages/kilo-jetbrains/build/release-notes.md - name: Upload workflow artifact if: always() uses: actions/upload-artifact@v4 with: - name: kilo-jetbrains-${{ steps.version.outputs.version }} + name: kilo-jetbrains-${{ steps.release.outputs.version }} path: packages/kilo-jetbrains/build/distributions/*.zip if-no-files-found: ignore diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93f79d63cb9..c2f0f56de15 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -118,8 +118,187 @@ jobs: outputs: version: ${{ needs.version.outputs.version }} - build-vscode: + # kilocode_change start - execute supported Unix CLI binaries before packaging VSIX artifacts + validate-cli-unix: + name: Validate CLI (${{ matrix.target }}) needs: build-cli + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-15 + uname: arm64 + package: "@kilocode/cli-darwin-arm64" + mode: host + - target: darwin-x64 + runner: macos-15-intel + uname: x86_64 + package: "@kilocode/cli-darwin-x64" + mode: host + - target: linux-arm64 + runner: ubuntu-24.04-arm + uname: aarch64 + package: "@kilocode/cli-linux-arm64" + mode: host + - target: linux-x64 + runner: ubuntu-24.04 + uname: x86_64 + package: "@kilocode/cli-linux-x64" + mode: host + - target: alpine-arm64 + runner: ubuntu-24.04-arm + uname: aarch64 + package: "@kilocode/cli-linux-arm64-musl" + mode: alpine + platform: linux/arm64 + - target: alpine-x64 + runner: ubuntu-24.04 + uname: x86_64 + package: "@kilocode/cli-linux-x64-musl" + mode: alpine + platform: linux/amd64 + steps: + - uses: actions/download-artifact@v8 + with: + name: kilo-cli.tar.zst + path: /tmp + skip-decompress: true + + - name: Unpack CLI dist + run: | + mkdir -p dist + tar --zstd -xf /tmp/kilo-cli.tar.zst -C dist + + - name: Run CLI smoke test + run: | + test "$(uname -m)" = "${{ matrix.uname }}" + + smoke_host() { + binary="$1" + "$binary" --version + root="$(mktemp -d)" + trap 'rm -rf "$root"' RETURN + ( + unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR + export XDG_DATA_HOME="$root/data" + export XDG_CACHE_HOME="$root/cache" + export XDG_CONFIG_HOME="$root/config" + export XDG_STATE_HOME="$root/state" + export KILO_DISABLE_MODELS_FETCH=1 + export KILO_DISABLE_PROJECT_CONFIG=1 + export KILO_CONFIG_CONTENT='{"enabled_providers":["anthropic"]}' + export ANTHROPIC_API_KEY=dummy + "$binary" --pure models anthropic | grep -q '^anthropic/' + ) + } + + if [ "${{ matrix.mode }}" = "host" ]; then + smoke_host "./dist/${{ matrix.package }}/bin/kilo" + exit 0 + fi + + docker run --rm \ + --platform "${{ matrix.platform }}" \ + -v "$PWD/dist:/dist:ro" \ + -e PACKAGE="${{ matrix.package }}" \ + alpine:3.22 \ + sh -c ' + set -eu + # kilocode_change start - Bun musl binaries link against libstdc++ and libgcc_s + # (GCC C++ runtime). Alpine does not ship these by default; they are available + # as optional packages and must be installed for any Bun-compiled musl binary to run. + apk add --no-cache libstdc++ libgcc + # kilocode_change end + binary="/dist/$PACKAGE/bin/kilo" + "$binary" --version + root="$(mktemp -d)" + trap '\''rm -rf "$root"'\'' EXIT + unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR + export XDG_DATA_HOME="$root/data" + export XDG_CACHE_HOME="$root/cache" + export XDG_CONFIG_HOME="$root/config" + export XDG_STATE_HOME="$root/state" + export KILO_DISABLE_MODELS_FETCH=1 + export KILO_DISABLE_PROJECT_CONFIG=1 + export KILO_CONFIG_CONTENT='\''{"enabled_providers":["anthropic"]}'\'' + export ANTHROPIC_API_KEY=dummy + "$binary" --pure models anthropic | grep -q "^anthropic/" + ' + + validate-cli-windows: + name: Validate CLI (windows-${{ matrix.arch }}) + needs: build-cli + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: windows-11-arm + package: "@kilocode/cli-windows-arm64" + - arch: x64 + runner: windows-2025 + package: "@kilocode/cli-windows-x64" + steps: + - uses: actions/download-artifact@v8 + with: + name: kilo-cli.tar.zst + path: ${{ runner.temp }} + skip-decompress: true + + - name: Install zstd + shell: pwsh + run: | + if (-not (Get-Command zstd -ErrorAction SilentlyContinue)) { + choco install zstandard -y --no-progress + } + + - name: Unpack CLI dist + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + zstd -d --stdout "$env:RUNNER_TEMP\kilo-cli.tar.zst" | tar -xf - -C dist + + - name: Run CLI smoke test + shell: pwsh + run: | + $package = "${{ matrix.package }}".Replace("/", "\") + $binary = ".\dist\$package\bin\kilo.exe" + & $binary --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $root = Join-Path $env:RUNNER_TEMP ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Force -Path $root | Out-Null + try { + foreach ($name in "KILO_MODELS_PATH", "KILO_MODELS_URL", "KILO_CONFIG", "KILO_CONFIG_DIR") { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + $env:XDG_DATA_HOME = Join-Path $root "data" + $env:XDG_CACHE_HOME = Join-Path $root "cache" + $env:XDG_CONFIG_HOME = Join-Path $root "config" + $env:XDG_STATE_HOME = Join-Path $root "state" + $env:KILO_DISABLE_MODELS_FETCH = "1" + $env:KILO_DISABLE_PROJECT_CONFIG = "1" + $env:KILO_CONFIG_CONTENT = '{"enabled_providers":["anthropic"]}' + $env:ANTHROPIC_API_KEY = "dummy" + $output = & $binary --pure models anthropic + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not ($output -match "(?m)^anthropic/")) { + throw "Compiled Windows binary did not list Anthropic models from the embedded snapshot" + } + } finally { + Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue + } + # kilocode_change end + build-vscode: + needs: + - build-cli + - validate-cli-unix + - validate-cli-windows runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'Kilo-Org/kilocode' steps: @@ -181,6 +360,8 @@ jobs: - version - build-cli - build-vscode + - validate-cli-unix # kilocode_change + - validate-cli-windows # kilocode_change - smoke-test runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index b0604eb8ebc..55f29db1a98 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -125,7 +125,7 @@ jobs: ./scripts/run_eval.sh \ -m kilo/anthropic/claude-sonnet-4.6 \ -d terminal-bench-sample \ - -t "log-summary-date-ranges" \ + --include-task-name "log-summary-date-ranges" \ --job-name smoke-test-log-summary \ --timeout-multiplier 2 diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml new file mode 100644 index 00000000000..8469b98776a --- /dev/null +++ b/.github/workflows/test-jetbrains.yml @@ -0,0 +1,103 @@ +# kilocode_change - new file +name: test-jetbrains + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + checks: write + pull-requests: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + changes: + name: detect JetBrains changes + runs-on: blacksmith-4vcpu-ubuntu-2404 + outputs: + jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }} + steps: + - name: Checkout repository + if: github.event_name != 'workflow_dispatch' + uses: actions/checkout@v6 + + - name: Detect JetBrains changes + if: github.event_name != 'workflow_dispatch' + id: filter + uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3 + with: + predicate-quantifier: every + filters: | + jetbrains: + - '**' + - '!packages/kilo-vscode/**' + - '!packages/kilo-docs/**' + + unit: + name: jetbrains + needs: changes + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + container: + image: ghcr.io/kilo-org/build/jetbrains:24.04 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Mark workspace as git-safe + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install dependencies + run: bun install + + - name: Run JetBrains unit tests + run: bun script/test-ci.ts + working-directory: packages/kilo-jetbrains + + - name: Publish JetBrains unit reports + if: always() + uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 + with: + report_paths: packages/kilo-jetbrains/.artifacts/unit/junit.xml + check_name: "unit results (jetbrains)" + detailed_summary: true + include_time_in_summary: true + fail_on_failure: false + + - name: Upload JetBrains unit artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unit-jetbrains-${{ github.run_attempt }} + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 + path: packages/kilo-jetbrains/.artifacts/unit/junit.xml + + required: + name: result + needs: + - changes + - unit + if: always() + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Verify JetBrains jobs passed + run: | + echo "changes=${{ needs.changes.result }}" + echo "jetbrains=${{ needs.unit.result }}" + test "${{ needs.changes.result }}" = "success" + if [ "${{ needs.changes.outputs.jetbrains }}" = "true" ]; then + test "${{ needs.unit.result }}" = "success" + else + test "${{ needs.changes.outputs.jetbrains }}" = "false" + test "${{ needs.unit.result }}" = "skipped" + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bdfe2e18a15..4b59bbb1208 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + # kilocode_change start unit: name: unit (${{ matrix.settings.name }}) strategy: @@ -29,9 +30,12 @@ jobs: settings: - name: linux host: blacksmith-4vcpu-ubuntu-2404 # kilocode_change + - name: macos + host: macos-15 # kilocode_change - name: windows host: blacksmith-4vcpu-windows-2025 # kilocode_change runs-on: ${{ matrix.settings.host }} + timeout-minutes: 45 # kilocode_change defaults: run: shell: bash @@ -45,7 +49,7 @@ jobs: - name: Setup Node id: setup-node continue-on-error: ${{ runner.os == 'Windows' }} - uses: actions/setup-node@v6 + uses: actions/setup-node@v6 # kilocode_change with: node-version: "24" @@ -59,14 +63,6 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun - # kilocode_change start - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - # kilocode_change end - - name: Configure git identity run: | git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com" @@ -82,13 +78,19 @@ jobs: turbo-${{ runner.os }}- - name: Run unit tests - run: bun turbo test:ci + run: bun turbo test:ci --filter='!@kilocode/kilo-jetbrains' env: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + KILO_TEST_PROFILE: ${{ runner.os == 'macOS' && github.event_name == 'pull_request' && 'darwin' || '' }} # kilocode_change + + - name: Run HttpApi exerciser gates + if: runner.os == 'Linux' # kilocode_change + working-directory: packages/opencode + run: bun run test:httpapi - name: Publish unit reports if: always() - uses: mikepenz/action-junit-report@v6 + uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: report_paths: packages/*/.artifacts/unit/junit.xml check_name: "unit results (${{ matrix.settings.name }})" @@ -105,15 +107,31 @@ jobs: if-no-files-found: ignore retention-days: 7 path: packages/*/.artifacts/unit/junit.xml + # kilocode_change end + + # kilocode_change start + jetbrains: + name: jetbrains + permissions: + contents: read + checks: write + pull-requests: read + uses: ./.github/workflows/test-jetbrains.yml + # kilocode_change end + # kilocode_change start required: name: test (linux) runs-on: blacksmith-4vcpu-ubuntu-2404 needs: - unit + - jetbrains if: always() steps: - name: Verify upstream test jobs passed run: | echo "unit=${{ needs.unit.result }}" + echo "jetbrains=${{ needs.jetbrains.result }}" test "${{ needs.unit.result }}" = "success" + test "${{ needs.jetbrains.result }}" = "success" + # kilocode_change end diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml deleted file mode 100644 index b467817c71b..00000000000 --- a/.github/workflows/triage.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: triage - -on: - issues: - types: [opened] - -jobs: - triage: - if: github.repository == 'Kilo-Org/kilocode' - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 # kilocode_change - with: - fetch-depth: 1 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup Kilo - uses: ./.github/actions/setup-kilo - - - name: Triage issue - env: - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} - KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_BODY: ${{ github.event.issue.body }} - run: | - kilo run --agent triage "The following issue was just opened, triage it: - - Title: $ISSUE_TITLE - - $ISSUE_BODY" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f49ea2bd277..11ef4e1ab75 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -8,7 +8,8 @@ on: workflow_dispatch: jobs: - typecheck: + typecheck-js: + name: typecheck-js runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository @@ -18,12 +19,51 @@ jobs: uses: ./.github/actions/setup-bun # kilocode_change start + - name: Run TypeScript typecheck + run: bun turbo typecheck --filter='!@kilocode/kilo-jetbrains' + + - name: Build Kilo Console + run: bun turbo build --filter=@kilocode/kilo-console + # kilocode_change end + + # kilocode_change start + typecheck-jetbrains: + name: typecheck-jetbrains + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - # kilocode_change end - - name: Run typecheck - run: bun typecheck + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + - name: Run JetBrains typecheck + run: ./gradlew typecheck + working-directory: packages/kilo-jetbrains + + required: + name: typecheck + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: + - typecheck-js + - typecheck-jetbrains + if: always() + steps: + - name: Verify typecheck jobs passed + run: | + echo "typecheck-js=${{ needs.typecheck-js.result }}" + echo "typecheck-jetbrains=${{ needs.typecheck-jetbrains.result }}" + test "${{ needs.typecheck-js.result }}" = "success" + test "${{ needs.typecheck-jetbrains.result }}" = "success" + # kilocode_change end diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index d059175adc8..69ebd6a17a6 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -13,21 +13,25 @@ jobs: is_fork: ${{ steps.fork-check.outputs.is_fork }} steps: - uses: actions/checkout@v6 # kilocode_change - - uses: Kilo-Org/paths-filter@master + # kilocode_change start + - name: Check changed files id: filter - with: - filters: | - matched: - - "packages/kilo-ui/**" - - "packages/ui/**" - - "packages/util/**" - - "packages/sdk/js/**" - - "packages/kilo-vscode/webview-ui/**" - - "packages/kilo-vscode/.storybook/**" - - "packages/kilo-vscode/tests/visual-regression*" - - "packages/kilo-vscode/tests/permission-dock-dropdown*" - - "packages/kilo-docs/public/img/screenshot-tests/**" - - ".github/workflows/visual-regression.yml" + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + matched=false + while IFS= read -r file; do + case "$file" in + packages/kilo-ui/*|packages/ui/*|packages/util/*|packages/sdk/js/*|packages/kilo-vscode/webview-ui/*|packages/kilo-vscode/.storybook/*|packages/kilo-vscode/tests/visual-regression*|packages/kilo-vscode/tests/permission-dock-dropdown*|packages/kilo-vscode/tests/accessibility*|packages/kilo-docs/public/img/screenshot-tests/*|.github/workflows/visual-regression.yml) + matched=true + break + ;; + esac + done < <(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --jq '.[].filename') + echo "matched=$matched" >> "$GITHUB_OUTPUT" + echo "matched=$matched" + # kilocode_change end - name: Check if PR is from a fork id: fork-check run: | @@ -207,6 +211,10 @@ jobs: name: Visual Regression (kilo-vscode webview) runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 15 + # kilocode_change start + env: + NODE_OPTIONS: --max-old-space-size=4096 + # kilocode_change end steps: - name: Checkout (internal) @@ -276,13 +284,18 @@ jobs: if: steps.storybook-cache-vscode.outputs.cache-hit != 'true' run: bun run build-storybook working-directory: packages/kilo-vscode + # kilocode_change start + env: + NODE_OPTIONS: --max-old-space-size=4096 + # kilocode_change end - - name: Generate baselines for new/missing stories + - name: Generate baselines and enforce webview accessibility checks # kilocode_change run: bun run test:visual:update working-directory: packages/kilo-vscode env: CI: true PLAYWRIGHT_WORKERS: "4" + NODE_OPTIONS: --max-old-space-size=4096 # kilocode_change - name: Remove stale baselines for deleted stories run: | diff --git a/.github/workflows/watch-opencode-releases.yml b/.github/workflows/watch-opencode-releases.yml index 8762e9481de..a3d766b7aed 100644 --- a/.github/workflows/watch-opencode-releases.yml +++ b/.github/workflows/watch-opencode-releases.yml @@ -12,7 +12,7 @@ permissions: jobs: check-release: - if: vars.OPENCODE_WATCH_ENABLED != 'false' + if: github.repository == 'Kilo-Org/kilocode' && vars.OPENCODE_WATCH_ENABLED != 'false' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -40,9 +40,12 @@ jobs: env: URL: ${{ secrets.OPENCODE_WATCH_SYNC_URL }} SECRET: ${{ secrets.OPENCODE_WATCH_SYNC_SECRET }} + VERCEL: ${{ secrets.OPENCODE_WATCH_VERCEL_SECRET }} run: | if [ -n "$URL" ] && [ -n "$SECRET" ]; then - curl -sf -X POST "$URL" -H "x-sync-secret: $SECRET" -H "Content-Type: application/json" -d '{}' || true + headers=(-H "x-sync-secret: $SECRET" -H "Content-Type: application/json") + [ -n "$VERCEL" ] && headers+=(-H "x-vercel-protection-bypass: $VERCEL") + curl -sf -X POST "$URL" "${headers[@]}" -d '{}' || true fi - if: steps.fetch.outputs.tag != '' && steps.cache.outputs.cache-hit != 'true' diff --git a/.gitignore b/.gitignore index 812dece5848..5611c39a766 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .worktrees .sst .env +.env.local .idea/* !.idea/gradle.xml !.idea/.run diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000000..cc01a286fb7 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# Fake secret-looking strings used by HTTP recorder redaction tests. +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71 diff --git a/.kilo/plans/1779979921672-mighty-river.md b/.kilo/plans/1779979921672-mighty-river.md new file mode 100644 index 00000000000..0e67f21ce0b --- /dev/null +++ b/.kilo/plans/1779979921672-mighty-river.md @@ -0,0 +1,37 @@ +# Sample Plan With Todos + +## Goal +Create a clear sample plan that breaks work into actionable todos. + +## Todos + +1. Define the objective + - Clarify the desired outcome. + - Identify the target audience. + - List success criteria. + +2. Gather requirements + - Collect relevant inputs. + - Note constraints and assumptions. + - Identify dependencies. + +3. Break down the work + - Split the goal into smaller tasks. + - Prioritize tasks by importance. + - Estimate effort for each task. + +4. Execute the tasks + - Complete the highest-priority items first. + - Track progress as todos are finished. + - Adjust the plan if new information appears. + +5. Review and validate + - Check that all todos are complete. + - Verify the result matches the goal. + - Document any follow-up work. + +## Acceptance Criteria +- The plan has a clear goal. +- The plan includes a list of actionable todos. +- The todos are ordered logically. +- The plan includes a way to confirm completion. diff --git a/.kilo/plans/1779979972859-crisp-comet.md b/.kilo/plans/1779979972859-crisp-comet.md new file mode 100644 index 00000000000..444d71b8dad --- /dev/null +++ b/.kilo/plans/1779979972859-crisp-comet.md @@ -0,0 +1,38 @@ +# Sample Plan With Todos + +## Goal +Create a simple sample plan that demonstrates how to organize work into clear, actionable todos. + +## Implementation Steps + +1. Define the objective + - State the desired outcome in one sentence. + - Identify who the plan is for. + - List the criteria that indicate success. + +2. Gather requirements + - Capture relevant inputs and context. + - Note constraints, assumptions, and dependencies. + - Identify any missing information that may affect execution. + +3. Break down the work + - Split the objective into smaller tasks. + - Order tasks by dependency and priority. + - Keep each todo specific enough to complete independently. + +4. Execute the todos + - Work through the highest-priority tasks first. + - Mark todos complete as progress is made. + - Update the plan if new information changes the scope. + +5. Review the result + - Confirm every todo has been addressed. + - Verify the result matches the original objective. + - Record any follow-up work separately. + +## Acceptance Criteria + +- The plan has a clear goal. +- The plan includes actionable todos. +- The todos are ordered logically. +- The plan includes a review step to confirm completion. diff --git a/.kilo/plans/1779980012659-happy-circuit.md b/.kilo/plans/1779980012659-happy-circuit.md new file mode 100644 index 00000000000..9256b415dd1 --- /dev/null +++ b/.kilo/plans/1779980012659-happy-circuit.md @@ -0,0 +1,37 @@ +# Sample Plan: Add Todo-Based Workflow + +## Goal +Create a small, traceable workflow for implementing a feature with clear todos, validation steps, and completion criteria. + +## Scope +- Identify the affected package or feature area. +- Make the smallest correct code change. +- Add or update tests only where they verify behavior. +- Run the smallest relevant checks before completion. + +## Todos +- [ ] Inspect the relevant files and existing patterns. +- [ ] Confirm whether the change belongs in Kilo-owned code or shared upstream code. +- [ ] Implement the minimal code change. +- [ ] Add or update targeted tests if behavior changes. +- [ ] Run formatting or linting if the touched package requires it. +- [ ] Run the smallest relevant typecheck or test command. +- [ ] Fix any failures introduced by the change. +- [ ] Summarize changed files and verification results. + +## Implementation Notes +- Prefer Kilo-owned directories for Kilo-specific behavior. +- If shared upstream files must be edited, keep the change narrow and add `kilocode_change` markers where required. +- Avoid broad refactors unless they are necessary for the requested behavior. +- Preserve existing style, naming, and package conventions. + +## Verification +- Run the targeted test for the changed behavior when available. +- Run the package typecheck if TypeScript code changes. +- Run any repo-specific guard required by touched files, such as annotation or source-link checks. + +## Completion Criteria +- The requested behavior is implemented. +- Tests or checks relevant to the change pass, or any inability to run them is documented. +- No unrelated files are modified. +- The final response includes a concise summary and verification status. diff --git a/.kilo/plans/1779980572375-proud-river.md b/.kilo/plans/1779980572375-proud-river.md new file mode 100644 index 00000000000..bb790b5fdb7 --- /dev/null +++ b/.kilo/plans/1779980572375-proud-river.md @@ -0,0 +1,35 @@ +# Sample Plan + +## Goal +Create a small example feature or change using a clear implementation workflow and tracked todo list. + +## Scope +- Add or update a minimal code path. +- Add focused tests for the changed behavior. +- Run the smallest relevant verification command. +- Summarize the result and any follow-up work. + +## Todo +- [ ] Inspect the relevant files and existing patterns. +- [ ] Identify the smallest safe implementation approach. +- [ ] Make the code change. +- [ ] Add or update tests for the behavior. +- [ ] Run targeted tests or typecheck. +- [ ] Fix any failures introduced by the change. +- [ ] Provide a concise final summary with verification results. + +## Implementation Notes +- Prefer the smallest correct change over broad refactors. +- Preserve existing style and naming conventions. +- Avoid touching unrelated files. +- If shared upstream-owned files are involved, use the repository’s `kilocode_change` marker guidance. + +## Verification +Run the most specific applicable check for the touched area, such as: +- CLI: `bun test ./path/to/test.ts` from `packages/opencode/` +- VS Code extension: `bun run test:unit` from `packages/kilo-vscode/` +- Cross-package changes: `bun run typecheck` from the repo root + +## Risks +- The sample scope may need adjustment once the real target files are known. +- Verification commands depend on which package is touched. diff --git a/.kilo/plans/1779987162764-misty-rocket.md b/.kilo/plans/1779987162764-misty-rocket.md new file mode 100644 index 00000000000..441e2dedde1 --- /dev/null +++ b/.kilo/plans/1779987162764-misty-rocket.md @@ -0,0 +1,69 @@ +# Plan: Replace Internal SVGLoader Usage + +## Context + +The JetBrains plugin currently imports `com.intellij.util.SVGLoader` in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt` and calls `SVGLoader.load(ByteArrayInputStream(patch()), scale.toFloat())` from `SvgIcon.image(Graphics)`. + +Inspection reports this as an internal API usage because `SVGLoader` and its `load(InputStream, float)` method are marked `@ApiStatus.Internal`. + +I checked the local IntelliJ source reference via `$INTELLIJ_REPO=/Users/kirillk/products/intellij-community`: + +- `platform/util/ui/src/com/intellij/util/SVGLoader.kt` +- `platform/util/ui/src/com/intellij/ui/svg/svg.kt` +- `platform/util/ui/src/com/intellij/ui/svg/jsvg.kt` +- `libraries/jsvg/intellij.libraries.jsvg.iml` + +Important findings: + +- IntelliJ `SVGLoader.load(stream, scale)` delegates to internal `loadSvg(...)` in `com.intellij.ui.svg`. +- The actual renderer uses `com.github.weisj:jsvg:2.1.0` and renders into a `BufferedImage` with antialiasing, bicubic interpolation, and pure stroke control. +- Copying `SVGLoader.kt` directly would keep references to other internal IntelliJ APIs (`IconLoader`, `ScaleContext`, `ImageUtil`, `SvgAttributePatcher`, `createJSvgDocument`, `renderSvgWithSize`, cache classes, etc.) and would be broader than needed. + +## Implementation Plan + +1. Add Kilo-owned SVG rendering code under the frontend UI package, for example: + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgLoader.kt` + - Package: `ai.kilocode.client.ui` + - Suggested internal object name: `SvgLoader` + +2. Keep the copied implementation minimal and purpose-specific: + - Accept `InputStream` and `scale: Float`. + - Parse the SVG with `com.github.weisj.jsvg.parser.SVGLoader().load(stream)` or the equivalent public jsvg API available in version `2.1.0`. + - Determine image dimensions from the root SVG `width` and `height` already parsed by existing `SvgIconColorizer.size(...)`, or by passing dimensions into the loader if the public jsvg API does not expose reliable intrinsic size. + - Render into `BufferedImage(width * scale, height * scale, BufferedImage.TYPE_INT_ARGB)` using `Graphics2D` rendering hints matching IntelliJ’s `jsvg.kt`: + - `KEY_ANTIALIASING = VALUE_ANTIALIAS_ON` + - `KEY_INTERPOLATION = VALUE_INTERPOLATION_BICUBIC` + - `KEY_STROKE_CONTROL = VALUE_STROKE_PURE` + - Return `Image`/`BufferedImage`. + +3. If jsvg does not expose intrinsic size cleanly from public API, adjust the API to avoid duplicating SVG parsing: + - Add `width` and `height` parameters to the Kilo loader. + - In `SvgIcon.image(Graphics)`, call the loader with the already-computed `size.first` and `size.second`. + - This is the likely smallest stable solution because current icons already require explicit `width` and `height` for `SvgIconColorizer.size(...)`. + +4. Add the jsvg dependency explicitly to the plugin frontend module instead of relying on the IntelliJ-bundled library: + - In `packages/kilo-jetbrains/gradle/libs.versions.toml`, add `jsvg = "2.1.0"` and `jsvg = { module = "com.github.weisj:jsvg", version.ref = "jsvg" }`. + - In `packages/kilo-jetbrains/frontend/build.gradle.kts`, add `implementation(libs.jsvg)`. + - This follows the package instruction to bundle third-party libraries with the plugin rather than relying on IDE-bundled versions. + +5. Update `SvgIconColorizer.kt`: + - Remove `import com.intellij.util.SVGLoader`. + - Replace `SVGLoader.load(ByteArrayInputStream(patch()), scale.toFloat())` with the Kilo-owned loader call. + - Keep the existing color patching, cache key, scale calculation, and size parsing intact. + +6. Add a focused frontend unit test: + - New test file: `frontend/src/test/kotlin/ai/kilocode/client/ui/SvgIconColorizerTest.kt` or `SvgLoaderTest.kt`. + - Exercise the actual loader/colorizer with a small SVG resource or inline SVG bytes. + - Assert that rendering returns a non-empty image at scale `1f` and `2f`, and that scaled dimensions are correct. + - If testing the colorizer, paint the icon into a `BufferedImage` and verify at least one expected patched pixel color appears. + +7. Run verification from `packages/kilo-jetbrains/`: + - `./gradlew typecheck` + - A targeted frontend test if Gradle supports one cleanly, otherwise `./gradlew test`. + - The requested DevKit internal API inspection is IDE-based; note in final response that the code no longer references `com.intellij.util.SVGLoader`, and typecheck/tests were run. + +## Notes And Risks + +- The user said “copy SaveLoaer”; I interpret this as “copy `SVGLoader`”. The implementation should copy the relevant rendering behavior, not IntelliJ’s full internal `SVGLoader` object, because the full source depends on multiple other internal APIs and cache infrastructure. +- This change is in `packages/kilo-jetbrains/`, a Kilo-owned package, so `kilocode_change` markers are not needed. +- jsvg package names and constructors should be confirmed during implementation against the downloaded dependency/API available to Gradle. If the API differs from IntelliJ source usage, inspect the source jar or compile error and adapt the minimal wrapper accordingly. diff --git a/.kilo/plans/1779987392284-clever-river.md b/.kilo/plans/1779987392284-clever-river.md new file mode 100644 index 00000000000..bc4180b51f9 --- /dev/null +++ b/.kilo/plans/1779987392284-clever-river.md @@ -0,0 +1,60 @@ +# Remove JetBrains SVG Runtime Colorization + +## Goal +Stop runtime SVG recoloring in the JetBrains plugin and rely on IntelliJ's standard light/dark icon asset resolution. Ensure every SVG asset used by the plugin has a light/dark pair. + +## Findings +- Runtime colorization exists only in `frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt`. +- The only production caller is `frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt`. +- `ScrollButtonIcon` colorizes two icons at runtime: + - `/icons/scroll-bottom.svg` + - `/icons/scroll-question.svg` +- Both already have dark variants: + - `scroll-bottom_dark.svg` + - `scroll-question_dark.svg` +- Other used frontend icons already have light/dark pairs: + - `send.svg` / `send_dark.svg` + - `stop.svg` / `stop_dark.svg` + - `shield.svg` / `shield_dark.svg` + - `shield-filled.svg` / `shield-filled_dark.svg` + - `compress.svg` / `compress_dark.svg` + - `chevron-down.svg` / `chevron-down_dark.svg` + - `arrow-up.svg` / `arrow-up_dark.svg` + - `arrow-down-to-line.svg` / `arrow-down-to-line_dark.svg` + - `kilo.svg` / `kilo_dark.svg` + - `kilo-content.svg` / `kilo-content_dark.svg` + - `plus.svg` / `plus_dark.svg` exists, though no current direct source reference was found. + - `kilo@20x20.svg` / `kilo@20x20_dark.svg` exists, though no current direct source reference was found. +- `src/main/resources/META-INF/pluginIcon.svg` is present and has no `pluginIcon_dark.svg`. It is not referenced in source/XML, but JetBrains treats this filename conventionally as plugin metadata/marketplace icon, so it should be paired for completeness. +- The SVG assets use literal colors and no `currentColor`, ` + + ) +} diff --git a/packages/kilo-docs/components/TableOfContents.tsx b/packages/kilo-docs/components/TableOfContents.tsx index c6b3f5b723f..f0e9157eddc 100644 --- a/packages/kilo-docs/components/TableOfContents.tsx +++ b/packages/kilo-docs/components/TableOfContents.tsx @@ -1,16 +1,46 @@ import React, { useState, useEffect } from "react" import Link from "next/link" +const TAB_SYNC_EVENT = "kilo-tab-select" + +function slugify(label: string) { + return label + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") +} + export function TableOfContents({ toc }) { - const items = toc.filter((item) => item.id && (item.level === 2 || item.level === 3)) + const [tab, setTab] = useState("") const [activeHash, setActiveHash] = useState("") + const items = toc.filter((item) => { + if (!item.id || (item.level !== 2 && item.level !== 3)) return false + + return !item.tab || item.tab.slug === tab + }) useEffect(() => { - const updateHash = () => setActiveHash(window.location.hash) - updateHash() - window.addEventListener("hashchange", updateHash) - return () => window.removeEventListener("hashchange", updateHash) - }, []) + const update = () => { + const hash = window.location.hash.slice(1) + const item = toc.find((entry) => entry.id === hash && entry.tab) + setActiveHash(window.location.hash) + setTab(item?.tab?.slug ?? (toc.some((entry) => entry.tab?.slug === hash) ? hash : "")) + } + + const sync = (e: Event) => { + const label = (e as CustomEvent).detail + setActiveHash(window.location.hash) + setTab(slugify(label)) + } + + update() + window.addEventListener("hashchange", update) + window.addEventListener(TAB_SYNC_EVENT, sync) + return () => { + window.removeEventListener("hashchange", update) + window.removeEventListener(TAB_SYNC_EVENT, sync) + } + }, [toc]) if (items.length <= 1) { return null @@ -22,14 +52,27 @@ export function TableOfContents({ toc }) { {items.map((item) => { const href = `#${item.id}` const active = activeHash === href + const select = (e: React.MouseEvent) => { + if (!item.tab) return + + e.preventDefault() + window.dispatchEvent(new CustomEvent(TAB_SYNC_EVENT, { detail: item.tab.label })) + setActiveHash(href) + setTab(item.tab.slug) + history.pushState(null, "", href) + requestAnimationFrame(() => document.getElementById(item.id)?.scrollIntoView()) + } + return (
  • - {item.title} + + {item.title} +
  • ) })} diff --git a/packages/kilo-docs/components/Tabs.tsx b/packages/kilo-docs/components/Tabs.tsx index ecffe35dc6d..d826b818c2f 100644 --- a/packages/kilo-docs/components/Tabs.tsx +++ b/packages/kilo-docs/components/Tabs.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, Children, isValidElement, ReactNode, ReactElement } from "react" +import React, { useState, useEffect, useRef, Children, isValidElement, ReactNode, ReactElement } from "react" const TAB_SYNC_EVENT = "kilo-tab-select" @@ -18,6 +18,17 @@ function slugify(label: string) { .replace(/[^a-z0-9-]/g, "") } +function contains(node: ReactNode, hash: string): boolean { + return Children.toArray(node).some((child) => { + if (!isValidElement(child)) return false + + const props = child.props as { children?: ReactNode; id?: string } + if (props.id === hash) return true + + return contains(props.children, hash) + }) +} + export function Tab({ children }: TabProps) { return <>{children} } @@ -32,19 +43,31 @@ export function Tabs({ children }: TabsProps) { const hash = window.location.hash.slice(1) if (!hash) return 0 const found = tabs.findIndex((tab) => slugify(tab.props.label) === hash) - return found >= 0 ? found : 0 + if (found >= 0) return found + + const child = tabs.findIndex((tab) => contains(tab.props.children, hash)) + return child >= 0 ? child : 0 } const [activeIndex, setActiveIndex] = useState(0) + const scroll = useRef(false) useEffect(() => { - setActiveIndex(indexFromHash()) - const onHashChange = () => setActiveIndex(indexFromHash()) + const activate = () => { + const hash = window.location.hash.slice(1) + const index = indexFromHash() + scroll.current = Boolean(hash && contains(tabs[index]?.props.children, hash)) + setActiveIndex(index) + } + + activate() + const onHashChange = activate window.addEventListener("hashchange", onHashChange) const onSync = (e: Event) => { const label = (e as CustomEvent).detail const found = tabs.findIndex((tab) => tab.props.label === label) + scroll.current = false if (found >= 0) setActiveIndex(found) } window.addEventListener(TAB_SYNC_EVENT, onSync) @@ -55,6 +78,14 @@ export function Tabs({ children }: TabsProps) { } }, []) + useEffect(() => { + const hash = window.location.hash.slice(1) + if (!hash || !scroll.current) return + + scroll.current = false + requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView()) + }, [activeIndex]) + const selectTab = (index: number) => { setActiveIndex(index) const label = tabs[index].props.label diff --git a/packages/kilo-docs/components/index.js b/packages/kilo-docs/components/index.js index 95694ac103c..26fed7dc325 100644 --- a/packages/kilo-docs/components/index.js +++ b/packages/kilo-docs/components/index.js @@ -2,6 +2,7 @@ export * from "./Callout" export * from "./CodeBlock" export * from "./Codicon" export * from "./CopyPageButton" +export * from "./CopyLine" export * from "./Heading" export * from "./Icon" export * from "./Image" diff --git a/packages/kilo-docs/docs/getting-started/switching-from-cline.md b/packages/kilo-docs/docs/getting-started/switching-from-cline.md index 8f04bb864ca..2c7dcd6be0c 100644 --- a/packages/kilo-docs/docs/getting-started/switching-from-cline.md +++ b/packages/kilo-docs/docs/getting-started/switching-from-cline.md @@ -202,9 +202,9 @@ One-click deployments from directly within Kilo. Go from code to production with Automatically analyzes your PRs using your choice of AI model. Reviews happen the moment a PR is opened or updated, covering performance, security, style, and test coverage. -### Managed Indexing +### Codebase Indexing -Semantic search across your repositories using cloud-hosted embeddings. Kilo indexes your codebase to deliver more relevant, context-aware responses. +Semantic search across your repositories using configurable embedding providers and vector stores. Kilo indexes your codebase to deliver more relevant, context-aware responses. ### Autocomplete diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts index 408598f0f52..0711ee81510 100644 --- a/packages/kilo-docs/lib/nav/ai-providers.ts +++ b/packages/kilo-docs/lib/nav/ai-providers.ts @@ -55,6 +55,7 @@ export const AiProvidersNav: NavSection[] = [ links: [ { href: "/ai-providers/ollama", children: "Ollama" }, { href: "/ai-providers/lmstudio", children: "LM Studio" }, + { href: "/ai-providers/atomic-chat", children: "Atomic Chat" }, { href: "/ai-providers/vscode-lm", children: "VS Code LM API" }, { href: "/ai-providers/openai-compatible", diff --git a/packages/kilo-docs/lib/nav/contributing.ts b/packages/kilo-docs/lib/nav/contributing.ts index 14f4dae5e63..a0c6404ceac 100644 --- a/packages/kilo-docs/lib/nav/contributing.ts +++ b/packages/kilo-docs/lib/nav/contributing.ts @@ -1,10 +1,10 @@ -import { NavSection } from "../types" +import type { NavSection } from "../types" export const ContributingNav: NavSection[] = [ { title: "Getting Started", links: [ - { href: "/contributing", children: "Contributing Overview" }, + { href: "/contributing", children: "Overview" }, { href: "/contributing/development-environment", children: "Development Environment", @@ -20,61 +20,69 @@ export const ContributingNav: NavSection[] = [ links: [ { href: "/contributing/architecture", - children: "Architecture Overview", - }, - { - href: "/contributing/architecture/features", - children: "Features", - subLinks: [ - { - href: "/contributing/architecture/agent-observability", - children: "Agent Observability", - }, - { - href: "/contributing/architecture/auto-model-tiers", - children: "Auto Model Tiers", - }, - { - href: "/contributing/architecture/benchmarking", - children: "Benchmarking", - }, - { - href: "/contributing/architecture/config-schema", - children: "CLI Config Schema", - }, - { - href: "/contributing/architecture/enterprise-mcp-controls", - children: "Enterprise MCP Controls", - }, - { - href: "/contributing/architecture/mcp-oauth-authorization", - children: "MCP OAuth Authorization", - }, - { - href: "/contributing/architecture/onboarding-improvements", - children: "Onboarding Improvements", - }, - { - href: "/contributing/architecture/organization-modes-library", - children: "Organization Modes Library", - }, - { - href: "/deploy-secure/security-reviews", - children: "Agentic Security Reviews", - }, - { - href: "/contributing/architecture/track-repo-url", - children: "Track Repo URL", - }, - { - href: "/contributing/architecture/voice-transcription", - children: "Voice Transcription", - }, - { - href: "/contributing/architecture/per-message-feedback", - children: "Per-Message Feedback", - }, - ], + children: "Overview", + }, + { + href: "/contributing/architecture/cli-runtime", + children: "CLI Runtime", + }, + { + href: "/contributing/architecture/vscode-extension", + children: "VS Code Extension", + }, + { + href: "/contributing/architecture/jetbrains-plugin", + children: "JetBrains Plugin", + }, + { + href: "/contributing/architecture/cloud-platform", + children: "Cloud Platform", + }, + { + href: "/contributing/architecture/automation-services", + children: "Automation Services", + }, + { + href: "/contributing/architecture/cloud-security", + children: "Cloud Security", + }, + ], + }, + { + title: "Development", + links: [ + { + href: "/contributing/architecture/development-patterns", + children: "Development Patterns", + }, + { + href: "/contributing/architecture/config-schema", + children: "CLI Config Schema", + }, + ], + }, + { + title: "Feature Proposals", + links: [ + { + href: "/contributing/features", + children: "Overview", + }, + { + href: "/contributing/features/enterprise-mcp-controls", + children: "Enterprise MCP Controls", + }, + { + href: "/contributing/features/onboarding-improvements", + children: "Onboarding Improvements", + }, + { + href: "/contributing/features/agent-observability", + children: "Agent Observability", + }, + { + href: "/contributing/features/benchmarking", + children: "Benchmarking", }, ], }, diff --git a/packages/kilo-docs/lib/nav/customize.ts b/packages/kilo-docs/lib/nav/customize.ts index 83006b9e1bd..a3bd302034b 100644 --- a/packages/kilo-docs/lib/nav/customize.ts +++ b/packages/kilo-docs/lib/nav/customize.ts @@ -22,6 +22,11 @@ export const CustomizeNav: NavSection[] = [ children: "Custom Subagents", platform: "new", }, + { + href: "/customize/agent-permissions", + children: "Agent Permissions", + platform: "new", + }, { href: "/customize/agents-md", children: "agents.md" }, { href: "/customize/workflows", children: "Workflows", platform: "new" }, { href: "/customize/skills", children: "Skills" }, diff --git a/packages/kilo-docs/lib/nav/deploy-secure.ts b/packages/kilo-docs/lib/nav/deploy-secure.ts index 7c41f137996..5120c5f5fb2 100644 --- a/packages/kilo-docs/lib/nav/deploy-secure.ts +++ b/packages/kilo-docs/lib/nav/deploy-secure.ts @@ -6,7 +6,6 @@ export const DeploySecureNav: NavSection[] = [ links: [ { href: "/deploy-secure", children: "Overview" }, { href: "/deploy-secure/deploy", children: "Deploy" }, - { href: "/deploy-secure/managed-indexing", children: "Managed Indexing" }, ], }, { diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts index 5d051fb7255..d7bed669f1f 100644 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ b/packages/kilo-docs/lib/nav/kiloclaw.ts @@ -30,11 +30,17 @@ export const KiloClawNav: NavSection[] = [ }, { href: "/kiloclaw/development-tools", - children: "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" }, + { href: "/kiloclaw/development-tools/composio", children: "Composio" }, + { href: "/kiloclaw/tools/1password", children: "1Password" }, + { href: "/kiloclaw/tools/brave-search", children: "Brave Search" }, + { href: "/kiloclaw/tools/agentcard", children: "AgentCard" }, + { href: "/kiloclaw/tools/other-tools", children: "Other Tools" }, ], }, { @@ -46,17 +52,6 @@ export const KiloClawNav: NavSection[] = [ { href: "/kiloclaw/triggers/scheduled", children: "Scheduled" }, ], }, - { - href: "/kiloclaw/tools", - children: "Tools", - subLinks: [ - { href: "/kiloclaw/tools", children: "Overview" }, - { href: "/kiloclaw/tools/1password", children: "1Password" }, - { href: "/kiloclaw/tools/brave-search", children: "Brave Search" }, - { href: "/kiloclaw/tools/agentcard", children: "AgentCard" }, - { href: "/kiloclaw/tools/other-tools", children: "Other Tools" }, - ], - }, { href: "/kiloclaw/troubleshooting/common-questions", children: "Troubleshooting", diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 1958c7e5ce5..fe1be68c3a2 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -10,6 +10,7 @@ exclude_path = ["node_modules"] accept = [ "200", + "202", "403", "429", "301", @@ -23,14 +24,19 @@ exclude = [ 'https://console.groq.com/pages/models', '^https?://([A-Za-z0-9-]+\.)?glama\.ai(/|$)', '^https?://(api|console)\.mistral\.ai', + '^https?://codestral\.mistral\.ai/v1/fim/completions/?$', + '^https?://api\.inceptionlabs\.ai/v1/fim/completions/?$', '^https?://(app|console)\.requesty\.ai', '^https?://(cloud|console)\.google\.ai', # API endpoint prefixes are valid but return 4xx to plain GET link checks. '^https?://ai-gateway\.vercel\.sh/v1/?$', '^https?://api\.voyageai\.com/v1/embeddings/?$', + '^https?://inference\.do-ai\.run/v1/?$', '^https?://generativelanguage\.googleapis\.com/v1beta/openai/?$', - # xAI API and OAuth endpoints require request parameters or non-GET methods. + '^https?://search\.parallel\.ai/mcp/?$', + # xAI API and OAuth endpoints require request parameters or reject plain link checks. '^https?://api\.x\.ai/v1/?$', + '^https?://accounts\.x\.ai/?$', '^https?://auth\.x\.ai/?$', '^https?://auth\.x\.ai/oauth2/(authorize|device/code|token)/?$', 'https://opencode.ai/pages/config', diff --git a/packages/kilo-docs/mappingplan.md b/packages/kilo-docs/mappingplan.md index d94063a4f67..7131580834f 100644 --- a/packages/kilo-docs/mappingplan.md +++ b/packages/kilo-docs/mappingplan.md @@ -113,7 +113,6 @@ | New Item | Existing Page(s) | |---|---| | Deploy | `advanced-usage/deploy` | -| Managed Indexing | `advanced-usage/managed-indexing` | | Security Reviews | `contributing/architecture/security-reviews` (move out of contributing) | --- diff --git a/packages/kilo-docs/markdoc/nodes/heading.markdoc.ts b/packages/kilo-docs/markdoc/nodes/heading.markdoc.ts index 862c179dcd4..ca17196bb5d 100644 --- a/packages/kilo-docs/markdoc/nodes/heading.markdoc.ts +++ b/packages/kilo-docs/markdoc/nodes/heading.markdoc.ts @@ -2,16 +2,17 @@ import { Tag } from "@markdoc/markdoc" import { Heading } from "../../components" +function text(child) { + if (typeof child === "string") return child + if (Tag.isTag(child)) return child.children.map(text).join(" ") + return "" +} + function generateID(children, attributes) { if (attributes.id && typeof attributes.id === "string") { return attributes.id } - return children - .filter((child) => typeof child === "string") - .join(" ") - .replace(/[?]/g, "") - .replace(/\s+/g, "-") - .toLowerCase() + return children.map(text).join(" ").replace(/[?/]/g, "").replace(/\s+/g, "-").toLowerCase() } export const heading = { diff --git a/packages/kilo-docs/markdoc/partials/cli-commands-table.md b/packages/kilo-docs/markdoc/partials/cli-commands-table.md index e2ae23c4c5e..65bfbedcf2c 100644 --- a/packages/kilo-docs/markdoc/partials/cli-commands-table.md +++ b/packages/kilo-docs/markdoc/partials/cli-commands-table.md @@ -13,14 +13,19 @@ | `kilo upgrade [target]` | upgrade kilo to the latest or a specific version | | `kilo uninstall` | uninstall kilo and remove all related files | | `kilo serve` | starts a headless kilo server | +| `kilo web` | start kilo server and open web interface | | `kilo models [provider]` | list all available models | | `kilo roll-call ` | batch-test text models matching a filter for connectivity and latency | +| `kilo profile` | show Kilo account profile | | `kilo stats` | show token usage and cost statistics | | `kilo export [sessionID]` | export session data as JSON | | `kilo import ` | import session data from JSON file or URL | +| `kilo github` | manage GitHub agent | | `kilo pr ` | fetch and checkout a GitHub PR branch, then run kilo | | `kilo session` | manage sessions | | `kilo remote` | enable remote connection for real-time session relay | +| `kilo daemon` | manage the local kilo daemon | +| `kilo console` | open or stop the local Kilo Console | | `kilo db` | database tools | | `kilo config` | configuration tools | | `kilo plugin ` | install plugin and update config | diff --git a/packages/kilo-docs/markdoc/partials/install-jetbrains.md b/packages/kilo-docs/markdoc/partials/install-jetbrains.md index a64c1c1974e..564946254c6 100644 --- a/packages/kilo-docs/markdoc/partials/install-jetbrains.md +++ b/packages/kilo-docs/markdoc/partials/install-jetbrains.md @@ -11,7 +11,10 @@ Before installing the Kilo Code plugin, ensure you have: 2. **Node.js:** - Download LTS version from [https://nodejs.org/](https://nodejs.org/) - - Required for the extension's backend services + +{% callout type="tip" %} +Try the [v7 Early Access Program plugin](#jetbrains-early-access) for a JetBrains-native experience that does not require Node.js or manual API key configuration. +{% /callout %} ### Install directly @@ -28,6 +31,31 @@ Before installing the Kilo Code plugin, ensure you have: 4. Search for "Kilo Code" 5. Click **Install** and restart your IDE +### Try the v7 Early Access Program plugin {% #jetbrains-early-access %} + +The v7 EAP plugin is available for users who want to try the newest JetBrains experience before it reaches the default Marketplace channel. It uses a JetBrains-native UI and is designed to work well with JetBrains remote development. + +Follow the [v7 roadmap and release milestone](https://github.com/Kilo-Org/kilocode/milestone/1) for planned work and release progress. + +{% callout type="info" %} +The v7 EAP plugin is compatible with JetBrains IDE builds 261 and later. EAP builds update frequently, so we recommend enabling automatic plugin updates in your JetBrains IDE from **Settings/Preferences → System Settings → Updates → Update plugins automatically**. Share feedback in the JetBrains channel on the [Kilo Discord](https://kilo.ai/discord). +{% /callout %} + +To install the EAP build and receive updates: + +1. Open IntelliJ IDEA or another JetBrains IDE +2. Go to **Settings/Preferences → Plugins** +3. Click the gear icon and choose **Manage Plugin Repositories** +4. Add this repository URL: + +{% copyLine text="https://plugins.jetbrains.com/plugins/list?channel=eap&pluginId=28350" /%} + +5. Return to the **Marketplace** tab +6. Search for **Kilo Code** +7. Click **Install** or **Update** and restart your IDE if prompted + +After the custom repository is added, JetBrains will offer EAP updates through the normal plugin update flow. + ### Supported IDEs - IntelliJ IDEA diff --git a/packages/kilo-docs/markdoc/tags/copy-line.markdoc.ts b/packages/kilo-docs/markdoc/tags/copy-line.markdoc.ts new file mode 100644 index 00000000000..51ef53e931f --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/copy-line.markdoc.ts @@ -0,0 +1,13 @@ +import { CopyLine } from "../../components" + +export const copyLine = { + render: CopyLine, + selfClosing: true, + attributes: { + text: { + type: String, + required: true, + description: "The exact one-line text to display and copy", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/index.ts b/packages/kilo-docs/markdoc/tags/index.ts index 6fc54c6d902..7e2a84d4f17 100644 --- a/packages/kilo-docs/markdoc/tags/index.ts +++ b/packages/kilo-docs/markdoc/tags/index.ts @@ -1,6 +1,7 @@ /* Use this file to export your markdoc tags */ export * from "./callout.markdoc" export * from "./codicon.markdoc" +export * from "./copy-line.markdoc" export * from "./tabs.markdoc" export * from "./icon.markdoc" export * from "./image.markdoc" @@ -10,3 +11,4 @@ export * from "./video.markdoc" export * from "./youtube.markdoc" export * from "./flow-diagram.markdoc" export * from "./browser-frame.markdoc" +export * from "./linebreak.markdoc" diff --git a/packages/kilo-docs/markdoc/tags/linebreak.markdoc.ts b/packages/kilo-docs/markdoc/tags/linebreak.markdoc.ts new file mode 100644 index 00000000000..7be1c9c14d5 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/linebreak.markdoc.ts @@ -0,0 +1,4 @@ +export const linebreak = { + render: "br", + selfClosing: true, +} diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 9b0d75f6267..a631b4b5a35 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,12 +1,14 @@ { "name": "@kilocode/kilo-docs", - "version": "7.3.8", + "version": "7.3.54", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", "build": "next build --webpack", + "lint": "bun run --cwd ../.. lint packages/kilo-docs", "start": "next start", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@docsearch/css": "^4", @@ -32,7 +34,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", - "vitest": "^3.2.3" + "vitest": "4.1.0" }, "peerDependencies": {} } diff --git a/packages/kilo-docs/pages/_app.tsx b/packages/kilo-docs/pages/_app.tsx index 2739752f3a6..0a84746cd72 100644 --- a/packages/kilo-docs/pages/_app.tsx +++ b/packages/kilo-docs/pages/_app.tsx @@ -28,27 +28,38 @@ import type { MarkdocNextJsPageProps } from "@markdoc/next.js" const TITLE = "Kilo Code Documentation" const DESCRIPTION = "Build, ship, and iterate faster with the most popular open source coding agent." -function collectHeadings(node, sections = []) { +function slugify(label) { + return label + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") +} + +function collectHeadings(node, sections = [], tab = undefined) { if (node) { - // Skip headings inside tabs - they're not always visible if (node.name === "Tabs") { + for (const child of node.children || []) { + const label = child.attributes?.label + collectHeadings(child, sections, typeof label === "string" ? { label, slug: slugify(label) } : undefined) + } return sections } if (node.name === "Heading") { - const title = node.children[0] + const title = typeof node.children[0] === "string" ? node.children[0].trim() : node.children[0] if (typeof title === "string") { sections.push({ ...node.attributes, title, + tab, }) } } if (node.children) { for (const child of node.children) { - collectHeadings(child, sections) + collectHeadings(child, sections, tab) } } } diff --git a/packages/kilo-docs/pages/ai-providers/atomic-chat.md b/packages/kilo-docs/pages/ai-providers/atomic-chat.md new file mode 100644 index 00000000000..efa3189a6b7 --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/atomic-chat.md @@ -0,0 +1,112 @@ +--- +title: "Using Atomic Chat with Kilo Code | Local LLMs" +description: "Run local models in Kilo Code via Atomic Chat's OpenAI-compatible API. Setup for VS Code and the CLI." +sidebar_label: Atomic Chat +--- + +# Using Atomic Chat With Kilo Code + +[Kilo Code](https://kilocode.ai/) supports [Atomic Chat](https://atomic.chat/) as a local provider. Atomic Chat runs models on your machine and exposes an OpenAI-compatible API (default `http://127.0.0.1:1337/v1`). + +**Website:** [https://atomic.chat/](https://atomic.chat/) +**Repository:** [https://github.com/AtomicBot-ai/Atomic-Chat](https://github.com/AtomicBot-ai/Atomic-Chat) + +## Prerequisites + +1. Install [Atomic Chat](https://atomic.chat/) (macOS or Windows). +2. Download and load a model in the app. +3. Enable the **local API server** (default port **1337**). +4. Confirm the API responds: + +```bash +curl http://127.0.0.1:1337/v1/models +``` + +## Configuration in Kilo Code + +Kilo Code ships the `@kilocode/plugin-atomic-chat` plugin by default. It **does not** call localhost unless you opt in (see below). When enabled, it discovers models from `GET /v1/models` and can warn if the selected model is not loaded. + +**Localhost HTTP runs only when one of these is true:** + +- You configure `provider.atomic-chat` in `kilo.jsonc` +- You set `"model": "atomic-chat/..."` (or per-agent model uses `atomic-chat`) +- You enable optional auto-detect: `"atomicChat": { "autoDetect": true }` (probes ports **1337** and **1338**) + +Otherwise no requests are made to Atomic Chat (suitable for restricted environments). + +{% tabs %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) → **Providers** → **Atomic Chat**. No API key is required for the default local server. Adjust the base URL if Atomic Chat uses a non-default host or port. + +{% /tab %} +{% tab label="CLI" %} + +**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`): + +```jsonc +{ + "provider": { + "atomic-chat": { + "options": { + "baseURL": "http://127.0.0.1:1337/v1", + }, + }, + }, +} +``` + +Set your default model (use an id from `curl http://127.0.0.1:1337/v1/models`): + +```jsonc +{ + "model": "atomic-chat/gemma-4-E4B-it-IQ4_XS", +} +``` + +Optional auto-detect without a provider block: + +```jsonc +{ + "atomicChat": { "autoDetect": true }, +} +``` + +To disable the provider entirely, use `disabled_providers: ["atomic-chat"]` or remove `@kilocode/plugin-atomic-chat` from the `plugin` array in your config. + +{% /tab %} +{% /tabs %} + +## Custom or unlisted models + +If a loaded model does not appear in the picker, register it under `provider.atomic-chat.models`: + +```jsonc +{ + "model": "atomic-chat/my-local-model", + "provider": { + "atomic-chat": { + "models": { + "my-local-model": { + "id": "exact-id-from-v1-models", + "name": "My Local Model", + }, + }, + }, + }, +} +``` + +See [Custom Models](/docs/code-with-ai/agents/custom-models) for all model fields. + +## Tips + +- Prefer capable models with large context windows; agent workflows use long prompts. +- Keep only the models you need loaded in Atomic Chat to save memory. +- For embeddings via Atomic Chat, use the **openai-compatible** indexing provider with the same base URL. + +## Related + +- [LM Studio](/docs/ai-providers/lmstudio) +- [Ollama](/docs/ai-providers/ollama) +- [Local models overview](/docs/automate/extending/local-models) diff --git a/packages/kilo-docs/pages/ai-providers/index.md b/packages/kilo-docs/pages/ai-providers/index.md index a7c8c05fee3..ef843690443 100644 --- a/packages/kilo-docs/pages/ai-providers/index.md +++ b/packages/kilo-docs/pages/ai-providers/index.md @@ -33,6 +33,7 @@ Major AI companies offering powerful models via API: Run models on your own hardware for privacy and offline use: +- **[Atomic Chat](/docs/ai-providers/atomic-chat)** - Local models with TurboQuant inference and auto-discovery in Kilo Code - **[Ollama](/docs/ai-providers/ollama)** - Easy local model management - **[LM Studio](/docs/ai-providers/lmstudio)** - Desktop app for local models - **[OpenAI Compatible](/docs/ai-providers/openai-compatible)** - Any OpenAI-compatible endpoint diff --git a/packages/kilo-docs/pages/ai-providers/minimax.md b/packages/kilo-docs/pages/ai-providers/minimax.md index 4641f5d136b..b2643de58fa 100644 --- a/packages/kilo-docs/pages/ai-providers/minimax.md +++ b/packages/kilo-docs/pages/ai-providers/minimax.md @@ -12,7 +12,7 @@ MiniMax is a global AI foundation model company focused on fast, cost-efficient ## Getting an API Key -1. **Sign Up/Sign In:** Go to the [MiniMax Console](https://platform.minimax.io/). Create an account or sign in. +1. **Sign Up/Sign In:** Go to the [MiniMax Console](https://platform.minimax.io/console/access). Create an account or sign in. 2. **Open the API Keys Page:** Navigate to your **Profile > API Keys**. 3. **Create a Key:** Click to generate a new API key and give it a descriptive name (e.g., "Kilo Code"). 4. **Copy the Key:** Copy the key immediately. You may not be able to view it again. Store it securely. diff --git a/packages/kilo-docs/pages/ai-providers/mistral.md b/packages/kilo-docs/pages/ai-providers/mistral.md index 684191febc1..422323ccf8c 100644 --- a/packages/kilo-docs/pages/ai-providers/mistral.md +++ b/packages/kilo-docs/pages/ai-providers/mistral.md @@ -72,11 +72,11 @@ Then set your default model: Mistral's adjustable reasoning support is exposed only for reasoning-capable Mistral Small 4 models: `mistral-small-2603` and `mistral-small-latest`. When one of these models is selected, Kilo offers a `high` variant that sends `reasoningEffort: "high"` to the Mistral provider. -Other Mistral models do not get automatic reasoning variants, even if they appear in the same provider. See Mistral's [adjustable reasoning documentation](https://docs.mistral.ai/capabilities/reasoning/adjustable) for provider-level details. +Other Mistral models do not get automatic reasoning variants, even if they appear in the same provider. See Mistral's [reasoning documentation](https://docs.mistral.ai/studio-api/conversations/reasoning) for provider-level details. ## Using Codestral -[Codestral](https://docs.mistral.ai/capabilities/code_generation/) is a model specifically designed for code generation and interaction. +[Codestral](https://docs.mistral.ai/vibe/code/overview) is a model specifically designed for code generation and interaction. Only for Codestral you could use different endpoints (Default: codestral.mistral.ai). For the La Platforme API Key change the **Codestral Base Url** to: https://api.mistral.ai diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 02af740576e..39bf874c40f 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -59,7 +59,8 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon - **Provider ID** — A unique identifier (e.g., `my-provider`). - **Display name** — A human-readable name shown in the UI. -- **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. For Azure OpenAI GPT-5, use the native `azure` provider instead. +- **Provider API** — Select **OpenAI Compatible** for an OpenAI Chat Completions-compatible endpoint. Use **OpenAI Responses** for OpenAI and xAI models. Use **Anthropic Messages** for Anthropic and MiniMax models. +- **Base URL** — The provider's API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL exposes an OpenAI-compatible models endpoint. For Azure OpenAI GPT-5, use the native `azure` provider instead. - **API key** — Your API key. Optional — leave empty if authentication is handled via headers. - **Models** — Add models manually or select from the auto-fetched list (see [Automatic Model Detection](#automatic-model-detection) below). - **Headers** (optional) — Custom HTTP headers as key-value pairs. @@ -91,6 +92,7 @@ You must define at least one model. Setting `name` and `limit` (context window a { "provider": { "vllm": { + "npm": "@ai-sdk/openai-compatible", "models": { "qwen35": { "name": "Qwen 3.5", @@ -119,8 +121,9 @@ Then set your default model using the `provider-id/model-id` format: **Configuration fields:** +- **`npm`** — The API protocol package. Use `@ai-sdk/openai-compatible` for OpenAI Chat Completions-compatible endpoints (the default when omitted). Other possible values include `@ai-sdk/openai` for OpenAI Responses endpoints and `@ai-sdk/anthropic` for Anthropic Messages endpoints. - **`models`** — A map of model IDs to model definitions. Each model should include a `name` and `limit` with `context` and `output` token counts. If `limit.context` or `limit.output` is omitted, it defaults to `0`, which limits context management. -- **`options.baseURL`** — The base URL of your OpenAI-compatible API endpoint. For Azure OpenAI GPT-5, configure `provider.azure` instead. +- **`options.baseURL`** — The base URL of your provider's API endpoint. For Azure OpenAI GPT-5, configure `provider.azure` instead. - **`options.apiKey`** — Your API key. Use any non-empty string (e.g., `"none"`) if the provider doesn't require authentication. You can also set the API key via an environment variable instead of putting it in the config file. Use the `env` field to specify which variable to read: diff --git a/packages/kilo-docs/pages/ai-providers/openrouter.md b/packages/kilo-docs/pages/ai-providers/openrouter.md index 1750b8699b5..5b2e2a6b06c 100644 --- a/packages/kilo-docs/pages/ai-providers/openrouter.md +++ b/packages/kilo-docs/pages/ai-providers/openrouter.md @@ -70,7 +70,7 @@ Then set your default model: ## Supported Transforms -OpenRouter provides an [optional "middle-out" message transform](https://openrouter.ai/docs/features/message-transforms) to help with prompts that exceed the maximum context size of a model. +OpenRouter provides an [optional "middle-out" message transform](https://openrouter.ai/docs/guides/features/message-transforms) to help with prompts that exceed the maximum context size of a model. {% tabs %} {% tab label="VSCode & CLI" %} diff --git a/packages/kilo-docs/pages/ai-providers/v0.md b/packages/kilo-docs/pages/ai-providers/v0.md index bd1d1b16c37..8ebd95c107d 100644 --- a/packages/kilo-docs/pages/ai-providers/v0.md +++ b/packages/kilo-docs/pages/ai-providers/v0.md @@ -34,7 +34,7 @@ Setting up v0 in Kilo Code is straightforward: {% /tab %} {% tab label="VSCode" %} -Open **Settings** (gear icon) and go to the **Providers** tab to add an OpenAI Compatible provider. Set the base URL to `https://api.v0.dev/v1` and enter your v0 API key. +Open **Settings** (gear icon) and go to the **Providers** tab to add a **Custom provider**. Select **OpenAI Compatible** under **Provider API**, set the base URL to `https://api.v0.dev/v1`, and enter your v0 API key. The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. diff --git a/packages/kilo-docs/pages/ai-providers/xai.md b/packages/kilo-docs/pages/ai-providers/xai.md index dc74332d781..59aee771efe 100644 --- a/packages/kilo-docs/pages/ai-providers/xai.md +++ b/packages/kilo-docs/pages/ai-providers/xai.md @@ -1,6 +1,6 @@ --- title: "Using xAI Grok with Kilo Code" -description: "Connect xAI's Grok models to Kilo Code. Guide to getting an API key and configuring Grok in VS Code and the CLI." +description: "Connect xAI's Grok models to Kilo Code. Use a SuperGrok or X Premium subscription via OAuth or a paid API key. Guide to setup in VS Code and the CLI." sidebar_label: xAI (Grok) --- @@ -10,14 +10,93 @@ xAI is the company behind Grok, a large language model known for its conversatio **Website:** [https://x.ai/](https://x.ai/) -## Getting an API Key +Kilo Code supports two ways to connect xAI: -1. **Sign Up/Sign In:** Go to the [xAI Console](https://console.x.ai/). Create an account or sign in. -2. **Navigate to API Keys:** Go to the API keys section in your dashboard. -3. **Create a Key:** Click to create a new API key. Give your key a descriptive name (e.g., "Kilo Code"). -4. **Copy the Key:** **Important:** Copy the API key _immediately_. You will not be able to see it again. Store it securely. +- **SuperGrok or X Premium subscription (OAuth):** If you subscribe to SuperGrok or X Premium, you can sign in with OAuth — no separate API key or pay-as-you-go charges required. +- **API key:** For pay-as-you-go access via the xAI API. -## Configuration in Kilo Code +--- + +## Option 1: SuperGrok or X Premium Subscription (OAuth) + +If you have an active [SuperGrok or X Premium subscription](https://x.ai/grok), you can authenticate with xAI using OAuth and use Grok models directly without needing a separate API key. + +### Why use SuperGrok or X Premium? + +- **No API billing:** Usage counts against your subscription, not a pay-per-token API account. +- **OAuth login — no API keys:** Sign in through your browser and Kilo Code handles token management automatically. +- **Automatic token refresh:** Kilo Code refreshes your access token in the background so long-running sessions stay authenticated. + +{% callout type="note" %} +SuperGrok and X Premium subscription access works with Kilo Code's core functionality (VS Code extension and CLI). For cloud features such as Cloud Agents or KiloClaw, use the [Kilo Gateway](/docs/gateway) — the Gateway supports xAI via [BYOK](/docs/getting-started/byok) with an API key (OAuth and subscription-based access are not supported through the Gateway). +{% /callout %} + +### Setup with SuperGrok / X Premium + +{% tabs %} +{% tab label="VSCode (Legacy)" %} + +1. Open Kilo Code settings (click the gear icon {% codicon name="gear" /%} in the Kilo Code panel). +2. In **API Provider**, select **xAI**. +3. Click **Sign in with xAI (SuperGrok / X Premium)**. +4. Complete the authorization flow in your browser. +5. Back in Kilo Code settings, select your desired Grok model. +6. Save. + +{% /tab %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab. Click **Show more providers**, then search for or select **xAI**. Choose the **xAI Grok OAuth (SuperGrok / X Premium)** sign-in option and complete the OAuth flow in your browser. + +For headless or remote environments (VPS, SSH, Docker, WSL) where a browser redirect to `127.0.0.1` is not reachable, choose **xAI Grok OAuth (Headless / Remote / VPS)** instead. You will be shown a short code to enter at a URL you open on any device with a browser. + +{% /tab %} +{% tab label="CLI" %} + +Run the auth command and follow the xAI sign-in flow: + +```bash +kilo auth login --provider xai +``` + +Kilo Code offers three methods at the prompt: + +- **xAI Grok OAuth (SuperGrok / X Premium)** — opens `https://auth.x.ai` in your browser for a standard PKCE OAuth flow. Best for local desktop environments. +- **xAI Grok OAuth (Headless / Remote / VPS)** — uses the RFC 8628 device-code flow. The CLI displays a short code and a URL; open the URL on any device with a browser, enter the code, and the CLI completes the login. Use this when running on a VPS, behind SSH, inside Docker, WSL, or CI where `127.0.0.1:56121` is not accessible from your browser. +- **Manually enter API Key** — fall back to a standard API key if you prefer. + +Then set your default model: + +```jsonc +{ + "model": "xai/grok-3", +} +``` + +{% /tab %} +{% /tabs %} + +### Tips for SuperGrok and X Premium + +- **Subscription required:** You need an active SuperGrok or X Premium subscription. This option will not work with a free xAI account. +- **Sign out:** To disconnect in VS Code, use the "Disconnect" button in the provider settings. In the CLI, run `kilo auth logout` and choose xAI. +- **Port 56121:** The browser OAuth flow (PKCE) starts a short-lived local server on `127.0.0.1:56121` to receive the OAuth callback. If another application is already using that port, use the headless device-code method instead. +- **Token rotation:** xAI rotates refresh tokens on each use. Kilo Code persists the latest tokens automatically. If you run Kilo Code from multiple processes simultaneously, the first refresh can invalidate the other process's token — re-run `kilo auth login --provider xai` to restore the session. + +--- + +## Option 2: API Key + +If you prefer pay-as-you-go access or do not have a SuperGrok or X Premium subscription, you can use an xAI API key. + +### Getting an API Key + +1. **Sign Up/Sign In:** Go to the [xAI Console](https://console.x.ai/). Create an account or sign in. +2. **Navigate to API Keys:** Go to the API keys section in your dashboard. +3. **Create a Key:** Click to create a new API key. Give your key a descriptive name (e.g., "Kilo Code"). +4. **Copy the Key:** **Important:** Copy the API key _immediately_. You will not be able to see it again. Store it securely. + +### Configuration with API Key {% tabs %} {% tab label="VSCode (Legacy)" %} @@ -30,7 +109,7 @@ xAI is the company behind Grok, a large language model known for its conversatio {% /tab %} {% tab label="VSCode" %} -Open **Settings** (gear icon) and go to the **Providers** tab to add xAI and enter your API key. +Open **Settings** (gear icon) and go to the **Providers** tab. Click **Show more providers**, then search for or select **xAI** and enter your API key. The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. @@ -68,6 +147,8 @@ Then set your default model: {% /tab %} {% /tabs %} +--- + ## Reasoning Capabilities Some models feature specialized reasoning capabilities, allowing them to "think before responding" - particularly useful for complex problem-solving tasks. @@ -91,5 +172,5 @@ Choose `low` for simple queries that should complete quickly, and `high` for har - **Context Window:** Most Grok models feature large context windows (up to 131K tokens), allowing you to include substantial amounts of code and context in your prompts. - **Vision Capabilities:** Select vision-enabled models (`grok-2-vision-latest`, `grok-2-vision`, etc.) when you need to process or analyze images. -- **Pricing:** Pricing varies by model, with input costs ranging from $0.3 to $5.0 per million tokens and output costs from $0.5 to $25.0 per million tokens. Refer to the xAI documentation for the most current pricing information. +- **Pricing:** API key pricing varies by model, with input costs ranging from $0.3 to $5.0 per million tokens and output costs from $0.5 to $25.0 per million tokens. Refer to the xAI documentation for the most current pricing information. - **Performance Tradeoffs:** "Fast" variants typically offer quicker response times but may have higher costs, while "mini" variants are more economical but may have reduced capabilities. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 42dbc169236..9d977ae4f7d 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -136,11 +136,17 @@ Imported work stays associated with its branch or worktree and can be continued - Use session history to reopen local sessions or preview cloud sessions - Continue a cloud session locally from Agent Manager using the same extension sign-in and provider settings +### Renaming Worktrees + +Double-click a worktree name to edit its label inline. You can also right-click the worktree and choose **Rename**. Press `Enter` or click outside the field to save, or press `Escape` to cancel. + +Renaming a worktree changes only the label shown in Agent Manager. It does not rename the underlying git branch. + ## Starting Sessions From Chat -Kilo can start Agent Manager sessions from chat with the experimental `agent_manager` tool. Enable it in **Settings > Experimental > Agent Manager Tool**, or set `experimental.agent_manager_tool` to `true` in `kilo.jsonc`. +Kilo can start Agent Manager sessions from chat with the `agent_manager` tool. It is available by default only in the VS Code extension because Agent Manager is an extension feature. -The tool is available only in the VS Code extension because Agent Manager is an extension feature. It supports two modes: +The tool supports two modes: | Mode | Behavior | |---|---| @@ -168,7 +174,7 @@ Sections let you group worktrees into collapsible, color-coded folders in the si Multi-version worktrees (created via Multi-Version Mode) are moved together — assigning one version to a section moves all versions in the group. -### Renaming +### Renaming Sections Right-click the section header and select **Rename Section**. An inline text field appears — type the new name and press `Enter` to confirm or `Escape` to cancel. diff --git a/packages/kilo-docs/pages/automate/code-reviews/github.md b/packages/kilo-docs/pages/automate/code-reviews/github.md index 4268264b0d2..f543e45b0b1 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/github.md +++ b/packages/kilo-docs/pages/automate/code-reviews/github.md @@ -40,7 +40,7 @@ The GitHub App requests the following permissions: - **Repository Selection** — All repositories or select specific ones - **Focus Areas** — Security, performance, bugs, style, testing, documentation - **Max Review Time** — 5 to 30 minutes - - **Custom Instructions** — Add team-specific review guidelines + - **Use REVIEW.md** — Load repository-specific review guidance, including sub-agent usage, from the base branch 4. Click **Save Configuration** ### Step 3: Open a Pull Request diff --git a/packages/kilo-docs/pages/automate/code-reviews/gitlab.md b/packages/kilo-docs/pages/automate/code-reviews/gitlab.md index 11969ace77c..cccc0541d60 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/gitlab.md +++ b/packages/kilo-docs/pages/automate/code-reviews/gitlab.md @@ -37,7 +37,7 @@ Once connected, return here to configure the Review Agent. - **Repository Selection** — All repositories or select specific ones - **Focus Areas** — Security, performance, bugs, style, testing, documentation - **Max Review Time** — 5 to 30 minutes - - **Custom Instructions** — Add team-specific review guidelines + - **Use REVIEW.md** — Load repository-specific review guidance, including sub-agent usage, from the base branch 4. Click **Save Configuration** When you select repositories, Kilo **automatically creates webhooks** on each project. diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index f72ecc48209..ac5981f7202 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -14,6 +14,7 @@ Kilo's **Code Reviews** feature automatically analyzes your pull or merge reques - Automatic detection of bugs, security risks, and anti-patterns - Deep reasoning over changed files, diffs, and repo context - Customizable review strictness and focus areas +- Repository-owned review guidance through `REVIEW.md`, including sub-agent usage ## Supported Platforms @@ -45,13 +46,73 @@ Before enabling Code Reviews: 5. Choose which **repositories** should receive automatic reviews. 6. Optionally select **Focus Areas** such as security, performance, bugs, style, testing, or documentation. 7. Set a **maximum review time** (5–30 minutes). -8. Add **custom instructions** to shape how the agent reviews your code. +8. Optionally enable **Use REVIEW.md** and add a `REVIEW.md` file at the repository root to shape how the agent reviews your code. Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see: - [GitHub Code Reviews](./github) - [GitLab Code Reviews](./gitlab) +## Repository Guidance with REVIEW.md + +Use `REVIEW.md` when review policy should live with the repository instead of only in the Kilo dashboard. This is the best place to document domain-specific rules, severity calibration, files to skip, verification expectations, summary style, and how Kilo should use sub-agents. + +To use it: + +1. Create `REVIEW.md` at the repository root. +2. Commit it to the base branch used by pull requests or merge requests. +3. Open Code Reviews settings in the [Kilo web app](https://app.kilo.ai/code-reviews) and enable **Use REVIEW.md**. +4. Save the configuration and run a review. + +Kilo reads `REVIEW.md` from the PR/MR base branch, not the feature branch. That prevents an unreviewed change from rewriting the review policy used to evaluate itself. If the file is disabled, missing, empty, or unreadable, Kilo falls back to built-in guidance. If it is longer than 10,000 characters, Kilo truncates it and notes that in the review summary footer. + +### Default Sub-Agent Usage + +By default, Code Reviews uses sub-agents only when they materially improve coverage. After reading the diff, the reviewer estimates changed file count and changed lines, then chooses the largest tier triggered by either signal. + +| Diff size | Default behavior | Why | +|---|---|---| +| Tiny: up to 2 files and under 100 changed lines | Use 0 sub-agents and review directly | The coordination cost is higher than the coverage benefit for very small changes. | +| Small: 3-5 files or 100-300 changed lines | Use at most 1 sub-agent for a distinct risky area | One focused second pass can help without creating duplicate or low-signal findings. | +| Medium and larger: 6+ files or more than 300 changed lines | Use the full 6 sub-agents, sharded by independent areas | Larger diffs benefit from parallel coverage across files, domains, and risk categories. | + +The reviewer does not spawn sub-agents for a single-file or straightforward typo/config change. Sub-agents are read-only and do not post comments themselves. They return findings with path, line, severity, and rationale. The main reviewer remains responsible for verifying findings, removing duplicates, checking that inline comments target valid diff lines, and posting the final comments and summary. + +#### Changing Sub-Agent Behavior + +`REVIEW.md` can replace the default sub-agent guidance. Use it to change both how many sub-agents Kilo should use and what each one should inspect. + +Good sub-agent guidance is explicit about: + +- When to use 0 sub-agents. +- When to use 1-2 targeted sub-agents. +- When to use the full 6 sub-agents. +- Which areas each sub-agent should own. +- What each sub-agent should return to the main reviewer. +- Which hard constraints still matter, such as read-only review and no direct commenting by sub-agents. + +Example `REVIEW.md` section: + +```markdown +## Sub-agent usage + +Use 0 sub-agents for docs-only, formatting-only, dependency-lockfile-only, or single-file typo changes. + +Use 1 sub-agent for focused changes under 300 changed lines when the diff touches one risky area, such as authentication, billing, database migrations, or security-sensitive parsing. + +Use 3 sub-agents when a PR spans API, data model, and UI changes: + +1. API/data reviewer: check request validation, authorization, persistence, and migration safety. +2. UI reviewer: check user-visible behavior, accessibility, empty states, and error states. +3. Test reviewer: check that tests cover the observable behavior and important edge cases. + +Use the full 6 sub-agents only for large cross-cutting changes, security-sensitive work, or changes above 800 changed lines. Split them by independent domains rather than asking every sub-agent to review the same files. + +Each sub-agent must stay read-only, must not post comments, and must return findings with path, line, severity, rationale, and confidence. The main reviewer must verify every finding before posting it. +``` + +`REVIEW.md` can change review policy and sub-agent usage, but it cannot override Kilo's hard safety constraints, read-only mode, non-interactive execution, platform API instructions, diff-line rules, duplicate-comment rules, or output formatting requirements. + ## Local Code Reviews Code Reviewer is also available locally. This is valuable for developers who want to review their code before pushing a pull request to their team publicly, or for developers who want reviews and don't need to ship a pull request to GitHub. @@ -173,6 +234,6 @@ The Review Agent is ideal for: - Reviews can run for **up to 30 minutes** depending on your setting. - The agent reviews **only the changed files**, not the entire repository. -- Some highly dynamic or domain-specific code may require additional context in custom instructions. +- Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`. - The agent will only run on **selected repositories**. - During beta, review capacity may be throttled for extremely large PRs. diff --git a/packages/kilo-docs/pages/automate/extending/local-models.md b/packages/kilo-docs/pages/automate/extending/local-models.md index 81921bdfb88..c540a25281b 100644 --- a/packages/kilo-docs/pages/automate/extending/local-models.md +++ b/packages/kilo-docs/pages/automate/extending/local-models.md @@ -5,7 +5,7 @@ description: "Run AI models locally with Kilo Code" # Using Local Models -Kilo Code supports running language models locally on your own machine using [Ollama](https://ollama.com/) and [LM Studio](https://lmstudio.ai/). This offers several advantages: +Kilo Code supports running language models locally on your own machine using [Ollama](https://ollama.com/), [LM Studio](https://lmstudio.ai/), and [Atomic Chat](https://atomic.chat/). This offers several advantages: - **Privacy:** Your code and data never leave your computer. - **Offline Access:** You can use Kilo Code even without an internet connection. @@ -21,10 +21,11 @@ Kilo Code supports running language models locally on your own machine using [Ol ## Supported Local Model Providers -Kilo Code currently supports two main local model providers: +Kilo Code supports several local model providers: 1. **Ollama:** A popular open-source tool for running large language models locally. It supports a wide range of models. -2. **LM Studio:** A user-friendly desktop application that simplifies the process of downloading, configuring, and running local models. It also provides a local server that emulates the OpenAI API. +2. **LM Studio:** A user-friendly desktop application that simplifies downloading and running local models, with a local server that emulates the OpenAI API. +3. **[Atomic Chat](https://atomic.chat/):** Open-source local AI with TurboQuant-optimized inference, a built-in chat UI, and an OpenAI-compatible API on port **1337**. Kilo Code can discover loaded models when you opt in (`provider.atomic-chat`, `atomicChat.autoDetect`, or an `atomic-chat/...` model). ## Setting Up Local Models @@ -32,12 +33,11 @@ For detailed setup instructions, see: - [Setting up Ollama](/docs/ai-providers/ollama) - [Setting up LM Studio](/docs/ai-providers/lmstudio) - -Both providers offer similar capabilities but with different user interfaces and workflows. Ollama provides more control through its command-line interface, while LM Studio offers a more user-friendly graphical interface. +- [Setting up Atomic Chat](/docs/ai-providers/atomic-chat) ## Troubleshooting -- **"No connection could be made because the target machine actively refused it":** This usually means that the Ollama or LM Studio server isn't running, or is running on a different port/address than Kilo Code is configured to use. Double-check the Base URL setting. +- **"No connection could be made because the target machine actively refused it":** This usually means that Atomic Chat, Ollama, or LM Studio isn't running, or is on a different port than Kilo Code expects (Atomic Chat: `http://127.0.0.1:1337/v1`, LM Studio: `http://127.0.0.1:1234/v1`, Ollama: `http://127.0.0.1:11434`). Double-check the Base URL setting. - **Slow Response Times:** Local models can be slower than cloud-based models, especially on less powerful hardware. If performance is an issue, try using a smaller model. diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md index 00914b2bb0e..e0f87943db5 100644 --- a/packages/kilo-docs/pages/automate/extending/plugins.md +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -452,26 +452,11 @@ For tools that don't need the full plugin context, drop them in a `tool/` or `to ## Examples -### Send a notification when a session finishes +### Configure CLI completion notifications -```ts -// .kilo/plugin/notify.ts -import type { Plugin } from "@kilocode/plugin" - -const Notify: Plugin = async ({ $ }) => ({ - event: async ({ event }) => { - if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session complete!" with title "Kilo"'` - } - }, -}) - -export default { id: "notify", server: Notify } -``` +The CLI has built-in attention alerts for session completion, errors, and prompts that need input. You do not need a plugin or platform-specific notification command. -{% callout type="tip" %} -The VS Code extension already emits system notifications when a session finishes or errors — this plugin is for the raw CLI / TUI. -{% /callout %} +Enable notifications and sounds in `kilo console` under **Settings > CLI > Notifications**, or configure the `attention` section of `tui.json`. See [CLI Notifications and Sounds](/docs/code-with-ai/platforms/cli#cli-notifications-and-sounds) for configuration and custom sound overrides. ### Block reads of `.env` files diff --git a/packages/kilo-docs/pages/automate/how-tools-work.md b/packages/kilo-docs/pages/automate/how-tools-work.md index 3f4d293c0cf..a8ffdf557e6 100644 --- a/packages/kilo-docs/pages/automate/how-tools-work.md +++ b/packages/kilo-docs/pages/automate/how-tools-work.md @@ -26,7 +26,7 @@ Describe what you want to accomplish in natural language, and Kilo Code will: | Read | Access file content and code structure | `read`, `glob`, `grep` | | Edit | Create or modify files and code | `edit`, `write`, `apply_patch` | | Execute | Run commands and perform system operations | `bash` | -| Web | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | +| Web | Fetch and search web content | `webfetch`, `websearch` | | Workflow | Manage task flow and sub-agents | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill` | {% /tab %} @@ -108,7 +108,7 @@ Every tool use is subject to a permission check. The default action for any tool | `bash` | `ask` (per-command) | | `external_directory` | `ask` (when accessing paths outside the project) | | `task` | `ask` | -| `webfetch`, `websearch`, `codesearch` | `ask` | +| `webfetch`, `websearch` | `ask` | | `todowrite`, `todoread`, `question`, `skill` | `ask` | No tools are auto-approved out of the box. You must explicitly grant `allow` in your config, or approve them at runtime. @@ -165,7 +165,6 @@ This safety mechanism ensures you maintain control over which files are modified | `bash` | Runs shell commands | Execute | | `webfetch` | Fetches a URL | Web | | `websearch` | Searches the web (Kilo/OpenRouter users) | Web | -| `codesearch` | Semantic code search (Kilo/OpenRouter users) | Web | | `question` | Asks you a clarifying question with selectable options | Workflow | | `task` | Spawns a sub-agent session | Workflow | | `todowrite` | Creates and updates a session TODO list | Workflow | diff --git a/packages/kilo-docs/pages/automate/integrations.md b/packages/kilo-docs/pages/automate/integrations.md index 1daed7f4a6c..8cfd5d2e2ff 100644 --- a/packages/kilo-docs/pages/automate/integrations.md +++ b/packages/kilo-docs/pages/automate/integrations.md @@ -5,7 +5,7 @@ description: "Overview of Kilo Code integrations" # Kilo Code Integrations -Kilo Integrations lets you connect your GitHub or GitLab account (soon Bitbucket) to enable advanced features inside Kilo Code. Once connected, Kilo can access your repositories securely, enabling features like **Code Reviews**, **Cloud Agents**, and **Kilo Deploy**. +Kilo Integrations lets you connect GitHub or GitLab for repository workflows and DoltHub for Dolt-versioned data. Once connected, Kilo can access authorized resources securely, enabling features like **Code Reviews**, **Cloud Agents**, **Kilo Deploy**, and data workflows through **Kilo Connect**. ## Supported Platforms @@ -13,12 +13,14 @@ Kilo Integrations lets you connect your GitHub or GitLab account (soon Bitbucket |---|---|---| | GitHub | GitHub App | [GitHub Setup](#connecting-github) | | GitLab | OAuth or PAT | [GitLab Setup](#connecting-gitlab) | +| DoltHub | OAuth | [DoltHub Setup](#connecting-dolthub) | ## What You Can Do With Integrations -- **Connect GitHub or GitLab to Kilo Code** in a few clicks +- **Connect GitHub, GitLab, or DoltHub to Kilo Code** in a few clicks - **Enable advanced features** like Cloud Agents, Code Reviews, and Kilo Deploy -- **Authorize repository access** so Kilo can analyze and work with your code +- **Authorize GitHub or GitLab repository access** so Kilo can analyze and work with your code +- **Query Dolt-versioned data** from your workspace through Kilo Connect ## Prerequisites @@ -27,6 +29,7 @@ Before connecting: - You must have a **GitHub** or **GitLab** account. - For GitHub: You need permission to install GitHub Apps for the repositories you want Kilo to access. - For GitLab: You need **Maintainer** role (or higher) on the projects you want to connect. +- For DoltHub: You need a DoltHub account to authorize the OAuth connection. - (Optional) If you're connecting an organization, you must be an admin or have app installation permissions. --- @@ -109,9 +112,24 @@ For self-hosted GitLab instances using OAuth, you need to register an OAuth appl --- +## Connecting DoltHub + +DoltHub is available through [Kilo Connect](/docs/code-with-ai/platforms/kilo-connect) for teams that work with Dolt-versioned data. + +1. Go to the **Integrations** page: + - **Personal**: [app.kilo.ai/integrations/dolthub](https://app.kilo.ai/integrations/dolthub) + - **Organization**: Your organization → Integrations → DoltHub +2. Click **Connect DoltHub**. +3. Authorize the connection with DoltHub. +4. Return to Kilo and confirm DoltHub shows a **Connected** status. + +To remove the connection, click **Disconnect** from the DoltHub integration page. + +--- + ## What Happens After Connecting -Once your Git provider is connected, the following features are enabled in Kilo: +Once your integrations are connected, the following features are enabled in Kilo: ### Cloud Agents @@ -131,6 +149,11 @@ Once your Git provider is connected, the following features are enabled in Kilo: - Trigger rebuilds automatically on push - Manage deployment logs and history +### DoltHub data access + +- Query Dolt-versioned databases from your workspace +- Use DoltHub alongside GitHub or GitLab when a workflow also needs repository access + ### Upcoming: - **Bitbucket Integration** @@ -157,6 +180,13 @@ From the **Integrations** page: > Disconnecting from Kilo does not revoke OAuth tokens on GitLab's side. You can manually revoke them from **GitLab → User Settings → Applications → Authorized Applications**. +### DoltHub + +From the **Integrations** page, open DoltHub to: + +- View the connected status +- Disconnect DoltHub from Kilo + --- ## Troubleshooting diff --git a/packages/kilo-docs/pages/automate/tools/index.md b/packages/kilo-docs/pages/automate/tools/index.md index 0ac0cc2ce28..96c7b02d533 100644 --- a/packages/kilo-docs/pages/automate/tools/index.md +++ b/packages/kilo-docs/pages/automate/tools/index.md @@ -21,10 +21,10 @@ Tools are organized into logical groups based on their functionality: | **Read Group** | File system reading and searching | `read`, `glob`, `grep` | Code exploration and analysis | | **Edit Group** | File system modifications | `edit`, `write`, `apply_patch` | Code changes and file manipulation | | **Execute Group** | Shell command execution | `bash` | Running scripts, building projects | -| **Web Group** | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | Research, documentation lookup | +| **Web Group** | Fetch and search web content | `webfetch`, `websearch` | Research, documentation lookup | | **Browser Group** | Web browser automation | `kilo-playwright_*` (via built-in Playwright MCP) | Browser testing and interaction | | **MCP Group** | External tool integration | MCP server tools (namespaced as `{server}_{tool}`) | Specialized functionality via MCP | -| **Workflow Group** | Sub-agents and task management | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill`, `agent_manager` (experimental) | Context switching and task organization | +| **Workflow Group** | Sub-agents and task management | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill`, `agent_manager` | Context switching and task organization | ### Always Available Tools @@ -66,7 +66,6 @@ These tools help Kilo Code access web content: - `webfetch` - Fetches a URL and returns the content - `websearch` - Searches the web (available to Kilo/OpenRouter users) -- `codesearch` - Semantic code search (available to Kilo/OpenRouter users) ### Browser Tools @@ -94,7 +93,7 @@ These tools help manage the conversation and task flow: - `todoread` - Reads the current session TODO list - `plan` - Enters structured planning mode - `skill` - Invokes a reusable skill (Markdown instruction module) -- `agent_manager` - Starts Agent Manager local or worktree sessions when the experimental Agent Manager Tool setting is enabled in VS Code +- `agent_manager` - Starts Agent Manager local or worktree sessions in VS Code {% /tab %} {% tab label="VSCode (Legacy)" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 1ed07cf5261..5d9fc1fcfde 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -1,16 +1,17 @@ --- title: "Auto Model" -description: "Smart model routing that automatically selects the optimal AI model based on your current mode" +description: "Smart model routing that selects an AI model for each Auto Model tier" --- # Auto Model -Auto Model is a smart model routing system that automatically selects the optimal AI model based on the Kilo Code mode you're using. It comes in multiple tiers so you can balance cost and capability to fit your needs. +Auto Model is a smart routing system that selects an underlying model for each request. Each tier uses its own routing strategy so you can balance cost and capability to fit your needs. | Tier | Best For | Pricing | |---|---|---| | `kilo-auto/frontier` | Maximum capability with the best available models | Paid | | `kilo-auto/balanced` | Strong performance at a lower cost | Paid | +| `kilo-auto/efficient` | Lowest cost per task, with capability matched to difficulty | Paid | | `kilo-auto/free` | The best free models available | Free | ## How It Works @@ -31,10 +32,21 @@ The underlying models behind each Auto Model tier are updated server-side as bet - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. - **Balanced** — Routes to a cost-effective model for all modes. The specific model is selected based on the API interface in use, but does not vary by mode. A good default for most developers who want strong AI assistance without paying frontier prices. +- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to the Balanced tier, so quality never drops below Balanced. - **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. +### How Auto Efficient routing works + +1. Kilo observes your coding session in context +2. It classifies each task by complexity +3. It routes to the benchmark-proven best model for that task automatically + +You get lean costs on routine work and stronger models when the work demands it — with no manual switching. + {% callout type="warning" title="Data handling for Auto Free" %} -Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services. In particular, it may route to NVIDIA's free endpoints, which are provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) — trial use only, not for production or sensitive data. Do not submit personal or confidential data when using Auto Free. +Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services. Do not submit personal or confidential data when using Auto Free. In particular, it may route to NVIDIA's free endpoints. + +For NVIDIA free endpoints (Super/Ultra/etc): Trial use only - do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://www.nvidia.com/en-us/about-nvidia/privacy-policy/). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). {% /callout %} ## Benefits @@ -49,7 +61,7 @@ No need to manually switch models when changing modes. Auto Model handles routin ### Flexible Cost Control -Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Balanced offers capable models at a fraction of the cost; Free costs nothing. +Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Balanced offers capable models at a fraction of the cost; Efficient minimizes cost per task by matching model capability to task difficulty; Free costs nothing. ## Requirements diff --git a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md index 3708f9c8095..ffa0f6da23e 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md @@ -82,6 +82,12 @@ Run `/export` in chat, or open a local session's **History** context menu and ch Kilo builds the export from the complete local session history, not only the messages currently loaded in the chat view. +**Renaming sessions:** + +Double-click the current session title at the top of the chat to edit it inline. Press `Enter` or click outside the field to save, or press `Escape` to cancel. + +You can also rename local sessions from **History** using the edit button or the session's context menu. + {% /tab %} {% tab label="CLI" %} 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 a8b035662c7..103043b6443 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 @@ -10,7 +10,7 @@ Kilo Code ships with a curated list of models for each provider, but you can use - Using a newly released model before it's added to the built-in catalog - Running a custom or fine-tuned model via LM Studio, Ollama, or another local provider -- Connecting to a self-hosted model behind an OpenAI-compatible API +- Connecting to a self-hosted model through a custom API endpoint - Configuring model-specific options like token limits, pricing, or reasoning settings ## Defining a Custom Model @@ -32,7 +32,8 @@ Add custom models under the `provider..models` key in your config f - **Provider ID** — A unique identifier using lowercase letters, numbers, hyphens, or underscores (e.g., `myprovider`). This becomes the `provider_id` in the `provider_id/model_id` format. - **Display name** — A human-readable name shown in the UI (e.g., `My AI Provider`). -- **Base URL** — The OpenAI-compatible API endpoint (e.g., `https://api.myprovider.com/v1`). When a valid URL is entered, Kilo automatically fetches available models from the endpoint. +- **Provider API** — The protocol used by the provider. Use **OpenAI Responses** for OpenAI and xAI models. Use **Anthropic Messages** for Anthropic and MiniMax models. **OpenAI Compatible** is the default for other OpenAI Chat Completions-compatible endpoints. +- **Base URL** — The provider's API endpoint (e.g., `https://api.myprovider.com/v1`). When a valid URL is entered, Kilo automatically fetches available models from the endpoint if it exposes an OpenAI-compatible models endpoint. - **API key** — Your provider's API key. Optional — leave empty if you manage authentication via headers. - **Models** — Add models manually by ID and display name, or select from the auto-fetched list that appears after entering a valid base URL. - **Headers** (optional) — Add custom HTTP headers as key-value pairs if your provider requires them. @@ -253,7 +254,7 @@ Connect to any provider that exposes an OpenAI-compatible API: ### Configuring model options and variants -Override options or define reasoning variants for a built-in model: +Override options or define reasoning variants for a built-in model. Variant fields are provider-specific and are merged into the request when you select that variant. ```jsonc { @@ -286,6 +287,18 @@ Override options or define reasoning variants for a built-in model: } ``` +MiniMax's OpenAI-compatible Chat Completions API supports the optional boolean `reasoning_split` field. Set it on the relevant variant to control how the API returns thinking content: + +```jsonc +"variants": { + "thinking": { + "reasoning_split": true, + }, +} +``` + +With `true`, MiniMax returns thinking separately in `reasoning_content` and `reasoning_details`. This setting changes only the response format, not whether the model thinks. Leave it unset for providers that do not support it, including MiniMax using the Anthropic Messages API. + ### Using the id field to map model names If the model key in your config differs from what the provider expects, use the `id` field: @@ -375,6 +388,7 @@ You can also set options that apply to all models from a provider: | `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | | `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. | ## Filtering Available Models diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index b0f7c227cec..b187da64dc7 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -52,12 +52,22 @@ While the specifics change constantly, some principles stay consistent: **For everyday coding**: Mid-tier models often provide the best balance of speed, cost, and quality. They're fast enough to keep your flow state intact and capable enough for most tasks. -**For budget-conscious work**: Newer efficient models keep surprising us with price-to-performance ratios. DeepSeek, Qwen, and similar models can handle more than you'd expect. +**For budget-conscious work**: Newer efficient models keep surprising us with price-to-performance ratios. DeepSeek, Qwen, and similar models can handle more than you'd expect. See the [free and budget picks](#free-and-budget-model-picks) below. **For local/private work**: Ollama and LM Studio let you run models locally. The tradeoff is usually speed and capability for privacy and zero API costs. **Using an unlisted model?** You can register any model — including fine-tunes, newly released models, or custom local models — by adding it to your config file. See [Custom Models](/docs/code-with-ai/agents/custom-models) for details. +## Free and Budget Model Picks + +You don't need a paid API key to use Kilo Code productively. For the lowest cost on paid work, [Auto Efficient](/docs/code-with-ai/agents/auto-model#tiers) (`kilo-auto/efficient`) routes each request to the cheapest model proven accurate enough for that task. The fastest way to start for free is [Auto Model Free](/docs/code-with-ai/agents/auto-model) (`kilo-auto/free`), which routes to the best available free models automatically. See [Using Kilo for Free](/docs/getting-started/using-kilo-for-free) for the full zero-cost setup. + +If you prefer to pick models yourself, type `free` in the model picker to filter by free models, or browse the full list at [kilo.ai/models](https://kilo.ai/models). + +{% callout type="info" %} +Free model availability changes as providers adjust promotional periods. Check [kilo.ai/models](https://kilo.ai/models) for the live list. +{% /callout %} + ## Context Windows Matter One thing that doesn't change: context window size matters for your workflow. @@ -132,6 +142,67 @@ The model selection is remembered per mode across sessions. For details on configuring subagent models, see [Custom Subagents](/docs/customize/custom-subagents). +## Selecting a Model or Agent via a Link (VS Code) + +The VS Code extension supports a `vscode://` protocol handler that lets you open VS Code and automatically select a model, an agent, or both — no manual picker interaction required. This is useful for sharing model recommendations, launching a specific model tier from a web page, or switching quickly to a preferred agent. + +### URL Format + +Include at least one of the `model` or `agent` parameters: + +``` +vscode://kilocode.kilo-code/kilocode/switch?model= +vscode://kilocode.kilo-code/kilocode/switch?agent= +vscode://kilocode.kilo-code/kilocode/switch?model=&agent= +``` + +Replace `` with a Kilo Gateway model ID such as `kilo-auto/free`. Replace `` with a visible primary agent ID such as `code` or `plan`, rather than its display name. + +### Example: Auto Free + +To open Kilo Code and switch to the [Auto Free](/docs/code-with-ai/agents/auto-model) tier (`kilo-auto/free`), use: + +``` +vscode://kilocode.kilo-code/kilocode/switch?model=kilo-auto%2Ffree +``` + +To switch only to Plan and use its normal model selection, specify the agent without a model: + +``` +vscode://kilocode.kilo-code/kilocode/switch?agent=plan +``` + +To select both at the same time, include both parameters: + +``` +vscode://kilocode.kilo-code/kilocode/switch?model=kilo-auto%2Ffree&agent=plan +``` + +{% callout type="tip" %} +URL-encode the `/` in model IDs as `%2F` when embedding this URL in HTML links or other contexts where bare slashes may be misinterpreted. +{% /callout %} + +### How It Works + +- **VS Code open**: the Kilo sidebar is focused and the linked selection is applied to the active session immediately. +- **VS Code closed**: VS Code launches, then applies the selection once the extension is ready. +- When `model` is provided, it must identify a model in the current Kilo Gateway catalog. Invalid or unavailable models cause the deep link to be ignored. +- When `agent` is provided, it must identify a visible primary agent. Invalid or unavailable agents cause the deep link to be ignored. +- An agent-only link uses the model that would normally be selected for that agent. When both parameters are present, the agent is selected first so the linked model applies to it. +- The selection follows the same precedence as using the pickers: it updates the active session, or the next session when no session is active. It does **not** change your configured defaults in settings. + +### Sharing and Embedding + +You can embed these links in a web page: + +```html + + Open Kilo Code with Auto Free in Plan + +``` + +Or share as a plain URL that users can paste into their browser's address bar. + ## Stay Current The AI model space moves fast. Bookmark [kilo.ai/models](https://kilo.ai/models) and check back when you're evaluating options. What's best today might not be best next month — and that's actually exciting. diff --git a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md index 9b8117a53b4..7c59e85eddb 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md +++ b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md @@ -360,7 +360,7 @@ Operations are queued to prevent concurrent Git operations that might corrupt re ## Git Installation -Checkpoints require Git to be installed on your system. +Checkpoints require Git to be installed on your system. If Git is unavailable or the workspace is not a Git repository, Kilo skips checkpoints automatically; you do not need to disable them manually. ### macOS diff --git a/packages/kilo-docs/pages/code-with-ai/features/speech-to-text.md b/packages/kilo-docs/pages/code-with-ai/features/speech-to-text.md index e66a937f10d..f95f55bfadb 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/speech-to-text.md +++ b/packages/kilo-docs/pages/code-with-ai/features/speech-to-text.md @@ -5,11 +5,7 @@ description: Dictate prompts through your signed-in Kilo account. # Voice Transcription -{% callout type="warning" title="Experimental feature" %} -Speech to Text is experimental. Expect issues and changes as it matures. -{% /callout %} - -Use voice input in prompt fields instead of typing. Transcription uses your Kilo account through Kilo Gateway. +Use voice input in prompt fields instead of typing. When the Kilo provider is enabled and you are signed in, the microphone appears automatically and transcription uses your account through Kilo Gateway. --- @@ -43,29 +39,15 @@ Enable and sign in to the Kilo provider to use voice input in prompt fields. Req --- -## Enable input - -Voice input is experimental and must be enabled: +## Choose a model -1. Open Kilo Code settings -2. Open **Experimental** settings -3. Enable the **Speech to Text** experiment - -Kilo stores this toggle in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`), not VS Code user settings: - -```json -{ - "experimental": { - "speech_to_text": true - } -} -``` +You can optionally choose a transcription model in **Settings** > **Models** > **Speech to Text Model**. Kilo stores this choice as `experimental.speech_to_text_model` in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`). --- ## Record prompts -Once enabled, a microphone button appears in prompt fields: +When you are signed in to the enabled Kilo provider, a microphone button appears in prompt fields: 1. Click the microphone button to start recording 2. Speak your message clearly @@ -87,13 +69,12 @@ The feature includes real-time audio level visualization and voice activity dete **Microphone button not appearing:** -- Ensure the Speech to Text experiment is enabled -- Verify FFmpeg is installed and in your PATH - Enable and sign in to the Kilo provider **Transcription errors:** - Confirm the Kilo provider remains enabled and signed in +- Verify FFmpeg is installed and in your PATH - Check your internet connection - Try speaking more clearly or adjusting your microphone settings @@ -101,7 +82,7 @@ The feature includes real-time audio level visualization and voice activity dete ## Know limits -Speech to Text is experimental and may have limitations: +Voice transcription has these requirements: - Requires an active internet connection - Requires Kilo Gateway access through your Kilo account diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 9488f7a8fcd..55bb776f44a 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -179,15 +179,19 @@ Options: --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] -f, --file file(s) to attach to message [array] --title title for the session (uses truncated prompt if no value provided) [string] - --attach attach to a running opencode server (e.g., http://localhost:4096) [string] + --attach attach to a running kilo server (e.g., http://localhost:4096) [string] -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] --dir directory to run in, path on remote server if attaching [string] --port port for the local server (defaults to random port if no value provided) [number] --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string] - --thinking show thinking blocks [boolean] [default: false] + --thinking show thinking blocks [boolean] + --replay replay visible session history on interactive resume [boolean] [default: false] + --replay-limit cap visible interactive replay to the newest N messages [number] + -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false] --auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false] + --demo enable direct interactive demo slash commands; pass one as the message to run it immediately [boolean] [default: false] ``` ## kilo debug @@ -205,6 +209,7 @@ Commands: kilo debug snapshot snapshot debugging utilities kilo debug startup print startup timing kilo debug agent show agent configuration details + kilo debug v2 debug v2 catalog and built-in plugins kilo debug info show debug information kilo debug paths show global paths (data, config, cache, state) kilo debug wait wait indefinitely (for debugging) @@ -507,6 +512,16 @@ Options: --params Tool params as JSON or a JS object literal [string] ``` +### kilo debug v2 + +``` +debug v2 catalog and built-in plugins + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + ### kilo debug info ``` @@ -637,7 +652,7 @@ Positionals: Options: --help Show help [boolean] --version Show version number [boolean] - -m, --method installation method to use [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"] + -m, --method installation method to use [string] [choices: "curl", "npm", "yarn", "pnpm", "bun", "brew", "choco", "scoop"] ``` ## kilo uninstall @@ -669,6 +684,21 @@ Options: --cors additional domains to allow for CORS [array] [default: []] ``` +## kilo web + +``` +start kilo server and open web interface + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] +``` + ## kilo models ``` @@ -703,6 +733,17 @@ Options: --output Output format (table, json, or md) [string] [choices: "table", "json", "md"] [default: "table"] ``` +## kilo profile + +``` +show Kilo account profile + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --json output profile as JSON [boolean] [default: false] +``` + ## kilo stats ``` @@ -744,6 +785,42 @@ Options: --version Show version number [boolean] ``` +## kilo github + +``` +manage GitHub agent + +Commands: + kilo github install install the GitHub agent + kilo github run run the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + +### kilo github install + +``` +install the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + +### kilo github run + +``` +run the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --event GitHub mock event to run the agent for [string] + --token GitHub personal access token (github_pat_********) [string] +``` + ## kilo pr ``` @@ -808,6 +885,117 @@ Options: --version Show version number [boolean] ``` +## kilo daemon + +``` +manage the local kilo daemon + +Commands: + kilo daemon start the local kilo daemon [default] + kilo daemon start start the local kilo daemon + kilo daemon status show local kilo daemon status + kilo daemon stop stop the local kilo daemon + kilo daemon restart restart the local kilo daemon + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] + --json print daemon details as JSON [boolean] + -f, --foreground keep the command active until interrupted [boolean] +``` + +### kilo daemon start + +``` +start the local kilo daemon + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] + --json print daemon details as JSON [boolean] + -f, --foreground keep the command active until interrupted [boolean] +``` + +### kilo daemon status + +``` +show local kilo daemon status + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --json print daemon details as JSON [boolean] +``` + +### kilo daemon stop + +``` +stop the local kilo daemon + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --json print daemon details as JSON [boolean] +``` + +### kilo daemon restart + +``` +restart the local kilo daemon + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] + --json print daemon details as JSON [boolean] + -f, --foreground keep the command active until interrupted [boolean] +``` + +## kilo console + +``` +open or stop the local Kilo Console + +Commands: + kilo console open the local Kilo Console [default] + kilo console stop stop the local kilo daemon + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] + -f, --foreground keep the command active until interrupted [boolean] +``` + +### kilo console stop + +``` +stop the local kilo daemon + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --json print daemon details as JSON [boolean] +``` + ## kilo db ``` 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 7a647f2e3e2..6c6e5cb6523 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -90,7 +90,8 @@ For detailed help on every command and subcommand, see the [CLI Command Referenc | `/compact` | `/summarize` | Compact/summarize session | | `/undo` | - | Undo previous message | | `/redo` | - | Redo message | -| `/copy` | - | Copy session transcript | +| `/copy` | - | Copy latest agent response | +| `/copy-session` | - | Copy session transcript | | `/export` | - | Export session transcript | | `/timestamps` | `/toggle-timestamps` | Show/hide timestamps | | `/thinking` | `/toggle-thinking` | Show/hide thinking blocks | @@ -152,8 +153,60 @@ Configuration is managed through: - `/connect` command for provider setup (interactive) - Config files in **`~/.config/kilo/`**: use **`kilo.jsonc`** for provider, model, permission, and **MCP** settings. Restart the CLI after editing. See [Using MCP in Kilo Code](/docs/automate/mcp/using-in-kilo-code) for MCP config format. +- **`tui.jsonc`** for terminal UI settings such as notifications, sounds, themes, and keybindings - `kilo auth` for credential management +## CLI Notifications and Sounds + +CLI attention alerts are disabled by default. Enable and configure them in either of these ways: + +- Run `kilo console`, open your project, then go to **Settings > CLI > Notifications**. +- Edit the TUI configuration directly. Use `~/.config/kilo/tui.jsonc` (or `tui.json`) for global settings, or `.kilo/tui.json` (or `tui.jsonc`) for project settings. + +The Console exposes the attention, desktop notification, sound, and volume controls. The equivalent TUI configuration is: + +```json +{ + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4 + } +} +``` + +- `enabled` is the master switch. When it is `false`, no attention notifications or sounds are delivered. +- `notifications` requests a desktop notification when the terminal is not focused. Your terminal and operating system decide whether the notification is displayed. +- `sound` enables the built-in attention sounds. Sounds can play while the terminal is focused. +- `volume` accepts a value from `0` to `1`. + +### Custom Sounds + +To replace individual sounds, add file paths under `attention.sounds`: + +```json +{ + "attention": { + "enabled": true, + "sound": true, + "volume": 0.4, + "sounds": { + "question": "./sounds/question.mp3", + "permission": "./sounds/permission.mp3", + "error": "./sounds/error.mp3", + "done": "./sounds/done.mp3" + } + } +} +``` + +Supported sound names are `default`, `question`, `permission`, `error`, `done`, and `subagent_done`. Relative paths are resolved from the directory containing the TUI configuration file. If an override cannot be loaded, Kilo falls back to the active sound pack and then the built-in `opencode.default` pack. + +The `attention.sound_pack` setting selects a sound pack registered by a TUI plugin. Setting an arbitrary pack name does not install or load a pack. Per-event file overrides remain the simplest way to customize sounds without a plugin. + +There is no notification slash command or command-palette toggle. Use Kilo Console or `tui.json` / `tui.jsonc` so all attention behavior is controlled by the same configuration. + ## Slash Commands The CLI's interactive mode supports slash commands for common operations. The main commands are documented above in the [Interactive Slash Commands](#interactive-slash-commands) section. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md b/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md index 4e0f2a18bb3..5a9fe22b843 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md @@ -147,6 +147,12 @@ Triggers allow you to initiate cloud agent sessions automatically, either via HT Triggers are currently in beta and subject to change. {% /callout %} +Webhook triggers and scheduled triggers use the same trigger concepts across Cloud +Agent and KiloClaw, but target different agents. Use Cloud Agent triggers when an +HTTP event or schedule should start a Cloud Agent session against a repository. +Use KiloClaw triggers when the event should deliver a chat message to a KiloClaw +instance. + ### Accessing Triggers Triggers are accessible from the main sidebar under **Webhooks / Triggers** and link to [https://app.kilo.ai/cloud/triggers](https://app.kilo.ai/cloud/triggers) for personal accounts. Organization-level trigger configurations are available through your organization's sidebar. @@ -186,6 +192,15 @@ Additional limits: The trigger endpoint will return rate limit responses when the number of queued or processing requests exceeds system capacity. +### Request History + +Open a trigger's request history to inspect recent invocations. History entries +show the source (webhook or scheduled), status such as captured, in progress, +success, or failed, request metadata, payload details when available, and links +or sharing actions for the resulting session. Use this view to debug webhook +payloads, scheduled runs, and organization handoff without changing the trigger +configuration. + ### Prompt Template Variables You can reference data in a trigger’s prompt template using these placeholders. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md index afb8928db5b..0ce89b82ada 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md @@ -1,21 +1,22 @@ --- title: "Kilo Connect" -description: "Use Kilo Code directly from Slack, GitHub, and Linear" +description: "Use Kilo Code from Slack, GitHub, and Linear, and connect DoltHub data" --- # Kilo Connect -**Kilo Connect** brings Kilo Code into the tools your team already uses. Instead of switching to a separate interface, you can trigger implementations, ask questions, and get pull requests opened directly from your chat, issue tracker, or code review workflow. +**Kilo Connect** brings Kilo Code into the tools your team already uses. Instead of switching to a separate interface, you can trigger implementations, ask questions, and get pull requests opened from your chat, issue tracker, or code review workflow. You can also connect DoltHub as a data source for Dolt-versioned data. --- ## Supported Integrations -| Integration | Trigger | What It Can Do | +| Integration | Entry Point | What It Can Do | |---|---|---| | [Slack](/docs/code-with-ai/platforms/slack) | `@Kilo` in any channel or DM | Ask questions, implement fixes, debug issues | | [GitHub](/docs/code-with-ai/platforms/github) | `@kilocode-bot` on issues and PRs | Fix issues, review code, cross-repo changes | | [Linear](/docs/code-with-ai/platforms/linear) | `@kilo` on any issue | Implement fixes, investigate bugs, cross-repo changes | +| DoltHub | [Connect DoltHub](https://app.kilo.ai/integrations/dolthub) from Integrations | Query Dolt-versioned data from your workspace | --- @@ -24,5 +25,5 @@ description: "Use Kilo Code directly from Slack, GitHub, and Linear" All integrations are configured from the **Integrations** tab at [app.kilo.ai](https://app.kilo.ai). Each integration requires: - A Kilo Code account with available credits -- A connected Git provider (GitHub or GitLab) so Kilo can access your repositories - The specific integration installed and authorized for your workspace +- A connected Git provider (GitHub or GitLab) for repository workflows such as Cloud Agents, Code Reviews, and Kilo Deploy diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md index ec23078ad6e..44b2b74d415 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md @@ -8,7 +8,7 @@ description: "Using Kilo Code on iOS and Android" Use Kilo Code from your phone to keep coding sessions moving while you are away from your desk. The mobile app connects to Cloud Agents, KiloClaw, and remote sessions from your local CLI or editor extensions. {% callout type="info" title="Android app available now" %} -Install Kilo Code for Android from [Google Play](https://kilocode.onelink.me/ZzZZ/r10ni4zy). +Install Kilo Code for Android from [Google Play](https://play.google.com/store/apps/details?id=com.kilocode.kiloapp). {% /callout %} ## What you can do @@ -21,6 +21,10 @@ The mobile app lets you: - Monitor and view all non-remote sessions in one place. - Create, onboard, and manage KiloClaw instances. +## Kilo Pass and Billing + +For Kilo Pass pricing, billing, and account management details, use the [Kilo Pass pricing page](https://kilo.ai/pricing/kilo-pass). + {% imageGallery columns="3" width="220px" %} {% image src="/docs/img/mobile-apps/home.webp" alt="Kilo Code mobile home screen showing KiloClaw and active agent sessions" caption="Start coding tasks, open KiloClaw, and resume active sessions from the mobile home screen." /%} @@ -39,7 +43,7 @@ The mobile app lets you: The Android app is available now on Google Play. -[Install the Android app →](https://kilocode.onelink.me/ZzZZ/r10ni4zy) +[Install the Android app →](https://play.google.com/store/apps/details?id=com.kilocode.kiloapp) ## iOS App diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md index 9c3cfb5c07d..ba857166eb4 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md @@ -41,6 +41,29 @@ Key features include: Settings apply across extension surfaces, including the sidebar and Agent Manager. The standalone CLI uses the same `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) files when used directly. +## Proxy and Certificate Troubleshooting + +Kilo Code for VS Code starts its embedded runtime from the extension and applies the relevant VS Code network settings to that runtime. On managed networks, configure proxy and certificate trust in VS Code settings rather than in a separate CLI install. + +Use these settings when your organization requires a proxy or inspects HTTPS traffic: + +- Set `http.proxy` to your organization proxy URL. +- Use `http.noProxy` for hosts that should bypass the proxy. +- Leave `http.proxySupport` enabled unless you intentionally want VS Code and Kilo Code to ignore proxy settings. +- Install your organization's root certificate authority in the operating system trust store when HTTPS inspection is in use. +- If the operating system trust store is not enough, set `kilo-code.new.extraCaCerts` to the absolute path of a PEM file that contains the additional certificate authority certificates. +- Keep `http.proxyStrictSSL` enabled whenever possible. Disable it only as a temporary troubleshooting step or when your administrator explicitly requires it, because it disables TLS certificate verification for this path. + +Example user or workspace settings: + +```json +{ + "http.proxy": "http://proxy.example.com:8080", + "http.noProxy": ["localhost", "127.0.0.1", ".example.internal"], + "kilo-code.new.extraCaCerts": "/absolute/path/to/corporate-ca.pem" +} +``` + {% /tab %} {% tab label="VSCode (Legacy)" %} diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md index 5bba0acd3f9..7ae258de9f1 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md @@ -71,7 +71,7 @@ Low Depth indicates that developers may be trying AI but not trusting or shippin **Actions:** -1. Enable [Managed Indexing](/docs/deploy-secure/managed-indexing) to improve context quality +1. Enable [Codebase Indexing](/docs/customize/context/codebase-indexing) to improve context quality 2. Review whether suggestions are relevant to your codebase 3. Introduce chained workflows to increase multi-stage usage @@ -119,7 +119,7 @@ Use the score tiers as milestones: **For Depth:** - "Chain Challenge" — Complete one feature using plan → build → review -- Managed Indexing rollout — Enable better context for the whole team +- Codebase Indexing rollout - Enable better context for the whole team - Deploy previews — Validate AI output before merging **For Coverage:** @@ -170,7 +170,7 @@ The AI Adoption Score is designed to be quotable: > > **Key Actions Taken:** > -> - Enabled Managed Indexing for better AI context +> - Enabled Codebase Indexing for better AI context > - Introduced Code Reviews for all PRs > - Onboarded 3 inactive team members > diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md index 126a8adc156..949329076f5 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md @@ -73,7 +73,7 @@ Linking coding → review → deploy actions significantly boosts your Depth sco If acceptance rates are low, the issue is often context. The AI is making suggestions without understanding your codebase. -**Action:** Enable [Managed Indexing](/docs/deploy-secure/managed-indexing) to give the model vector-backed search across your repository. +**Action:** Enable [Codebase Indexing](/docs/customize/context/codebase-indexing) to give the model vector-backed search across your repository. Better context leads to: @@ -171,7 +171,7 @@ Other ways to spread usage: 1. Identify your most active users and learn what they're doing 2. Introduce Code Reviews to spread usage -3. Enable Managed Indexing for better context +3. Enable Codebase Indexing for better context 4. Set a monthly score goal (e.g., "reach 55 by next month") ### If You're at 51–75 (Growing Adoption) diff --git a/packages/kilo-docs/pages/contributing/architecture/agent-observability.md b/packages/kilo-docs/pages/contributing/architecture/agent-observability.md deleted file mode 100644 index c99994d9fb8..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/agent-observability.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: "Agent Observability" -description: "Observability and monitoring for agentic coding systems" ---- - -# Kilo Code - Agent Observability - -## Problem Statement - -Agentic coding systems like Kilo Code operate with significant autonomy, executing multi-step tasks that involve LLM inference, tool execution, file manipulation, and external API calls. These systems mix traditional systems observability (i.e. request/response) with agentic behavior (i.e. planning, reasoning, and tool use). - -At the lower level, we can observe the system as a traditional API, but at the higher level, we need to observe the agent's behavior and the quality of its outputs. - -Some examples of customer-facing error modes: - -- Model API calls may be slow or fail due to rate limits, network issues, or model unavailability -- Model API calls may produce invalid JSON or malformed responses -- An agent may get stuck in a loop, repeatedly attempting the same failing operation -- Sessions may degrade gradually as context windows fill up -- The agent may complete a task technically but produce incorrect or unhelpful output -- Users may abandon sessions out of frustration without explicit error signals - -All of these contribute to the overall reliability and user experience of the system. - -## Goals - -1. Detect and alert on acute incidents within minutes -2. Surface slow-burn degradations within hours -3. Facilitate root cause analysis when issues occur -4. Track quality and efficiency trends over time -5. Build a foundation for continuous improvement of the agent - -**Non-goals for this proposal:** - -- Automated remediation -- A/B testing infrastructure -- Offline benchmarking and model/agent comparison (covered by [Benchmarking](/docs/contributing/architecture/benchmarking)) - -## Proposed Approach - -Focus on the lower-level systems observability first, then build up to higher-level agentic behavior observability. - -## Phase 1: Systems Observability - -**Objective:** Establish awareness and alerting for hard failures. - -This phase focuses on systems metrics we can capture with minimal changes, providing immediate operational visibility. - -### Phase 1a: LLM observability and alerting - -#### Metrics to Capture - -Capture these metrics per LLM API call: - -- Provider -- Model -- Tool -- Latency -- Success / Failure -- Error type and message (if failed) -- Token counts -- Source (CLI/JetBrains/VSCode/etc) - -#### Dashboards - -Common dashboards which offer filtering based on provider, model, and tool: - -- Error rate -- Latency -- Token usage - -#### Alerting - -Implement [multi-window, multi-burn-rate alerting](https://sre.google/workbook/alerting-on-slos/) against error budgets: - -| Window | Burn Rate | Action | Use Case | -|---|---|---|---| -| 5 min | 14.4x | Page | Major Outage | -| 30 min | 6x | Page | Incident | -| 6 hr | 1x | Ticket | Change in behavior | - -Paging should **only occur on Recommended Models when using the Kilo Gateway**. All other alerts should be tickets, and some may be configured to be ignored. - -**Initial alert conditions:** - -- LLM API error rate exceeds SLO (per tool/model/provider) -- Tool error rate exceeds SLO (per tool/model/provider) -- p50/p90 latency exceeds SLO (per tool/model/provider) - -### Phase 1b: Session metrics - -#### Metrics to Capture - -**Per-session (aggregated at session close or timeout):** - -- Session duration -- Time from user input to first model response -- Total turns/steps -- Total tool calls by tool type -- Total errors by error type - - Agent stuck errors (repetitive tool calls, etc) - - Tool call errors -- Total tokens consumed -- Context condensing frequency -- Termination reason (user closed, timeout, explicit completion, error) - -#### Alerting - -None. - -## Phase 2: Agent Tool Usage - -**Objective:** Detect how agents are using tools in a given session. - -### Metrics to Capture - -**Loop and repetition detection:** - -- Count of identical tool calls within a session (same tool + same arguments) -- Count of identical failing tool calls (same tool + same arguments + same error) -- Detection of oscillation patterns (alternating between two states) - -**Progress indicators:** - -- Unique files touched per session -- Unique tools used per session -- Ratio of repeated to unique operations - -### Alerting - -None to start, we will learn. - -## Phase 3: Session Outcome Tracking - -**Objective:** Understand whether sessions are successful from the user's perspective. - -Hard errors and behavior metrics tell us about failures, but we also need signal on overall session health. - -### Metrics to Capture - -**Explicit signals:** - -- User feedback (thumbs up/down) rate and sentiment -- User abandonment patterns (session ends mid-task without completion signal) - -**Implicit signals:** - -May require LLM analysis of session transcripts to detect: - -- Session termination classification (completed, abandoned, errored, timed out) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md deleted file mode 100644 index 043f65a4b10..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -title: "Auto Model Tiers" -description: "Architecture of the Auto Model tiers — a family of smart model tiers that match users to the right models without requiring AI expertise" ---- - -# Auto Model Tiers - -## Overview - -Auto Model is a routing system that automatically selects the optimal AI model based on the user's current mode (Code, Architect, Debug, etc.). It comes in multiple tiers so that every user — regardless of budget, preference, or expertise — gets a "just works" experience without needing to understand the AI model landscape. - -Three tiers are user-facing, and one is internal: - -| Tier ID | Audience | Pricing | -|---|---|---| -| `kilo-auto/frontier` | Best paid models | Paid | -| `kilo-auto/balanced` | Strong performance, lower cost | Paid | -| `kilo-auto/free` | Best available free models | Free | -| `kilo-auto/small` | Internal — background tasks | Varies | - -## Problem - -### Users shouldn't need to be AI model experts - -The AI model landscape is overwhelming. There are hundreds of models across dozens of providers, with different pricing, capabilities, context windows, and availability. Most developers just want to write code — they don't want to research which model is best for their task, budget, and workflow. - -Without Auto Model, three groups are underserved: - -1. **Free users** — They see a list of free models that changes on promotional periods and shifting availability. Which one is the best? Which is good for a particular task? They have no way to know without trial and error. - -2. **Cost-conscious users** — They want something better than free but cheaper than frontier. Open-weight models are useful and significantly cheaper, but which one? Which version? The answer changes every few weeks. - -3. **Background tasks** — Kilo uses small models for things like generating session titles and commit messages. These should be invisible and reliable, not dependent on the user's model selection or credit status. - -### Free model churn creates a moving target - -Free models on OpenRouter appear and disappear based on promotional periods. A model that works well today may be gone next week. Users who manually selected a free model discover it's unavailable. Auto Model tiers absorb this churn — when the best free model changes, the mapping updates server-side and users keep working. - -## Tiers - -### Auto: Frontier - -**Who it's for**: Users who want the best available models and are willing to pay for them. - -**What it does**: Routes between the best paid models based on the task — stronger reasoning models for planning and architecture, faster models for code generation and editing. Optimizes for the best balance of capability, speed, and token efficiency. - -**Pricing**: Paid. Uses credits. - -For the current mode-to-model mappings, see the [Auto Model user docs](/docs/code-with-ai/agents/auto-model#tiers). - -### Auto: Balanced - -**Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. - -**What it does**: Routes to a cost-effective model based on the API interface used by the client. Requests using the Completions API (default) route to `qwen/qwen3.6-plus`; Responses API requests route to `openai/gpt-5.5`; Messages API requests route to `anthropic/claude-sonnet-4.6`. Unlike Frontier, Balanced does not vary its underlying model by mode. - -**Pricing**: Paid, but significantly cheaper than Frontier. - -For the current mode-to-model mappings, see the [Auto Model user docs](/docs/code-with-ai/agents/auto-model#tiers). - -### Auto: Free - -**Who it's for**: Users who want to try Kilo without a credit card, students, hobbyists, and anyone exploring AI-assisted coding. - -**What it does**: Routes each session to one of the best available free models, selected deterministically based on the session (or user/IP) so a given session sticks with one model. The full candidate pool is determined server-side from curated preferred free models, and updated transparently as availability changes due to promotional periods. Users always get the best free option without having to track which models are currently available. - -**Pricing**: Free. No credits required. - -**Constraints**: Free models do not vary by mode — the same model is used for every mode within a session. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. - -**Data handling**: Auto Free may route to providers that log prompts and outputs and use them to improve their services, including NVIDIA's free endpoints (governed by the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)). This is surfaced to users alongside Auto Free mentions in the user-facing docs. - -### Auto: Small (internal) - -**Who it's for**: Not user-facing. Used internally by Kilo for lightweight background tasks (session titles, commit messages, conversation summaries). - -**What it does**: Automatically selects the right small model for lightweight tasks. When the account has a positive balance, it uses a fast paid small model; otherwise it falls back to a free small model. - -**Why it matters**: Users never think about background tasks, and they shouldn't have to. Auto: Small ensures these tasks always work, always feel fast, and never waste credits on an expensive model when a cheap one will do. - -**Implementation**: The `getSmallModel()` function in `packages/opencode/src/provider/provider.ts` prioritizes `kilo-auto/small` when the Kilo provider is active. If the user's provider doesn't have a dedicated small model, it falls back globally to `kilo-auto/small` when available. - -## User experience - -### Model picker - -The three user-facing tiers appear in the model selector: - -| Display Name | Description shown to user | -|---|---| -| Auto: Frontier | Best paid models, automatically matched to your task | -| Auto: Balanced | Strong performance at lower cost | -| Auto: Free | Best free models, no credits required | - -Auto: Small does not appear in the model picker. It is filtered out by the UI (see `KILO_AUTO_SMALL_IDS` in the VS Code extension). - -### Defaults - -- **All new users**: Default to `kilo-auto/free` (defined in `packages/kilo-gateway/src/api/constants.ts`) - -This means a brand-new user gets a working experience immediately — no model selection or credits required. - -### What users see - -The UI shows the tier name (e.g., "Auto: Frontier"), not the underlying model. Users don't need to know or care that their planning request went to Opus and their coding request went to Sonnet. The abstraction is the product. - -## Implementation architecture - -Auto Model uses a split client/server architecture. The actual model-to-mode mappings are not hardcoded in the client — they're served dynamically from the Kilo API, making it possible to update routing without client releases. - -### Server side (Kilo API) - -The Kilo API at `api.kilo.ai` defines which underlying models each `kilo-auto/*` tier routes to per mode. Each auto model is returned with an `opencode.variants` field — a map of mode-specific provider options: - -```json -{ - "opencode": { - "variants": { - "architect": { "model": "anthropic/claude-opus-4.7", ... }, - "code": { "model": "anthropic/claude-sonnet-4.6", ... } - } - } -} -``` - -This is fetched via `packages/kilo-gateway/src/api/models.ts` which parses the `opencode.variants` field from the API response. - -### Client side - -The client-side chain works as follows: - -1. **Model fetching**: `packages/opencode/src/provider/model-cache.ts` caches Kilo Gateway models with a 5-minute TTL, fetching from the Kilo API. - -2. **Variant passthrough**: `packages/opencode/src/provider/transform.ts` — the `variants()` function passes through server-defined variants for Kilo Gateway models directly, rather than computing them locally. - -3. **Variant storage**: `packages/opencode/src/provider/provider.ts` stores `variants` on the model object when the provider is `kilo`. - -4. **Agent variant resolution**: Each agent (mode) specifies a `variant` in its config (`packages/opencode/src/config/config.ts`). At prompt time, `packages/opencode/src/session/prompt.ts` resolves the variant from the agent config and attaches it to the user message. - -5. **LLM call merging**: At call time, `packages/opencode/src/session/llm.ts` merges the variant's options (including the actual underlying model ID) into the provider options sent to OpenRouter. - -### Key files - -| File | Role | -|---|---| -| `packages/kilo-gateway/src/api/constants.ts` | Default model constants (`DEFAULT_MODEL`, `DEFAULT_FREE_MODEL`) | -| `packages/kilo-gateway/src/api/models.ts` | Fetches models from Kilo API, parses `opencode.variants` | -| `packages/opencode/src/provider/model-cache.ts` | Caches Kilo Gateway models with 5-min TTL | -| `packages/opencode/src/provider/provider.ts` | Preserves variants for kilo provider; `getSmallModel()` prioritizes `kilo-auto/small` | -| `packages/opencode/src/provider/transform.ts` | Passes through server-defined variants for Kilo Gateway models | -| `packages/opencode/src/session/prompt.ts` | Resolves variant from agent config, attaches to user messages | -| `packages/opencode/src/session/llm.ts` | Merges variant options into LLM call parameters | -| `packages/opencode/src/config/config.ts` | Agent config schema includes `variant` field | - -## Requirements - -- Unauthenticated users default to `kilo-auto/free` with no configuration required -- All tiers use mode-based routing where the underlying models support it -- When a tier routes to different model families across turns in a conversation, thinking/reasoning blocks from the previous model are stripped to prevent compatibility errors -- Auto Model requires **VS Code/JetBrains extension v5.2.3+** or **CLI v1.0.15+** for mode-based switching. Older versions fall back to a single model for all requests. - -## Risks - -| Risk | User impact | Mitigation | -|---|---|---| -| Free model disappears mid-session | User's next message fails | Fallback chain: primary → secondary → tertiary free model. Graceful error only if all options exhausted. | -| Model quality variance across free/balanced tiers | Inconsistent experience compared to Frontier | Set clear expectations in UI. Curate model lists, don't just pick the cheapest. | -| Cross-family model switching breaks context | Thinking blocks from Model A incompatible with Model B | Strip thinking blocks when the underlying model family changes between turns. Frontier stays within one family so this primarily affects Free tier (which may switch models). | -| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Free for all new users) so most users never need to actively choose. | - -## Data and compliance - -- **Frontier**: Uses Anthropic models with no training on user data. -- **Balanced**: As a paid tier, underlying providers are selected with data-handling policies suitable for professional use. Prefer providers with stronger privacy posture when updating the routing. -- **Free**: May route to providers that log prompts and outputs and use them to improve their services, including NVIDIA's free endpoints (see [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)). Users should avoid submitting personal or confidential data. Surface this disclosure in proximity to every user-facing Auto Free mention. -- **Small**: Same concern as Balanced/Free — the model selected depends on credit status, which may route to providers with different policies. - -## Features for the future - -- **Resolved model transparency**: Show the actual model being used on hover/click for users who want to know -- **Per-agent tier overrides**: Let users pick Frontier for their code agent but Free for explore -- **Auto model changelog**: A status page or in-product notification when tier mappings change -- **Tier analytics**: Dashboard showing which models each tier resolves to, latency, error rates, quality metrics -- **Enterprise open-weight preference**: Organizations that require open-weight models for auditability could enforce the Balanced tier across their team diff --git a/packages/kilo-docs/pages/contributing/architecture/automation-services.md b/packages/kilo-docs/pages/contributing/architecture/automation-services.md new file mode 100644 index 00000000000..6f8b73ff0af --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/automation-services.md @@ -0,0 +1,190 @@ +--- +title: "Automation Services Architecture" +description: "Architecture of Kilo Cloud automation services that dispatch scoped work" +--- + +# Automation Services Architecture + +Automation services turn commands, source-control events, labels, review requests, HTTP webhooks, and schedules into scoped work. + +{% callout type="info" title="Static source scope" %} +This page describes cloud automation boundaries present in `Kilo-Org/cloud`. Static source shows supported code paths, Worker bindings, and deployable surfaces. It does not prove live production enablement or rollout policy. +{% /callout %} + +## How to use this page + +Use this page for trigger-to-execution workflows: what starts work, which owner authorizes it, where orchestration state lives, when Cloud Agent launches, how output returns, and how stuck work recovers. Use [Cloud Platform](/docs/contributing/architecture/cloud-platform) for hosted service topology and [Cloud Security](/docs/contributing/architecture/cloud-security) for trust boundaries. + +## Ownership model + +Automation state and credentials are scoped to owner where supported. Owner is personal user or organization. Personal and organization paths stay separate so credentials, concurrency, findings, and callbacks do not collapse into global automation state. + +| Dimension | Model | +|---|---| +| Owner scope | Personal user owner or organization owner, handled separately where supported | +| Source-control target | GitHub is primary across automation; GitLab target support exists in selected paths | +| Command ingress | GitHub, Slack, and Linear command surfaces are distinct from target repository support | +| Credentials | Web control plane resolves user, bot, or installation token and passes scoped access to Worker or Cloud Agent | +| Callback auth | Workers use internal secrets, service bindings, or per-run callback secrets depending on flow | +| Cloud Agent sandbox | Launches inherit policy-selected sandbox allocation and session-specific workspace paths; see [Cloud Agent](/docs/contributing/architecture/cloud-platform#cloud-agent) | + +## Common lifecycle + +Most automation paths follow same shape. Individual services can stop before Cloud Agent or select different destination. + +```mermaid +flowchart LR + trigger["Command, webhook, label, schedule, or manual dispatch"] + web["Web control plane"] + worker["Worker, queue, or Durable Object"] + agent["Cloud Agent when coding work is needed"] + output["Callback, status update, or product-facing output"] + scm["GitHub or GitLab target repository"] + + trigger --> web + trigger --> worker + web --> worker + worker --> agent + worker --> output + agent --> scm + agent --> output + output --> web +``` + +| Stage | Responsibility | +|---|---| +| Trigger | Accept command, source-control event, label, webhook, schedule, or manual dispatch | +| Authorization owner | Resolve personal or organization scope and permitted credentials | +| Orchestration | Store durable work state and coordinate queues, Durable Objects, callbacks, and alarms | +| Execution target | Launch Cloud Agent only when repository or structured coding work requires it; some flows stop earlier or select another destination | +| Output | Post review, label, pull request, finding state, callback, or destination message | +| Recovery | Retry queue delivery, enforce timeout alarm, reconcile stale state, or dispatch next waiting item | + +## Service inventory + +| Service | Trigger | Orchestration boundary | Execution target | Output or recovery | +|---|---|---|---|---| +| Kilo Bot | GitHub, Slack, or Linear command ingress | Web control plane bot libraries | Cloud Agent for requested repository work | Command response and coding-session result | +| Code Review | Pull-request webhook or review dispatch | Database queue and `code-review-infra` Durable Object per review | Cloud Agent review session | Pull-request feedback; dispatch next waiting review | +| Auto Triage | GitHub issue event or dispatch queue | Web duplicate check and `auto-triage-infra` Durable Object per ticket | Cloud Agent only when classification session is needed | Labels, status, callback, and timeout alarm | +| Auto Fix | `kilo-auto-fix` label or dispatch rule | `auto-fix-infra` Durable Object per fix ticket | Cloud Agent branch and pull-request work | Pull request and lifecycle status callback | +| Security Agent | Interactive or scheduled Dependabot sync plus analysis queue | `security-sync` and `security-auto-analysis` Workers | Model triage in `security-auto-analysis`; Cloud Agent only for selected deep analysis | Finding state, audit records, and stale-analysis cleanup | +| Webhook Agent Ingest | HTTP webhook or scheduled alarm | `webhook-agent-ingest` queue and `TriggerDO` | Cloud Agent or Kilo Chat destination | Destination delivery and queue retry behavior | + +## Kilo Bot ingress and source-control targets + +Kilo Bot command ingress and repository target support are separate concerns. + +| Concern | Current static-source statement | +|---|---| +| Command ingress | GitHub, Slack, and Linear are current Kilo Bot command surfaces | +| GitHub target | Supported across bot, review, triage, fix, security, and Cloud Agent paths | +| GitLab target | Supported in selected Cloud Agent and bot context paths | +| GitLab command ingress | GitLab repository support does not imply note or comment-triggered Kilo Bot commands | + +Do not document GitLab issue notes or merge-request comments as current Kilo Bot trigger surfaces unless source adds explicit ingress handling. + +## Code Review + +Code Review queues pull-request work in database, enforces per-owner concurrency in Next.js dispatch layer, and starts `CodeReviewOrchestrator` Durable Object when slot is available. + +| Concern | Behavior | +|---|---| +| Trigger | Pull-request webhook or review dispatch | +| Authorization owner | Per-owner dispatch slots separate concurrent review work | +| Queue | Reviews wait in database as pending rows | +| Orchestration | Durable Object keeps Cloud Agent connection alive | +| Output | Review feedback is posted back to pull request | +| Recovery | Worker updates database and triggers dispatch of next pending review | + +## Auto Triage + +Auto Triage classifies GitHub issues and applies labels or status updates. Duplicate check happens through web backend. Non-duplicate issues can launch Cloud Agent classification through prepare, initiate, and callback flow. + +| Concern | Behavior | +|---|---| +| Trigger | GitHub issue event or dispatch queue | +| Duplicate check | Calls Next.js API and can complete without Cloud Agent | +| Execution | Cloud Agent session runs structured classification prompt when needed | +| Callback | `POST /tickets/:ticketId/classification-callback` with per-ticket secret | +| Output | High-confidence classification applies labels such as `kilo-auto-fix` for downstream Auto Fix | +| Recovery | Durable Object alarm marks stuck ticket failed | + +## Auto Fix + +Auto Fix receives dispatch requests when issues are selected for automated fixes. Durable Object manages fix session state, launches Cloud Agent, and reports status to backend. + +| Concern | Behavior | +|---|---| +| Trigger | Label or dispatch rule selects issue for fixing | +| Orchestration | `AutoFixOrchestrator` owns fix session state | +| Execution | Cloud Agent creates branch and pull request | +| Output | Worker reports lifecycle updates to internal backend API | + +## Security Agent + +Security Agent splits finding sync from analysis. Findings, queue rows, and owner state remain scoped to personal or organization owner. + +| Concern | Owner | +|---|---| +| Interactive finding sync | Web product checks owner integration permissions, fetches Dependabot alerts, normalizes results, and upserts findings | +| Scheduled finding sync | `security-sync` six-hour cron selects enabled GitHub security-scan owners and emits one owner-level queue message per owner | +| Scheduled sync consumer | `security-sync` filters owner repositories, resolves owner-scoped GitHub credentials through Git Token Service binding, and updates findings, SLA dates, and audit state through Hyperdrive | +| Analysis lifecycle | `security-auto-analysis` claims queued analysis rows, runs model triage, and launches Cloud Agent only when deep analysis is needed | +| Cleanup | Web cron reconciles stale `running` findings only when no matching queue row remains `pending` or `running` | + +Static source proves scheduled sync and separate auto-analysis infrastructure. It does not prove newly synced findings are automatically enqueued for analysis. See [Cloud Platform](/docs/contributing/architecture/cloud-platform#security-agent) for durable topology and [Cloud Security](/docs/contributing/architecture/cloud-security#security-agent-sync-and-cleanup) for trust boundaries. + +## App Builder orchestration boundaries + +App Builder is prompt-driven product orchestration, not normal automation ingress. Cloud Agent owns generated-app coding and iteration. Preview, deployment build, and public deployed-app ingress use separate service boundaries. See [Cloud Platform](/docs/contributing/architecture/cloud-platform#app-generation-boundaries) for canonical phase topology and [Cloud Security](/docs/contributing/architecture/cloud-security#generated-application-preview-and-deployment) for trust boundaries. + +## Webhook Agent Ingest + +Webhook Agent Ingest handles configured trigger endpoints and schedules. `TriggerDO` stores trigger config and scheduled alarms. Queue consumer dispatches selected destination. + +| Dimension | Variants | Notes | +|---|---|---| +| Activation | HTTP webhook | Can apply configured webhook authentication before queued delivery | +| Activation | Scheduled | Uses cron expression and Durable Object alarm; webhook auth is not applicable | +| Destination | `cloud_agent` | Launches Cloud Agent session with webhook or scheduled platform marker | +| Destination | `kiloclaw_chat` | Posts to user-scoped Kilo Chat destination through Kilo Chat service binding | + +```mermaid +flowchart LR + http["HTTP webhook"] + schedule["TriggerDO scheduled alarm"] + queue["Webhook delivery queue"] + consumer["Queue consumer"] + agent["Cloud Agent"] + chat["Kilo Chat destination"] + + http --> queue + schedule --> queue + queue --> consumer + consumer --> agent + consumer --> chat +``` + +## Source map + +Paths below are relative to [`Kilo-Org/cloud`](https://github.com/Kilo-Org/cloud). + +| Service | Source paths | +|---|---| +| Kilo Bot | `apps/web/src/lib/bot/`{% linebreak /%}`apps/web/src/lib/bots/`{% linebreak /%}`apps/web/src/lib/slack-bot/` | +| Code Review | `apps/web/src/lib/code-reviews/`{% linebreak /%}`services/code-review-infra/` | +| Auto Triage | `apps/web/src/lib/auto-triage/`{% linebreak /%}`services/auto-triage-infra/` | +| Auto Fix | `apps/web/src/lib/auto-fix/`{% linebreak /%}`services/auto-fix-infra/` | +| Security Agent | `apps/web/src/lib/security-agent/`{% linebreak /%}`services/security-sync/`{% linebreak /%}`services/security-auto-analysis/` | +| App Builder preview | `apps/web/src/lib/app-builder/`{% linebreak /%}`services/app-builder/` | +| Generated-app deployment | `services/deploy-infra/builder/`{% linebreak /%}`services/deploy-infra/dispatcher/` | +| Webhook Agent Ingest | `services/webhook-agent-ingest/` | +| Cloud Agent | `services/cloud-agent-next/` | + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - local and hosted execution map +- [Cloud Platform](/docs/contributing/architecture/cloud-platform) - hosted layers, Cloudflare terms, Cloud Agent topology, and adjacent hosted runtimes +- [Cloud Security](/docs/contributing/architecture/cloud-security) - trust boundaries, persistence, controls, privacy, and shared responsibility +- [Development Patterns](/docs/contributing/architecture/development-patterns) - choose code-ownership seam before changing architecture-facing contracts diff --git a/packages/kilo-docs/pages/contributing/architecture/benchmarking.md b/packages/kilo-docs/pages/contributing/architecture/benchmarking.md deleted file mode 100644 index a0548e6eb17..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/benchmarking.md +++ /dev/null @@ -1,306 +0,0 @@ ---- -title: "Benchmarking" -description: "Design for benchmarking Kilo Code against models and other agents" ---- - -# Benchmarking - -## Summary - -This document proposes a benchmarking system for Kilo Code with two primary goals: - -1. **Compare models against one another** using the same agent -- measuring task completion, token cost, and total time -2. **Compare agents against one another** using the same model -- e.g., Kilo Code vs Claude Code, or Kilo Code v1.0 vs v1.1 - -The design leverages existing open source infrastructure rather than building a custom harness: - -- **[Harbor](https://harborframework.com)** as the evaluation framework, with **[Terminal-Bench](https://tbench.ai)** and other datasets for task definitions -- **[ATIF](https://harborframework.com/docs/agents/trajectory-format)** (Agent Trajectory Interchange Format) for structured, per-step trace logging -- **[Opik](https://www.comet.com/docs/opik)** for trace ingestion, step-level LLM judge evaluation, and root cause analysis - -The key engineering deliverable is a **Kilo Code Harbor adapter** that runs Kilo CLI autonomously in containerized environments and emits ATIF-compliant trajectories. - -{% callout type="info" %} -This is separate from [production observability](/docs/contributing/architecture/agent-observability), which monitors real user sessions via PostHog. Benchmarking is an offline evaluation system for comparing quality, cost, and performance across models and agents. -{% /callout %} - -## Problem Statement - -As Kilo Code evolves, we need systematic answers to questions like: - -- Did our latest release make the agent better or worse? -- Which model gives the best results for our users at a given price point? -- How does Kilo Code compare to Claude Code, Codex, or other agents on the same tasks? -- When a benchmark score drops, what specific step or decision caused the regression? - -Today we have no structured way to answer these questions. Manual testing is not reproducible, and our existing PostHog telemetry does not capture the turn-by-turn detail needed for easy comparative analysis. - -## Goals - -1. Run Kilo Code against standardized benchmark datasets in a reproducible, containerized environment -2. Compare model performance (same agent, different models) on task completion, token cost, and wall-clock time -3. Compare agent performance (same model, different agents or Kilo versions) on the same metrics -4. Capture detailed per-step traces for root cause analysis when results differ -5. Make it easy to create custom task sets for targeted evaluation or marketing purposes - -**Non-goals:** - -- Production monitoring (covered by [Agent Observability](/docs/contributing/architecture/agent-observability)) -- Automated remediation based on benchmark results - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Harbor Framework │ -│ │ -│ ┌──────────────┐ ┌─────────────┐ ┌─────────────────┐ │ -│ │Terminal-Bench│ │ SWE-bench │ │ Custom Tasks │ │ -│ │ 2.0 │ │ │ │ (Kilo-specific) │ │ -│ └──────┬───────┘ └──────┬──────┘ └───────┬─────────┘ │ -│ └────────────────┼─────────────────┘ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ Containerized Trial │ │ -│ │ │ │ -│ │ ┌─────────────────┐ │ │ -│ │ │ Agent Under │ │ │ -│ │ │ Test │ │ │ -│ │ │ (kilo --auto) │ │ │ -│ │ └────────┬────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ ┌─────────────────┐ │ │ -│ │ │ Model API │ │ │ -│ │ │ (Opus, GPT-5, │ │ │ -│ │ │ Gemini, etc.) │ │ │ -│ │ └─────────────────┘ │ │ -│ └───────────┬───────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ ATIF Trajectory │ │ -│ │ (per-step traces) │ │ -│ └───────────┬───────────┘ │ -└──────────────────────────┼──────────────────────────────┘ - │ - ┌────────────┴────────────┐ - ▼ ▼ -┌──────────────────────┐ ┌──────────────────────────┐ -│ tbench.ai Dashboard │ │ Opik │ -│ - Leaderboard │ │ - Step-level traces │ -│ - Task pass/fail │ │ - LLM judge per step │ -│ - Asciinema replay │ │ - Cost attribution │ -│ - Aggregate scores │ │ - Root cause comparison │ -└──────────────────────┘ └──────────────────────────┘ -``` - -## Components - -### Harbor Framework - -[Harbor](https://harborframework.com) is the evaluation framework built by the Terminal-Bench team. It provides: - -- **Containerized environments** for reproducible task execution -- **Pre-integrated agents**: Claude Code, Codex, Gemini CLI, OpenHands, Terminus-2 -- **A registry of benchmark datasets**: Terminal-Bench, SWE-bench, LiveCodeBench, and more -- **Cloud scaling** via Daytona, Modal, and E2B for running trials in parallel -- **Automatic ATIF trajectory generation** for all integrated agents - -Harbor is the standard evaluation framework used by many frontier labs. Rather than building our own harness, we write a Kilo Code adapter and plug into the existing ecosystem. - -### ATIF (Agent Trajectory Interchange Format) - -[ATIF](https://harborframework.com/docs/agents/trajectory-format) is a standardized JSON format for logging the complete interaction history of an agent run. Each trajectory captures: - -- **Every step**: User messages, agent responses, tool calls, observations -- **Per-step metrics**: Token counts (prompt, completion, cached), cost in USD, latency -- **Tool call detail**: Function name, arguments, and observation results -- **Reasoning content**: The agent's internal reasoning at each step (when available) -- **Aggregate metrics**: Total tokens, total cost, total steps - -This granularity is what enables step-level comparison between runs -- not just "did it pass or fail" but "at step 7, Agent A chose tool X while Agent B chose tool Y." - -### Opik - -[Opik](https://www.comet.com/docs/opik) (by Comet) provides trace ingestion and analysis with a first-class Harbor integration. Running benchmarks through Opik is as simple as: - -```bash -opik harbor run -d terminal-bench@head -a kilo -m anthropic/claude-opus-4 -``` - -Opik adds value beyond what the tbench.ai dashboard provides: - -| Capability | tbench.ai Dashboard | Opik | -|---|---|---| -| Task-level pass/fail | Yes | Yes | -| Aggregate leaderboard | Yes | No | -| Asciinema replay | Yes | No | -| Step-level trace view | No | Yes | -| Step-level LLM judge | No | Yes | -| Cost attribution per step | No | Yes | -| Side-by-side trace comparison | No | Yes | -| Root cause analysis | No | Yes | - -The two dashboards are complementary: tbench.ai for high-level leaderboard comparisons, Opik for drilling into why a specific run succeeded or failed. - -### Datasets - -Harbor's registry provides access to established benchmark datasets. The choice of dataset can vary depending on what you are evaluating: - -| Dataset | Focus | Use Case | -|---|---|---| -| Terminal-Bench 2.0 | CLI/terminal tasks (89 tasks) | General agent capability on hard, realistic tasks | -| SWE-bench | Real GitHub issues in real repos | Software engineering task completion | -| LiveCodeBench | Competitive programming problems | Code generation quality | -| Custom task sets | Whatever you define | Targeted evaluation, marketing, regression testing | - -#### Creating Custom Task Sets - -Creating a custom Harbor task set is straightforward. Each task consists of: - -1. **A Dockerfile** defining the environment (OS, installed packages, repo state) -2. **A task description** (the prompt given to the agent) -3. **A verification script** (tests that determine pass/fail) -4. **Optionally, a reference solution** - -This makes it easy to create task sets that target specific Kilo Code capabilities -- for example, a set of refactoring tasks, or a set of multi-file debugging scenarios. Custom sets can be published to the Harbor registry or kept private. - -See the [Harbor task tutorial](https://www.tbench.ai/docs/task-tutorial) for a step-by-step guide. - -## Deliverables - -### 1. Kilo Code Harbor Adapter - -The primary engineering deliverable. This adapter: - -- **Installs Kilo CLI** in a Docker container -- **Configures autonomous execution** using `kilo run --auto`, which disables all permission prompts so the agent runs fully unattended -- **Translates Harbor task prompts** into Kilo CLI invocations -- **Emits ATIF-compliant trajectories** capturing every step, tool call, and metric - -The adapter follows the same pattern as existing Harbor agents (see the [OpenHands adapter](https://harborframework.com/docs/agents/trajectory-format#openhands-example) for reference). The key implementation detail is the `populate_context_post_run` method that converts Kilo's execution log into ATIF format. - -**Autonomous execution is critical.** Harbor runs containerized trials in parallel and expects agents to execute from start to finish without human intervention. The adapter must ensure: - -- No interactive prompts for API keys (injected via environment variables) -- No permission dialogs for file writes, command execution, etc. -- Graceful timeout handling if the agent gets stuck - -### 2. Custom Task Set Template - -Documentation and examples for creating Kilo-specific task sets: - -- Template Dockerfile and verification script -- Guidelines for writing good task descriptions -- Examples of tasks that highlight coding agent capabilities -- Instructions for publishing to Harbor's registry or running privately - -This enables the team to create targeted benchmarks for marketing, regression testing, or capability evaluation. - -### 3. Opik Integration - -Configure the Opik-Harbor integration for Kilo Code benchmark runs: - -- Set up `opik harbor run` with the Kilo Code adapter -- Define standard LLM judge criteria for step-level evaluation: - - **Tool choice correctness**: Did the agent use the right tool at each step? - - **Reasoning quality**: Was the agent's reasoning at each step sound? - - **Efficiency**: Were there unnecessary or redundant steps? -- Create saved views for common comparison scenarios (model-vs-model, version-vs-version) - -### 4. CI Regression Detection - -{% callout type="note" %} -Lower priority. Implement after the core benchmarking system is working. -{% /callout %} - -Run a small subset of benchmark tasks (10-15) on release branches to catch regressions before shipping. Harbor supports this pattern natively. The subset should be chosen for: - -- Fast execution (under 5 minutes per task) -- High signal (tasks that historically differentiate good and bad agent behavior) -- Stability (deterministic verification, not flaky) - -## Example Workflows - -### Comparing Models - -Run the same Kilo Code agent against Terminal-Bench with different models: - -```bash -# Run with Claude Opus -opik harbor run -d terminal-bench@2.0 -a kilo -m anthropic/claude-opus-4 - -# Run with GPT-5 -opik harbor run -d terminal-bench@2.0 -a kilo -m openai/gpt-5 - -# Run with Gemini 3 Pro -opik harbor run -d terminal-bench@2.0 -a kilo -m google/gemini-3-pro -``` - -Compare results in tbench.ai for aggregate scores and in Opik for step-level analysis of where models diverge. - -### Comparing Agents - -Run different agents against the same dataset with the same model: - -```bash -# Run Kilo Code -opik harbor run -d terminal-bench@2.0 -a kilo -m anthropic/claude-opus-4 - -# Run Claude Code -opik harbor run -d terminal-bench@2.0 -a claude-code -m anthropic/claude-opus-4 -``` - -### Comparing Kilo Versions - -Test a new release against the previous version: - -```bash -# Run current release -opik harbor run -d terminal-bench@2.0 -a kilo@v2.0 -m anthropic/claude-opus-4 - -# Run candidate release -opik harbor run -d terminal-bench@2.0 -a kilo@v2.1-rc1 -m anthropic/claude-opus-4 -``` - -Use Opik's trace comparison view to identify specific steps where the new version regressed or improved. - -### Running a Custom Task Set - -```bash -# Run against a custom Kilo-specific dataset -opik harbor run -d kilo-refactoring@1.0 -a kilo -m anthropic/claude-opus-4 -``` - -## LLM Judge: Two Levels - -Harbor provides task-level judging (did the agent solve the task?). Opik adds step-level evaluation: - -| Level | Tool | What It Tells You | -|---|---|---| -| **Task-level** | Harbor | Pass/fail, score, total time, total cost | -| **Step-level** | Opik | At step N, the agent chose tool X when it should have used tool Y. The reasoning was flawed because of Z. This step cost $0.03 and took 4 seconds. | - -Step-level evaluation is where root cause debugging happens. When a benchmark score drops between versions, you can trace back to the exact decision point that caused the regression. - -## Relationship to Production Observability - -This benchmarking system is complementary to, but separate from, the [Agent Observability](/docs/contributing/architecture/agent-observability) system: - -| Concern | Benchmarking | Production Observability | -|---|---|---| -| **Purpose** | Offline evaluation of agent quality | Real-time monitoring of user sessions | -| **Data source** | Controlled benchmark tasks | Real user interactions | -| **Tools** | Harbor, Opik, tbench.ai | PostHog, custom metrics | -| **When** | Before release, on-demand | Continuously in production | -| **Output** | Leaderboard scores, trace comparisons | Alerts, dashboards, SLO tracking | - -## References - -- [Harbor Framework Documentation](https://harborframework.com/docs) -- [Terminal-Bench 2.0 Paper](https://huggingface.co/papers/2601.11868) -- [ATIF Specification (RFC)](https://github.com/laude-institute/harbor/blob/main/docs/rfcs/0001-trajectory-format.md) -- [Opik Harbor Integration](https://www.comet.com/docs/opik/integrations/harbor) -- [tbench.ai Dashboard](https://www.tbench.ai/docs/dashboard) -- [Harbor Task Tutorial](https://www.tbench.ai/docs/task-tutorial) diff --git a/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md b/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md new file mode 100644 index 00000000000..02963d704fd --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md @@ -0,0 +1,332 @@ +--- +title: "CLI Runtime Architecture" +description: "Architecture of the Kilo CLI runtime, daemon, server, persistence, SDK, and indexing" +--- + +# CLI Runtime Architecture + +The CLI (`packages/opencode/`) is Kilo Code's local agent engine. It owns agent execution, tools, sessions, provider integration, configuration, local persistence, directory routing, and HTTP surfaces used by editor clients and Kilo Console. + +{% callout type="info" title="Scope" %} +This page describes repository-defined local runtime behavior. It is not an endpoint catalog or a statement about cloud deployment configuration. +{% /callout %} + +## Concepts + +These terms describe local execution. They are separate from hosted Cloud Agent sessions described in [Cloud Platform](/docs/contributing/architecture/cloud-platform). + +| Term | Meaning | +|---|---| +| Kilo CLI runtime | Local agent engine in `packages/opencode/` | +| `kilo serve` server | Local HTTP and SSE process used by editor clients and Kilo Console; selected browser-oriented paths also use WebSocket | +| Local daemon | Detached reusable `kilo serve` server managed by `kilo daemon` commands | +| Directory context | Normalized local filesystem directory used to select local runtime state | +| Local runtime instance | Directory-keyed runtime context inside one Kilo CLI process | +| Local routing workspace | Optional routing context that can resolve to a local directory or remote target | +| Worktree directory | Alternate git worktree path used as directory context for isolated concurrent work | +| Process-shared state | Runtime service state shared by every directory context in one Kilo CLI process | +| Modes | Configurable agent presets for tools, prompts, restrictions, and behavior | +| MCP | Protocol for extending agent tools | + +One `kilo serve` process can host several local runtime instances. Directory-keyed state stays isolated. Process-shared service state does not. + +## Command entry points + +| Entry point | Command or caller | Runtime model | +|---|---|---| +| Interactive TUI | `kilo` | Attaches to local daemon when available; otherwise starts Bun worker and sends SDK-shaped requests over RPC | +| Headless run | `kilo run` | Uses daemon attach when available, then embedded server fetch fallback | +| Attached run | `kilo run --attach ` | Targets explicit running `kilo serve` server | +| Explicit API server | `kilo serve` | Starts HTTP + SSE server for external local clients | +| Local daemon | `kilo daemon start` | Starts detached `kilo serve` child for reuse | +| Browser console | `kilo console` | Starts or reuses local daemon and opens daemon-served `/console` UI | +| Editor-spawned server | VS Code or JetBrains client | Starts bundled `kilo serve --port 0` child owned by editor client, not local daemon manager | + +```mermaid +flowchart LR + run["kilo run"] + tui["kilo TUI"] + daemon["Detached daemon: kilo serve"] + worker["Bun worker"] + rpc["RPC-backed fetch and global events"] + embedded["Embedded Server.Default().app.fetch"] + serve["Explicit kilo serve"] + editors["VS Code or JetBrains"] + editorServer["Editor-owned kilo serve --port 0"] + runtime["Kilo CLI runtime"] + + run -->|"default first choice"| daemon + run -->|"fallback"| embedded + run -->|"--attach"| serve + tui -->|"default when available"| daemon + tui -->|"fallback"| worker --> rpc --> embedded + editors --> editorServer + daemon --> runtime + embedded --> runtime + serve --> runtime + editorServer --> runtime +``` + +TUI fallback is not direct call from UI thread to embedded fetch. UI thread starts `worker.ts`; worker RPC method constructs request, calls `Server.Default().app.fetch()`, and forwards global events back to UI thread. + +## One server with multiple directory contexts + +Each running editor host starts one editor-owned `kilo serve` server. That server can handle coding sessions for workspace root and additional worktree directories at same time. It does not start separate server process for each directory. + +```mermaid +flowchart LR + views["Editor views
    Workspace root and worktrees"] + server["One editor-owned
    kilo serve server"] + store["InstanceStore"] + root["Workspace-root
    local runtime instance"] + worktree["Worktree
    local runtime instance"] + sse["One process-wide
    /global/event stream"] + + views -->|"request includes directory"| server + server --> store + store -->|"select by directory"| root + store -->|"select by directory"| worktree + root -->|"event includes directory metadata"| sse + worktree -->|"event includes directory metadata"| sse + sse -->|"client routes event to matching view"| views +``` + +| Step | What happens | Why it matters | +|---|---|---| +| Send request | Editor client includes directory with local API request | CLI can distinguish workspace root from worktree directory | +| Select state | `InstanceStore` normalizes directory and selects directory-keyed local runtime instance | Sessions for alternate directories keep isolated runtime state | +| Return events | Server publishes event with directory metadata through shared `/global/event` SSE stream | Editor client routes event to matching directory and session view | + +This distinction matters for Agent Manager worktrees and JetBrains workspace caches. Directory-keyed state stays isolated. Process-wide event stream and server-owned service state remain shared; snapshot slow-track guard is one example. Authentication, provider routing, SSE, and snapshots appear in later sections. + +## Authentication boundaries + +Three credential boundaries coexist. Keep them separate when tracing request path or changing authentication code. + +| Boundary | Protects | Owner | +|---|---|---| +| Local `kilo serve` access | HTTP, SSE, and selected WebSocket access to local server | Kilo CLI server and spawning local client | +| Outbound provider authentication | Model provider, Kilo Gateway, catalog, and indexing access | Kilo CLI provider router and auth stores | +| Remote MCP OAuth | Browser authorization and credentials for remote MCP server | Kilo CLI MCP runtime | + +### Local `kilo serve` access + +Server Basic Auth is optional. It becomes required when `KILO_SERVER_PASSWORD` is non-empty. Default username is `kilo`; `KILO_SERVER_USERNAME` can override it. + +| Path or mode | Authentication behavior | +|---|---| +| Normal HTTP and SSE | Basic `Authorization` header when server password is configured | +| Browser WebSocket | `auth_token` query parameter accepts base64 `username:password` because browser WebSocket constructors cannot set arbitrary headers | +| Public UI assets | Selected manifest and icon GET paths bypass Basic Auth so browser metadata can load | +| PTY ticket issue | Authenticated `POST /pty/{ptyID}/connect-token` requires expected ticket header and allowed origin | +| PTY ticket connect | `GET /pty/{ptyID}/connect?ticket=...` bypasses Basic middleware, then consumes single-use, scope-bound ticket in PTY handler | +| PTY shell child | Removes `KILO_SERVER_PASSWORD` and `KILO_SERVER_USERNAME` from spawned user-shell environment | + +PTY connect supports two browser-oriented modes: loopback query credential mode (`auth_token`) used by current Console and VS Code Agent Manager paths, and short-lived ticket mode exposed by server API. + +### Outbound provider authentication + +Provider auth records use `api`, `oauth`, or `wellknown` variants in `${Global.Path.data}/auth.json`, written with mode `0600`. `KILO_AUTH_CONTENT` can supply process-local auth JSON. Separate v2 multi-account auth store also exists for account-oriented flows. + +| Path | Behavior | +|---|---| +| Direct providers | Use provider-specific keys, OAuth records, environment values, and configured endpoints | +| Kilo Gateway | Resolves Kilo model access and model catalog through gateway client | +| Anonymous Kilo | If no Kilo key exists, provider loader sets API key value `anonymous`; gateway model catalog can fall back to public unauthenticated endpoint | +| Organization catalog | Kilo model fetch includes organization ID when resolved from config, auth, or environment | +| Model cache | Caches provider model results for five minutes; failed loads invalidate cache for retry | +| Custom endpoints | Provider config can override endpoint and credential options | +| Indexing auth | Resolves indexing-specific Kilo config first, then provider config, auth record, provider options, and `KILO_API_KEY` / `KILO_ORG_ID` environment values | + +### Remote MCP OAuth + +Remote MCP OAuth belongs to CLI runtime. Static headers remain supported. For OAuth servers, CLI handles browser authorization and stores credentials in protected local state; editor clients invoke CLI-owned flow instead of storing MCP credentials themselves. + +## Directory routing and local runtime instances + +Instance routes select directory context in this order: + +1. `directory` query parameter. +2. `x-kilo-directory` request header. +3. Server process cwd. + +Local routing workspace selection is separate. Session workspace, `workspace` query parameter, and `KILO_WORKSPACE_ID` can select workspace context. Configured `KILO_WORKSPACE_ID` keeps requests local to current workspace runtime. Other selected workspaces resolve through workspace-routing adapter to local directory or remote target. + +| Request plan | Behavior | +|---|---| +| Local | Provides resolved directory and optional workspace ID to request handlers | +| Remote | Proxies HTTP or WebSocket request to adapter target | +| Missing workspace | Returns workspace-not-found response | +| Workspace-routing local | Keeps selected local routes and `/console` on local server instead of proxying | + +Remote HTTP proxy responses can include sync fence metadata. Router waits for matching sync progress before returning. `InstanceStore` normalizes directory keys, deduplicates concurrent boots with deferred entry, and disposes directory state through registered cleanup hooks. + +## Core subsystems + +| Subsystem | Purpose | +|---|---| +| Agent runtime | Orchestrates messages, model calls, permissions, questions, and multi-step execution | +| Tool registry | Loads built-in, Kilo-specific, MCP, and readiness-gated semantic search tools | +| LSP client | Provides diagnostics and language intelligence | +| Config service | Merges global, project, organization, managed, and runtime inputs | +| Instance store | Caches normalized directory-scoped runtime contexts | +| SQLite and storage services | Persist structured records and remaining JSON-owned data | +| Snapshot service | Tracks git-backed file baselines for diffs and revert flows | +| Provider router | Resolves direct providers, Kilo Gateway, custom endpoints, and credentials | +| HTTP server | Publishes REST, WebSocket, and SSE surfaces | + +## Daemon lifecycle + +`kilo daemon start|status|stop|restart` manage a detached local `kilo serve` child, with bare `kilo daemon` equivalent to `kilo daemon start`. `kilo console` calls the same start path, so it reuses a healthy daemon instead of spawning a second process, while `kilo console stop` aliases `kilo daemon stop`. + +| Area | Behavior | +|---|---| +| State file | `${Global.Path.state}/daemon.json`, written with mode `0600` | +| Log file | `${Global.Path.log}/daemon.log`, created with mode `0600` | +| Port allocation | For `--port 0`, scans `4097..4116` and chooses available port | +| Child process | Detached `kilo serve --hostname --port ` process | +| Foreground mode | `--foreground` / `-f` keeps the invoking command attached; SIGINT, SIGTERM, or SIGHUP stops only the daemon identity it started or reused | +| Health | Probes authenticated `/global/health` with 2 second timeout | +| Reuse | Reuses daemon only when process is alive, health succeeds, and installed version matches | +| Cleanup | Terminates stale process when present, clears stale state, then starts replacement | +| Opt-out | `KILO_NO_DAEMON` disables automatic attach by clients; explicit daemon commands still manage daemon | + +Daemon credentials differ from editor-spawned server credentials. Current daemon source stores username `kilo`, password `kilo`, and base64 Basic token in `daemon.json`. File permissions protect this local credential record. Editor clients generate random passwords per spawned server. + +## Persistence + +SQLite is default structured store. + +| Area | Behavior | +|---|---| +| Default database | `${Global.Path.data}/kilo.db` | +| Override | `KILO_DB`; relative paths resolve under data directory; `:memory:` is accepted | +| Runtime pragmas | WAL journal, normal sync, 5 second busy timeout, foreign keys, passive checkpoint, bounded cache | +| Schema changes | Drizzle migrations load from bundled journal in compiled binary or migration directories in development | +| Main tables | Projects, sessions, messages, parts, todos, permissions, session messages, workspaces, sync events, accounts, and account state | +| Legacy migration | On first database creation, CLI runs one-time JSON-to-SQLite migration for projects, sessions, messages, parts, todos, permissions, and shares | + +Some JSON-backed storage remains. Session diffs still use storage path `session_diff`, and configuration, auth, and selected local state files retain their own owners. Snapshot storage is separate from SQLite and JSON storage. + +## Snapshot state boundary + +Snapshot baselines use separate git directory per project worktree: + +```text +${Global.Path.data}/snapshot// +``` + +Snapshot implementation state is directory-keyed through `InstanceState`. One `Snapshot.Service` also owns process-shared slow-snapshot guard state outside directory cache. This distinction matters when multiple Agent Manager worktrees use same `kilo serve` process. + +Slow initial tracking has guarded behavior: + +| Condition | Behavior | +|---|---| +| Fast track | Returns snapshot hash normally | +| Slow interactive track | After default 10 seconds, can prompt to keep waiting or disable snapshots for project | +| Managed Agent Manager turn | Sends `snapshotInitialization: "wait"`; waits without inline question so concurrent started sessions retain baselines | +| Visible long track | Adds temporary progress part after short delay, updates spinner, and removes part when done | +| Disable choice | Writes `"snapshot": false` to project config without disposing active turn | +| Dismissed or untargeted timeout | Interrupts or skips track and suppresses repeat prompt for active service scope | + +## SDK contract + +CLI server contract flows through generated and handwritten layers: + +1. Effect `HttpApi` groups under `packages/opencode/src/server/routes/instance/httpapi/` define routes. +2. `packages/opencode/src/server/routes/instance/httpapi/public.ts` normalizes public OpenAPI to legacy-compatible request and response shapes. +3. Kilo-specific API groups and handlers live under `packages/opencode/src/kilocode/server/httpapi/` and enter shared API through narrow injection seams. +4. `packages/sdk/js/script/build.ts` generates TypeScript v2 client from CLI OpenAPI. +5. `packages/sdk/js/src/v2/client.ts` adds `createKiloClient()` wrapper for directory and workspace routing, Electron and Node fetch compatibility, and clearer empty-response errors. +6. Root `./script/generate.ts` runs SDK generation, emits tracked OpenAPI artifact, updates CLI docs, and formats outputs. +7. JetBrains Gradle build generates build-local OpenAPI, normalizes it, and generates Kotlin OkHttp client. + +Regenerate checked-in JavaScript SDK output after server endpoint changes. Do not hand-edit generated client files. + +## Config precedence + +Later sources override earlier values during instance config load: + +| Order | Source | +|---|---| +| 1 | Legacy Kilo migrations | +| 2 | Organization modes | +| 3 | Auth-record `.well-known/opencode` remote config | +| 4 | Global config files | +| 5 | Explicit `KILO_CONFIG` file | +| 6 | Project `kilo.json[c]` and `opencode.json[c]` files plus discovered config directories | +| 7 | `KILO_CONFIG_DIR` directory | +| 8 | `KILO_CONFIG_CONTENT` | +| 9 | Active Kilo Cloud organization config | +| 10 | Managed config directory | +| 11 | macOS managed preferences | +| 12 | Runtime flag-derived permission, tool, compaction, and plugin behavior | + +Global config files load from `${Global.Path.config}`. Project updates prefer existing config files found in ancestor `.kilo`, `.kilocode`, or `.opencode` directories, then existing project root config files, then create `.kilo/kilo.json`. Global indexing settings can carry provider and storage defaults, but global `indexing.enabled` is stripped so project enablement remains local in effective instance config. + +Signed-in organization modes become normal agent configuration during load. They override migrated legacy modes and remain overridable by later config sources in table. + +Runtime config loading is separate from editor-facing JSON Schema publication. Cloud-served schema improves validation and completion for `kilo.json` and `kilo.jsonc`; it does not load, apply, or override effective runtime config. When adding or changing config key, follow [CLI Config Schema](/docs/contributing/architecture/config-schema) so CLI source and cloud overlay stay aligned. + +## Global and instance SSE + +| Stream | Scope | Payload | +|---|---|---| +| `/event` | One local runtime instance bus | Direct event payloads until instance disposal | +| `/global/event` | Process-wide multiplexed bus | Wrapper with payload and available directory, project, and workspace metadata | + +Both streams send initial `server.connected` event and heartbeat every 10 seconds. VS Code and JetBrains consume `/global/event` so one server connection can route events for multiple directories. + +## Kilo Console + +`kilo console` starts or reuses daemon, opens `/console`, and prints Console launch URL. Browser launch URL embeds daemon Basic credentials so initial request authenticates. + +| Area | Behavior | +|---|---| +| Frontend | Solid/Vite app in `packages/kilo-console/` | +| Server route | `/console` assets resolved by CLI UI handler | +| Release build | CLI executable build copies Console assets beside binary under `bin/console` | +| SDK | Console calls generated JavaScript SDK through `createKiloClient()` | +| Discovery | Console scans `4097..4116` loopback daemon URLs, ranks healthy hits, then tries cached URL fallback | + +Source development can serve built Console assets from package output or build them on demand. This is development behavior, not production deployment claim. + +## Codebase indexing + +`packages/kilo-indexing/` owns indexing engine. CLI bridge injects indexing plugin by default unless default plugins are disabled, then starts indexing asynchronously per normalized directory during instance bootstrap. + +| Area | Behavior | +|---|---| +| Bootstrap | `KilocodeBootstrap` forks indexing initialization so instance startup is not blocked | +| Worker | Dedicated indexing worker owns `CodeIndexManager` and search calls | +| Cache | CLI bridge caches worker entry by directory and disposes it with instance | +| Status | `GET /indexing/status` and `indexing.status` bus event expose progress | +| Tool | `semantic_search` is registered only after indexing reports readiness | +| Worktrees | Agent Manager `.kilo/worktrees/` and legacy `.kilocode/worktrees/` paths return disabled status | +| Empty VS Code window | Extension sets `KILO_DISABLE_CODEBASE_INDEXING=vscode-no-workspace`; bridge reports disabled status | +| Embeddings | Supports Kilo, OpenAI, Ollama, OpenAI-compatible, Gemini, Mistral, Vercel AI Gateway, Bedrock, OpenRouter, and Voyage configuration | +| Vector stores | Supports Qdrant and LanceDB | + +## Source map + +Paths below are relative to [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). + +| Concern | Source paths | +|---|---| +| CLI entry points | `packages/opencode/src/cli/cmd/` | +| Daemon | `packages/opencode/src/kilocode/daemon/` | +| HTTP server | `packages/opencode/src/server/` | +| Directory and workspace routing | `packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts` | +| SQLite | `packages/opencode/src/storage/db.ts` | +| Snapshots | `packages/opencode/src/snapshot/index.ts`{% linebreak /%}`packages/opencode/src/kilocode/snapshot/track.ts` | +| SDK | `packages/sdk/js/`{% linebreak /%}`script/generate.ts` | +| Console | `packages/kilo-console/`{% linebreak /%}`packages/opencode/src/kilocode/console/` | +| Indexing | `packages/kilo-indexing/`{% linebreak /%}`packages/opencode/src/kilocode/indexing.ts` | + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - local and hosted execution map +- [VS Code Extension](/docs/contributing/architecture/vscode-extension) - extension-host ownership, Agent Manager, and webview bridge +- [JetBrains Plugin](/docs/contributing/architecture/jetbrains-plugin) - split-mode client, bundled server lifecycle, and workspace cache +- [Development Patterns](/docs/contributing/architecture/development-patterns) - API generation, code-ownership seams, and fork-maintenance rules +- [CLI Config Schema](/docs/contributing/architecture/config-schema) - editor validation contract for CLI config keys diff --git a/packages/kilo-docs/pages/contributing/architecture/cloud-platform.md b/packages/kilo-docs/pages/contributing/architecture/cloud-platform.md new file mode 100644 index 00000000000..770c4ccc72f --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/cloud-platform.md @@ -0,0 +1,397 @@ +--- +title: "Cloud Platform Architecture" +description: "Architecture overview for Kilo Cloud services and hosted runtimes" +--- + +# Cloud Platform Architecture + +Kilo Cloud is hosted platform layer for authentication, model routing, billing, product configuration, automation, and scoped execution services. Cloud implementation lives in open-source [`Kilo-Org/cloud`](https://github.com/Kilo-Org/cloud) repository. + +{% callout type="info" title="Static source scope" %} +This page describes Worker surfaces, bindings, routes, and code paths present in `Kilo-Org/cloud`. Static source shows deployable architecture, not live production enablement, rollout percentages, retention configuration, or vendor settings. Validate live environment before making production or compliance claims. Use [Kilo Cloud Security Architecture](/docs/contributing/architecture/cloud-security) for trust boundaries and data flows. +{% /callout %} + +## How to use this page + +Use this page to understand hosted service topology: which product boundaries exist, where long-running work executes, and how hosted runtimes relate. For trigger-to-execution workflows, continue to [Automation Services](/docs/contributing/architecture/automation-services). For trust boundaries and controls, continue to [Cloud Security](/docs/contributing/architecture/cloud-security). + +## Hosted layers + +| Layer | Responsibility | Examples | +|---|---|---| +| Web control plane | Identity, organization authorization, billing, product configuration, and API orchestration | Next.js application in `apps/web/` | +| Shared cloud services | Model routing, asynchronous orchestration, real-time delivery, persistence adapters, and operational services | Kilo Gateway, Workers, queues, Durable Objects, R2, KV, Hyperdrive | +| Scoped execution | Runs code or owner-scoped runtime workloads | Cloud Agent, App Builder preview sandbox, deployment builder sandbox, KiloClaw runtime, Gas Town container | +| External providers | Services outside Kilo Cloud trust boundary | Model providers, source-control providers, messaging providers, telemetry providers | + +Where these pages say `owner`, they mean personal user or organization that authorizes scoped product state and credentials. + +## Cloudflare terms + +| Term | Meaning in these docs | +|---|---| +| Worker | Deployed service boundary that handles HTTP requests, queue messages, schedules, or service-binding calls | +| Durable Object | Stateful Cloudflare actor with stable identity, storage, and alarm support | +| Queue | Asynchronous delivery boundary used to separate ingress from long-running work | +| Dead-letter queue | Queue for messages that exhausted normal delivery attempts | +| Service binding | Direct Worker-to-Worker call boundary configured in Wrangler | +| R2 | Object storage for scoped blobs, assets, attachments, or export data | +| KV | Distributed key-value storage for cache, mapping, rollout, and dedup state; not strongly consistent authority | +| Hyperdrive | Cloudflare binding used to connect Workers to PostgreSQL | +| Sandbox | Isolated container execution binding used by selected hosted workloads | + +## Product topology + +```mermaid +flowchart LR + clients["Browser, editor, and mobile clients"] + web["Web control plane"] + gateway["Kilo Gateway"] + automation["Automation Workers"] + agent["Cloud Agent"] + preview["App Builder preview"] + deployBuilder["Deployment builder"] + deployEdge["Deployment dispatcher"] + claw["KiloClaw"] + chat["Kilo Chat / Event Service / Notifications"] + town["Gas Town"] + wasteland["Wasteland"] + repos["GitHub and GitLab repositories"] + providers["Model providers and gateways"] + stores["PostgreSQL, Durable Objects, queues, R2, KV, and analytics stores"] + + clients --> web + clients --> gateway + web -. "short-lived connection ticket" .-> clients + clients -->|"ticketed WebSocket"| chat + web --> automation --> agent + web --> agent + web --> preview + web --> deployBuilder --> deployEdge + web --> claw + chat --> claw + agent --> repos + agent --> providers + claw --> providers + town --> repos + town --> providers + town --> wasteland + gateway --> stores + agent --> stores + preview --> stores + deployBuilder --> stores + claw --> stores + chat --> stores + wasteland --> stores +``` + +Not every hosted flow launches Cloud Agent. Shared services also route model requests, deliver chat events, dispatch notifications, serve generated applications, and coordinate owner-scoped runtimes. + +## Service families + +| Family | Primary services | Role | +|---|---|---| +| Session execution | `cloud-agent-next`{% linebreak /%}`session-ingest`{% linebreak /%}`git-token-service`{% linebreak /%}`notifications` | Hosted coding sessions, session ingestion, repository credentials, and completion push | +| Automation | `code-review-infra`{% linebreak /%}`auto-triage-infra`{% linebreak /%}`auto-fix-infra`{% linebreak /%}`security-auto-analysis`{% linebreak /%}`security-sync`{% linebreak /%}`webhook-agent-ingest` | Queue-backed review, triage, fix, security, and configured trigger flows | +| App generation | `app-builder`{% linebreak /%}`db-proxy`{% linebreak /%}`images-mcp`{% linebreak /%}`deploy-infra/builder`{% linebreak /%}`deploy-infra/dispatcher` | Generated-app preview, data access, image tools, build orchestration, and deployed-app ingress | +| KiloClaw | `kiloclaw`{% linebreak /%}`kiloclaw-billing`{% linebreak /%}`gmail-push`{% linebreak /%}`kiloclaw-inbound-email` | Owner-scoped assistant runtime coordination, billing, and external ingress | +| Real-time chat | `kilo-chat`{% linebreak /%}`event-service`{% linebreak /%}`notifications` | Conversation state, WebSocket delivery, and mobile push | +| Multi-agent orchestration | `gastown`{% linebreak /%}`wasteland` | Town execution and collaborative commons | +| Evaluation and operations | `o11y`{% linebreak /%}`kilo-ops`{% linebreak /%}`model-eval-ingest` | Metrics, alerts, operations, and model-evaluation ingestion | +| Attribution | `ai-attribution` | AI-edit attribution events | + +## Kilo Gateway + +Gateway consists of cloud API routes plus `packages/kilo-gateway/` client integration in `Kilo-Org/kilocode`. It handles account-aware and anonymous-free model access. See [Cloud Security](/docs/contributing/architecture/cloud-security#model-request-gateway) for request branches and endpoint families. + +| Responsibility | Description | +|---|---| +| Authentication | Resolves signed-in account and organization context when required | +| Anonymous free access | Allows eligible free-model requests without account auth under IP-derived context and limits | +| Provider routing | Routes managed-key, BYOK, custom-endpoint, and configured-gateway requests | +| Catalogs | Serves model, provider, embedding-model, and transcription-model surfaces | +| Usage and billing | Records applicable token usage, credits, entitlements, and billing metadata | + +Auto Model clients send stable `kilo-auto/*` tier IDs. Gateway resolves tiers server-side before provider routing so mappings can change without client releases. See [Models and Providers](/docs/gateway/models-and-providers#auto-models) for current tier behavior. + +Eligible gateway requests can include normalized project label for usage attribution and grouping. Label identifies project without sending full repository URL. + +## Cloud Agent + +`services/cloud-agent-next/` is current Cloud Agent session runtime. Each launched unit is a Cloud Agent execution session. Runtime uses queue-first orchestration and session messages. + +Every Cloud Agent execution session receives separate workspace and home paths. Policy-selected sandbox allocation is not universally one container per session. + +| Layer | Isolation rule | +|---|---| +| Working directory | Separate per execution session | +| Home directory | Separate per execution session | +| Git workspace | Separate per execution session | +| Sandbox identity | Policy-selected | +| Default allocation | May share owner-scoped sandbox across sessions | +| Selected organization flows | May use per-session sandbox | +| Devcontainer flows | Use per-session DIND sandbox | + +```mermaid +flowchart TB + callers["Web control plane and automation Workers"] + session["CloudAgentSession Durable Object"] + sandbox["Sandbox"] + small["SandboxSmall"] + dind["SandboxDIND"] + ingest["Session Ingest binding"] + tokens["Git Token Service binding"] + notify["Notifications binding"] + r2["R2 session bucket"] + db["Hyperdrive -> PostgreSQL"] + callback["Callback queue"] + report["Report queue"] + dlq["Report dead-letter queue"] + + callers --> session + session --> sandbox + session --> small + session --> dind + session --> ingest + session --> tokens + session --> notify + session --> r2 + session --> db + session --> callback + session --> report --> dlq +``` + +`services/cloud-agent-next/wrangler.jsonc` defines these bindings. Presence in Wrangler config proves deployable topology, not active production allocation counts or rollout policy. + +## Automation boundaries + +[Automation Services](/docs/contributing/architecture/automation-services) owns trigger, owner-scope, queue, callback, output, and recovery details. This table only shows how automation relates to hosted platform. + +| Service | Hosted execution relationship | +|---|---| +| Kilo Bot | Launches Cloud Agent for requested repository work | +| Code Review | Runs queued review sessions through Cloud Agent | +| Auto Triage | Can classify issue without Cloud Agent during duplicate check; launches Cloud Agent when classification session is needed | +| Auto Fix | Launches Cloud Agent to create issue-fix pull request | +| Security Agent | Runs model triage in `security-auto-analysis`; launches Cloud Agent only for selected deep analysis | +| Webhook Agent Ingest | Delivers configured prompt to Cloud Agent or Kilo Chat destination | + +## App generation boundaries + +App Builder is product orchestration, not normal automation ingress. + +```mermaid +flowchart TB + prompt["User prompt"] --> web["Web App Builder orchestration"] --> coding["Cloud Agent
    coding and iteration"] + + subgraph previewBoundary ["Preview boundary: services/app-builder/"] + direction LR + worker["app-builder Worker"] --> repo["GitRepositoryDO"] --> preview["PreviewDO"] --> previewSandbox["Preview Sandbox container"] + end + + subgraph deployBoundary ["Deployment build boundary: services/deploy-infra/builder/"] + direction LR + builder["Deployment builder"] --> orchestrator["DeploymentOrchestrator"] --> buildSandbox["Deployment build Sandbox container"] + end + + subgraph ingressBoundary ["Public ingress boundary: services/deploy-infra/dispatcher/"] + direction LR + dispatcher["Public wildcard ingress"] --> app["Dispatched generated application"] + end + + coding --> worker + coding --> builder + buildSandbox --> dispatcher +``` + +| Boundary | Ownership | +|---|---| +| Coding and iteration | Cloud Agent edits generated application code | +| Preview | `services/app-builder/` owns preview routing and preview sandbox containers | +| Deployment build | `services/deploy-infra/builder/` owns build orchestration in separate sandbox boundary | +| Public deployed-app ingress | `services/deploy-infra/dispatcher/` owns wildcard ingress and dispatch namespace routing | + +## Webhook Agent Ingest + +`services/webhook-agent-ingest/` is configured-trigger boundary. It accepts HTTP webhooks and scheduled alarms, then dispatches selected Cloud Agent or Kilo Chat destination. [Automation Services](/docs/contributing/architecture/automation-services#webhook-agent-ingest) owns activation, authentication, queue, and alarm details. + +## Security Agent + +Security Agent keeps finding sync, analysis dispatch, and sandbox execution separate. + +```mermaid +flowchart TB + github["GitHub Dependabot API"] + postgres["PostgreSQL
    Security Agent state"] + + subgraph sync ["Finding sync"] + direction LR + interactive["Interactive web sync"] --> web["Web Security Agent handler"] --> github + cron["security-sync
    six-hour cron"] --> queue["Owner-level sync queue"] --> tokens["Git Token Service binding"] --> github + github --> postgres + end + + subgraph analysis ["Analysis"] + direction LR + postgres -->|"Queued analysis row"| worker["security-auto-analysis"] --> triage["Model gateway triage"] --> deep{"Deep analysis needed?"} + deep -->|"Yes"| agent["Cloud Agent
    deep analysis"] --> callback["Finding-scoped callback"] --> postgres + deep -->|"No"| postgres + end + + subgraph cleanup ["Stale-analysis cleanup"] + direction LR + cleanupCron["Web cleanup cron"] --> reconcile["Reconcile stale running findings
    without active queue work"] --> postgres + end +``` + +PostgreSQL holds owner-scoped findings, analysis queue rows, owner pause or block state, Security Agent configuration, and audit records. See [Automation Services](/docs/contributing/architecture/automation-services#security-agent) for queue lifecycle and its static-source limitation, and [Cloud Security](/docs/contributing/architecture/cloud-security#security-agent-sync-and-cleanup) for trust boundaries. + +## Chat events and notifications + +```mermaid +sequenceDiagram + participant Client as Browser or mobile client + participant Ticket as Event Service ticket API + participant Events as Event Service + participant Session as UserSessionDO + participant Chat as Kilo Chat conversation-state DOs + participant Notify as NotificationChannelDO + participant Expo + participant Queue as Receipt queue + + Client->>Ticket: Request short-lived connection ticket + Ticket-->>Client: Return short-lived ticket + Client->>Events: Open WebSocket with ticket + Events->>Session: Register presence + Chat->>Events: Fan out conversation event + Events-->>Client: Deliver WebSocket event + Chat->>Notify: Request selected conversation push + Notify->>Events: Check presence context + Events-->>Notify: Return presence context + alt User already present + Notify-->>Chat: Suppress push + else Push needed + Notify->>Expo: Send push + Expo-->>Notify: Return receipt + Notify->>Queue: Enqueue receipt + Queue->>Notify: Process receipt and stale-token cleanup + end +``` + +Kilo Chat stores conversation state in Durable Objects and fans events out through Event Service. Notifications checks Event Service presence context before selected pushes and processes Expo receipts asynchronously. See [Cloud Security](/docs/contributing/architecture/cloud-security#chat-events-and-notifications) for ticket and push-delivery trust boundaries. + +## KiloClaw + +KiloClaw is owner-scoped hosted OpenClaw runtime coordination. Durable Objects track instance lifecycle, routing, configuration, and reconciliation. Runtime provider support includes Fly, docker-local development, and Northflank paths; source support does not prove active provider rollout. See [Cloud Security](/docs/contributing/architecture/cloud-security#kiloclaw-ingress) for ingress controls. + +| Ingress path | Auth or validation | Entry boundary | Async handoff | Target | +|---|---|---|---|---| +| Browser request | JWT auth | KiloClaw proxy | None | Owner-scoped runtime | +| One-time access code | Redeemed code and auth cookie | Access gateway | None | Owner-scoped OpenClaw UI | +| Controller machine check-in | Machine API key and derived gateway token | KiloClaw controller route | None | Owner-scoped runtime controller | +| Kilo Chat RPC | Service binding | KiloClaw binding | None | Owner-scoped runtime | +| Cloudflare Email Routing | Alias lookup and bounded parse | `kiloclaw-inbound-email` | Queue | KiloClaw platform service | +| Gmail Pub/Sub push | Google OIDC validation | `gmail-push` | Queue | Owner-scoped runtime controller | + +KiloClaw resolves owner or instance scope before runtime delivery. Table compares ingress boundaries; it does not describe global shared destinations. + +### Fly-provider topology example + +```mermaid +flowchart TB + subgraph worker ["Cloudflare Worker"] + direction LR + auth["JWT auth
    tied to Kilo user"] + instanceDO["Per-instance Durable Object"] + dbConnection["Kilo database connection"] + end + + db["Kilo database
    Instances
    Short-lived access codes
    Image catalog
    Billing and user preferences"] + proxy["Fly proxy
    Per-user Fly app
    Per-user encryption
    Routes to pinned instance"] + + subgraph flyInstance ["Fly instance: owner-scoped runtime"] + direction TB + subgraph container ["KiloClaw container"] + direction TB + controller["KiloClaw controller
    Supervises OpenClaw gateway
    Exposes control endpoints
    Proxies HTTP and WebSocket traffic"] + openclaw["OpenClaw
    Gateway and Control UI"] + tools["Pre-installed tools and skills"] + controller --> openclaw + tools --> openclaw + end + volume["Persistent Fly volume
    /root/.openclaw config
    /root/clawd workspace"] + volume --> openclaw + end + + dbConnection <--> db + worker <--> proxy + proxy <--> controller +``` + +## Gas Town and Wasteland + +Gas Town is multi-agent orchestration for coding work on repositories. `TownDO` owns town state and `TownContainerDO` owns town container execution. Active town work uses 5-second alarm cadence. Idle towns use 5-minute cadence. + +| Gas Town concept | Role | +|---|---| +| Town | Workspace or project with one or more rigs | +| Rig | Repository attached to town | +| Bead | Unit of work such as issue, task, merge request, or message | +| Convoy | Related beads with dependency tracking | +| Mayor | Persistent coordinator that decomposes and delegates work | +| Polecat | Worker agent that edits code and creates pull requests | +| Refinery | Review agent that runs quality gates and handles merge flow | +| Triage | Ephemeral agent for ambiguous automated-check outcomes | + +Gas Town binds separate `wasteland` Worker through `WASTELAND_SERVICE`. Wasteland uses `WastelandDO` and `WastelandRegistryDO` Durable Objects and DoltHub-backed collaborative commons paths. + +```mermaid +flowchart LR + town["Gas Town TownDO"] + container["TownContainerDO"] + binding["WASTELAND_SERVICE binding"] + wasteland["Wasteland Worker"] + dos["WastelandDO and WastelandRegistryDO"] + dolt["DoltHub-backed collaborative commons"] + + town --> container + town --> binding --> wasteland --> dos --> dolt +``` + +## Observability + +`services/o11y/` is current metrics and alert infrastructure. Higher-order agent outcome analysis remains roadmap work unless backed by separate implementation. + +| Surface | Static-source behavior | +|---|---| +| Alert evaluation | Worker cron runs every minute | +| API metrics | Analytics Engine dataset plus Pipeline stream | +| Session metrics | Analytics Engine dataset plus Pipeline stream | +| Export | Pipelines dual-write R2 Parquet data for Snowflake export infrastructure | +| Alert deduplication | KV namespace stores TTL-based cooldown state | +| Alert configuration | `AlertConfigDO` stores strongly consistent config | +| Session connection | `session-ingest` binds to `o11y` and emits session metrics | + +## Source map + +Paths below are relative to [`Kilo-Org/cloud`](https://github.com/Kilo-Org/cloud). + +| Concern | Source paths | +|---|---| +| Cloud Agent session service and bindings | `services/cloud-agent-next/`{% linebreak /%}`services/cloud-agent-next/wrangler.jsonc` | +| Session ingestion and Git tokens | `services/session-ingest/`{% linebreak /%}`services/git-token-service/` | +| Automation Workers | `services/code-review-infra/`{% linebreak /%}`services/auto-triage-infra/`{% linebreak /%}`services/auto-fix-infra/`{% linebreak /%}`services/webhook-agent-ingest/` | +| Security Agent | `apps/web/src/lib/security-agent/`{% linebreak /%}`services/security-auto-analysis/`{% linebreak /%}`services/security-sync/` | +| App generation and deployment | `services/app-builder/`{% linebreak /%}`services/db-proxy/`{% linebreak /%}`services/images-mcp/`{% linebreak /%}`services/deploy-infra/` | +| KiloClaw | `services/kiloclaw/`{% linebreak /%}`services/kiloclaw-billing/`{% linebreak /%}`services/gmail-push/`{% linebreak /%}`services/kiloclaw-inbound-email/` | +| Chat, events, and notifications | `services/kilo-chat/`{% linebreak /%}`services/event-service/`{% linebreak /%}`services/notifications/` | +| Multi-agent orchestration | `services/gastown/`{% linebreak /%}`services/wasteland/` | +| Observability and operations | `services/o11y/`{% linebreak /%}`services/kilo-ops/`{% linebreak /%}`services/model-eval-ingest/` | +| Attribution | `services/ai-attribution/` | + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - local and hosted execution map +- [Automation Services](/docs/contributing/architecture/automation-services) - trigger-to-execution workflows, queue ownership, callbacks, and recovery +- [Cloud Security](/docs/contributing/architecture/cloud-security) - trust boundaries, persistence, controls, privacy, and shared responsibility +- [Development Patterns](/docs/contributing/architecture/development-patterns) - choose code-ownership seam before changing architecture-facing contracts diff --git a/packages/kilo-docs/pages/contributing/architecture/cloud-security.md b/packages/kilo-docs/pages/contributing/architecture/cloud-security.md new file mode 100644 index 00000000000..12d574aa2d9 --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/cloud-security.md @@ -0,0 +1,427 @@ +--- +title: "Kilo Cloud Security Architecture" +description: "Security architecture overview for Kilo Cloud" +--- + +# Kilo Cloud Security Architecture + +This page gives contributors and customer security reviewers a high-level view of Kilo Cloud security architecture. It covers logical topology, trust boundaries, data flows, persistence, execution isolation, external integrations, and shared responsibility. + +{% callout type="info" title="Static source scope" %} +This overview is based on deployable code and configuration in open-source `Kilo-Org/cloud` repository. Static source does not prove live production enablement, rollout percentages, exact regions, retention enforcement, backup policy, WAF rules, credential rotation, or vendor settings. Validate those against live production inventory before making contractual, production, or compliance claims. +{% /callout %} + +{% callout type="info" title="How to use this page" %} +Cloud contributors should read [Cloud Platform](/docs/contributing/architecture/cloud-platform) first, then use this page as cross-cutting security reference. Security reviewers can start here: selected security-specific flows repeat so trust boundaries remain understandable, while linked platform sections provide full topology detail. Use [Automation Services](/docs/contributing/architecture/automation-services) for trigger, queue, callback, and recovery details. +{% /callout %} + +## Executive overview + +Kilo Cloud combines web control plane with Cloudflare-hosted services and scoped execution environments. + +- Browser, editor, and mobile clients connect to public application, gateway, and event surfaces. +- Vercel-hosted Next.js application provides account management, organization authorization, billing, product configuration, and API orchestration. +- Cloudflare Workers provide feature-specific ingress, service bindings, queue-backed workflows, durable coordination, real-time streams, and selected sandbox orchestration. +- Managed PostgreSQL stores relational control-plane records. Durable Objects, queues, KV, R2, and feature-specific analytical stores hold scoped state. +- Cloud Agent coding sessions run in Cloudflare sandbox containers with session-specific workspaces and policy-selected sandbox allocation. +- Generated-app preview and deployment builds run in boundaries separate from Cloud Agent coding sessions. +- KiloClaw assistant instances run in owner-scoped provider-backed runtimes with instance-scoped storage and encrypted configuration delivery. +- Gas Town binds to Wasteland as separate multi-agent orchestration boundary. + +## Logical topology + +```mermaid +flowchart LR + subgraph customer["Customer and Internet-controlled inputs"] + clients["Browser, editor, and mobile clients"] + repos["Customer repositories"] + integrations["Webhooks, email, push, and source-control events"] + end + + subgraph public["Public ingress"] + webEdge["Web application edge"] + gateway["Model gateway routes"] + cloudEdge["Feature-specific Worker ingress"] + deployEdge["Deployment dispatcher"] + end + + subgraph control["Control and service layer"] + web["Web and API application"] + workers["Cloud service Workers"] + chat["Kilo Chat / Event Service / Notifications"] + async["Queues and Durable Objects"] + end + + subgraph execution["Scoped execution"] + agent["Cloud Agent policy-selected sandbox"] + preview["App Builder preview sandbox"] + build["Deployment builder sandbox"] + claw["Owner-scoped KiloClaw runtime"] + town["Gas Town Town container"] + wasteland["Wasteland commons boundary"] + end + + subgraph stores["Managed persistence"] + postgres["Managed PostgreSQL"] + durable["Durable Object state"] + objects["R2 object storage"] + analytics["Analytics and operational stores"] + end + + subgraph providers["External providers"] + model["Model gateways and providers"] + source["Source-control providers"] + messaging["Expo, Gmail, and messaging providers"] + telemetry["Monitoring and telemetry providers"] + end + + clients --> webEdge --> web + clients --> gateway --> model + clients --> cloudEdge + integrations --> cloudEdge + clients --> deployEdge + web --> workers + workers --> async --> durable + workers --> chat + workers --> agent + workers --> preview + workers --> build + workers --> claw + workers --> town --> wasteland + agent --> repos + agent --> source + agent --> model + claw --> model + chat --> messaging + web --> postgres + workers --> postgres + workers --> objects + workers --> analytics + web --> telemetry + workers --> telemetry +``` + +| Layer | Security relevance | +|---|---| +| Client applications | User-controlled environments where authentication begins | +| Web control plane | Identity, organization authorization, billing, configuration, and API orchestration | +| Cloud service layer | Authenticated APIs, asynchronous workflows, durable coordination, streaming, and integration delivery | +| Managed persistence | Scoped records, durable state, queue delivery, object storage, and operational telemetry | +| Cloud Agent execution | Policy-selected sandbox containers with session-specific workspace and home directory | +| Generated-app preview | App Builder preview `Sandbox` container reached through preview routing | +| Generated-app deployment | Deployment builder `Sandbox` container plus dispatcher public wildcard ingress | +| KiloClaw execution | Owner-scoped provider-backed runtime with persistent storage | +| Gas Town and Wasteland | Town-owned container execution plus separate collaborative commons Worker | +| External providers | Third-party trust boundaries invoked by enabled capabilities | + +## Trust boundaries + +| Boundary | What crosses it | Primary controls | +|---|---|---| +| Clients to public application surfaces | Sessions, bearer tokens, requests, WebSockets, and customer input | Session or token validation, organization-aware authorization, security headers, short-lived event tickets, and selected origin allowlists | +| External systems to feature ingress | Webhooks, inbound email, Gmail Pub/Sub push, and source-control events | Provider proof where applicable, optional customer webhook secret, bounded payload handling, validation, idempotency, and queued processing | +| Web control plane to Workers | Session preparation, orchestration, integration delivery, and callbacks | Service credentials, scoped callback tokens, or Cloudflare service bindings by flow | +| Workers to persistence | Relational records, Durable Object state, queue messages, objects, and telemetry | Scoped identifiers, schema validation, service-specific authorization, and feature storage separation | +| Control plane to Cloud Agent | Repository metadata, task input, credentials, and runtime configuration | Policy-selected sandbox identity, session-specific paths, and just-in-time scoped credentials | +| Control plane to generated-app preview | Generated source and preview request traffic | App Builder `PreviewDO`, preview routing, bearer-protected status APIs, and separate preview sandbox | +| Control plane to deployment builder | Generated source and build input | `DeploymentOrchestrator`, build sandbox container, and deployment event callbacks | +| Internet to deployed applications | Public wildcard deployed-app requests | Dispatcher routes, dispatch namespace, KV mappings, and dispatcher rate limit | +| Control plane to KiloClaw runtime | Owner routing, config, proxy traffic, and machine lifecycle | JWT auth, one-time code redemption, derived gateway tokens, machine API keys, Durable Object owner scope, and encrypted config delivery | +| Gas Town to Wasteland | Collaborative orchestration operations | `WASTELAND_SERVICE` binding and separate Wasteland Durable Objects | +| Kilo Cloud to third parties | Repository operations, model requests, billing, notifications, and telemetry | Provider credentials, opt-in where applicable, scoped tokens, and feature-specific routing | + +## Identity and access + +Web control plane uses JWT-backed application sessions and supports multiple sign-in methods. Repository-supported providers include Google, Apple, GitHub, GitLab, Discord, LinkedIn OpenID Connect, WorkOS enterprise SSO, and email magic links. + +Kilo Cloud uses several authorization contexts: + +- Browser sessions for web product use. +- Signed bearer tokens for non-browser clients and selected cloud services. +- Organization membership and role checks for tenant-scoped operations. +- Administrative authorization for restricted operations. +- Internal service credentials, callback tokens, and Cloudflare service bindings. +- Short-lived one-time Event Service connection tickets. +- Provider-specific signature or token checks on supported external ingress. + +Application records commonly scope to user or organization. Cloud Agent durable state scopes to session while sandbox allocation remains policy-selected. KiloClaw runtime scopes to owner or instance rather than global assistant process. + +## Data and persistence + +| Data category | Examples | Processing context | +|---|---|---| +| Identity and account | Email, name, profile metadata, provider links, and account state | Sign-in, account administration, support, and privacy flows | +| Organization and access | Membership, roles, invitations, SSO domains, and audit actors | Tenant authorization and enterprise administration | +| Billing | Customer IDs, subscription state, transaction references, and invoices | Entitlement, reconciliation, and financial record keeping | +| Usage and operations | Model, token counts, costs, feature status, session IDs, timestamps, and error summaries | Metering, support, and reliability | +| Repository and automation | Repository metadata, refs, issue or review context, webhook payloads, and findings | Source control, Cloud Agent work, review automation, and security features | +| AI and session content | Prompts, responses, conversation history, attachments, and session events | Inference, Cloud Agent sessions, KiloClaw, and enabled experiments | +| Integration config | OAuth metadata, provider config, webhook settings, and customer secrets | Enabled integrations and owner-scoped runtime config | +| Network and abuse telemetry | IP address, user agent, browser signals, and risk metadata | Abuse prevention, fraud controls, and investigation | +| Mobile and notification | Device tokens, notification status, and mobile-store transaction metadata | Mobile auth, subscriptions, and notifications | + +| Persistence surface | Primary role | Security review note | +|---|---|---| +| Managed PostgreSQL | Relational system of record and workflow state | Vendor, regions, backups, and network controls require live validation | +| Durable Objects | Scoped coordination and feature state | Used for sessions, chat, notifications, ingestion, preview, and orchestration | +| Queues | Async processing, retries, and dead-letter handling | Used to separate public ingress and long-running work | +| R2 | Session blobs, attachments, feature assets, templates, and telemetry export | Bucket lifecycle, encryption, residency, and deletion require live validation | +| KV | Cache, rollout, mapping, and dedup state | Not strongly consistent authority | +| Analytical stores | Analytics Engine datasets, Pipeline export, and optional specialized stores | Active providers and retention require live validation | +| Runtime storage | Owner-scoped KiloClaw workspace and config persistence | Separate execution boundary tied to assigned runtime provider | + +## Core data flows + +### Model request gateway + +Gateway exposes endpoint families for chat API kinds, autocomplete, transcription, embeddings, catalogs, anonymous free access, custom LLM endpoints, and BYOK routing. + +| Family | Static-source surfaces | +|---|---| +| Chat APIs | `/api/gateway` and `/api/openrouter` aliases for chat completions, responses, and messages | +| FIM and edit | `/api/fim/completions`, `/api/edit/completions` | +| Transcription | Audio transcription routes | +| Embeddings | Embedding proxy routes | +| Catalogs | Models, transcription models, embedding models, providers, models-by-provider, and validation routes | +| Provider choice | Managed provider path, direct BYOK, custom LLM endpoint, organization settings, and configured gateway paths | +| Anonymous free | Eligible free-model requests only, with IP-derived context and limits | + +Authenticated and anonymous requests diverge after model eligibility and free-model limit checks. + +```mermaid +flowchart TB + client["Client model request"] + route["Gateway route and API-kind validation"] + free["Eligible free model?"] + auth["Valid account auth?"] + signed["Authenticated account / organization context"] + ip["IP-derived anonymous context anon:{ip}"] + reject["Reject paid unauthenticated request"] + limits["Free-model rate limit and usage log"] + provider["Managed, BYOK, custom, or configured provider routing"] + usage["Usage and billing metadata"] + + client --> route --> free + free -->|yes| limits --> auth + free -->|no| auth + auth -->|yes| signed --> provider + auth -->|no, eligible free| ip --> provider + auth -->|no, paid| reject + provider --> usage +``` + +Static source details: + +- Paid model requests require authentication. +- Anonymous access applies only to eligible free models. +- Anonymous context derives from request IP and uses synthetic ID format `anon:{ip_address}`. +- Free-model requests use rate limits. General path checks IP-based usage; server-side feature traffic from Cloudflare IPs can use user-based limits. +- Anonymous free requests also use promotion limit by IP. +- `free_model_usage` records support limits. `apps/web/vercel.json` defines hourly cleanup cron and cleanup route code removes rows older than seven days in batches. + +Seven-day cleanup is code-defined retention path, not proof of live retention execution. Validate deployed cron and database policy before external retention claim. + +### Cloud Agent session + +```mermaid +sequenceDiagram + participant Client + participant Web as Web control plane + participant Service as Cloud Agent + participant State as CloudAgentSession DO + participant Sandbox as Policy-selected sandbox + participant Source as Source-control provider + participant Model as Model gateway or provider + + Client->>Web: Start authorized coding task + Web->>Service: Create scoped session + Service->>State: Persist admitted work and metadata + Service->>Sandbox: Prepare workspace and execution environment + Sandbox->>Source: Fetch authorized repository content + Sandbox->>Model: Send configured model request + Sandbox-->>State: Stream session events + State-->>Client: Replay and stream authorized output +``` + +Every Cloud Agent execution session receives separate workspace and home paths. Policy-selected sandbox allocation is not universally one container per session. Default allocation may share owner-scoped sandbox across sessions; selected organization flows may use per-session sandbox; devcontainer flows use per-session DIND sandbox. See [Cloud Agent](/docs/contributing/architecture/cloud-platform#cloud-agent) for canonical topology, isolation matrix, and binding inventory. + +### Generated application preview and deployment + +```mermaid +flowchart LR + prompt["App Builder prompt"] + coding["Cloud Agent coding and iteration"] + preview["app-builder PreviewDO"] + previewSandbox["Preview Sandbox container"] + builder["deploy-infra/builder DeploymentOrchestrator"] + buildSandbox["Deployment build Sandbox container"] + dispatcher["deploy-infra/dispatcher public wildcard ingress"] + app["Dispatched generated app"] + + prompt --> coding --> preview --> previewSandbox + coding --> builder --> buildSandbox --> dispatcher --> app +``` + +App Builder orchestrates prompt-driven product flow. Cloud Agent owns coding and iteration only. `services/app-builder/` owns preview routing and preview sandbox; `services/deploy-infra/builder/` owns deployment build sandbox; `services/deploy-infra/dispatcher/` owns public deployed-app ingress. See [App generation boundaries](/docs/contributing/architecture/cloud-platform#app-generation-boundaries) for canonical phase topology. Deployment builder config enables Sentry instrumentation; static source currently includes `sendDefaultPii: true`. Treat deployment telemetry payload shape, masking, access, and retention as review item, not assumed privacy property. + +### Chat events and notifications + +```mermaid +sequenceDiagram + participant Client as Browser or mobile client + participant Ticket as Event Service ticket API + participant Events as Event Service + participant Session as UserSessionDO + participant Chat as Kilo Chat conversation-state DOs + participant Notify as NotificationChannelDO + participant Expo + participant Queue as Receipt queue + + Client->>Ticket: Request bearer-authenticated connection ticket + Ticket-->>Client: Return short-lived ticket + Client->>Events: Open WebSocket with ticket + Events->>Session: Register per-user presence + Chat->>Events: Fan out conversation event + Events-->>Client: Deliver WebSocket event + Chat->>Notify: Request selected conversation push + Notify->>Events: Check presence context + Events-->>Notify: Return presence context + alt User already present + Notify-->>Chat: Suppress push + else Push needed + Notify->>Expo: Send push + Expo-->>Notify: Return receipt + Notify->>Queue: Enqueue delayed receipt + Queue->>Notify: Process receipt and stale-token cleanup + end +``` + +Kilo Chat binds to Event Service and Notifications. Event Service consumes one-time tickets before WebSocket upgrade and places connections in per-user Durable Objects. Notifications service uses per-user Durable Objects, checks presence context for conversation pushes, sends Expo push, and processes delayed receipts. See [Chat, events, and notifications](/docs/contributing/architecture/cloud-platform#chat-events-and-notifications) for canonical service topology. + +### KiloClaw ingress + +```mermaid +flowchart TB + subgraph ingress ["Public and external ingress"] + direction LR + browser["Browser request"] --> jwt["JWT validation"] + code["One-time code"] --> access["Access gateway form"] + machine["Runtime machine"] --> machineAuth["API key + gateway token"] + chat["Kilo Chat RPC binding"] + email["Cloudflare Email Routing"] --> parse["Alias lookup + bounded parse"] + gmail["Gmail Pub/Sub push"] --> oidc["Google OIDC validation"] + end + + subgraph coordination ["KiloClaw coordination"] + direction LR + proxy["KiloClaw proxy"] + scope["Resolve owner or instance scope"] + instance["Owner- or instance-scoped
    Durable Object"] + redeem["Hyperdrive-backed redemption
    Auth cookie + derived gateway token"] + controller["/api/controller/checkin"] + emailQueue["Inbound email queue"] + gmailQueue["Gmail delivery queue"] + platform["KiloClaw platform delivery"] + controllerDelivery["KiloClaw controller delivery"] + end + + runtime["Provider-backed runtime"] + ui["OpenClaw UI"] + + jwt --> proxy --> scope + access --> redeem --> scope + machineAuth --> controller --> scope + chat --> scope + parse --> emailQueue --> platform --> scope + oidc --> gmailQueue --> controllerDelivery --> scope + scope --> instance --> runtime + scope --> ui +``` + +KiloClaw separates lifecycle coordination from runtime process. Fly is provider path and legacy fallback, docker-local supports development, and Northflank support exists in provider model. Active rollout must be checked in live environment. See [KiloClaw](/docs/contributing/architecture/cloud-platform#kiloclaw) for canonical runtime topology. + +### Gas Town and Wasteland + +Gas Town and Wasteland are separate trust boundaries. Gas Town owns town state and container execution. It calls Wasteland through `WASTELAND_SERVICE` binding; Wasteland owns separate Durable Objects and DoltHub-backed collaborative commons paths. See [Gas Town and Wasteland](/docs/contributing/architecture/cloud-platform#gas-town-and-wasteland) for canonical topology and orchestration concepts. + +### Security Agent sync and cleanup + +Security Agent interactive web sync and scheduled Worker sync are separate paths. `services/security-sync` config defines six-hour cron, owner-level queue, Hyperdrive access, and Git Token Service binding. `apps/web/vercel.json` defines stale cleanup every 15 minutes. Cleanup marks stale `running` findings failed only when no matching queue row remains `pending` or `running`. Static source does not prove scheduled sync enqueues newly synced findings for auto-analysis. + +| Boundary | Control | +|---|---| +| GitHub vulnerability access | Installation tokens and `vulnerability_alerts` permission | +| Owner scope | Findings, analysis queue rows, sync queue messages, and owner state scope to one user or organization owner | +| Scheduled sync credentials | Git Token Service binding resolves owner-scoped GitHub credentials | +| Internal worker calls | Bearer auth, internal API secret, or service bindings depending on flow | +| Analysis callback | Derived callback token scopes completion to Security Agent callback and finding ID | +| Sandbox execution | Optional deep analysis inherits Cloud Agent policy-selected sandbox allocation and session-specific workspace isolation | +| Auditability | Finding sync and analysis activity write to Security Agent audit surfaces | + +See [Cloud Platform](/docs/contributing/architecture/cloud-platform#security-agent) for topology and [Automation Services](/docs/contributing/architecture/automation-services#security-agent) for queue ownership. + +## Observability + +Observability is a security-review boundary because operational metrics and exports can contain customer-linked identifiers and diagnostic content. `services/o11y/` defines Worker-backed metrics, alerts, Analytics Engine datasets, Pipeline streams, R2 Parquet export infrastructure, KV cooldown state, and `AlertConfigDO`. See [Observability](/docs/contributing/architecture/cloud-platform#observability) for canonical topology. Active providers, access, filtering, and retention require live validation. + +Higher-order agent outcome analysis is roadmap work unless separate source proves implementation. + +## Security controls summary + +| Control area | Architecture-level control | +|---|---| +| Authentication | JWT-backed sessions, bearer tokens, machine tokens, one-time code redemption, and provider-specific ingress proof | +| Authorization | User, organization, role, owner, instance, and administrative checks by operation | +| Abuse prevention | Turnstile, fraud telemetry, blocking logic, free-model limits, bounded external payload handling, and deployment threat scanning | +| Internal service separation | Service bindings, callback tokens, and service credentials separate public access from orchestration | +| Execution isolation | Cloud Agent workspaces, preview sandbox, deployment builder sandbox, town container, and owner-scoped KiloClaw runtimes | +| Secret handling | Protected config storage, encrypted delivery for supported runtime secrets, fail-closed KiloClaw bootstrap, and sensitive-log prohibitions | +| Privacy | Soft-delete and anonymization workflows, webhook-header redaction, explicit experiment paths, and purpose-specific retention paths | +| Reliability | Durable coordination, queues, retries, dead-letter patterns, idempotency handling, and reconciliation | +| Browser hardening | HSTS, framing restrictions, MIME protection, referrer policy, cross-origin policies, permissions restrictions, and configurable CSP | +| Observability | Structured metrics, log aggregation, alerts, R2 Parquet export infrastructure, and production-managed access and retention | + +## Third-party integration categories + +| Status | Meaning | +|---|---| +| Platform dependency | Represented as part of architecture or deployment path | +| Feature-dependent | Invoked when capability is enabled; live production enablement needs validation | +| Customer-configured | Opt-in integration or endpoint selected by customer | +| Runtime-selected | Supported by repository but active provider or rollout is outside static source | +| Production validation required | Referenced by source but live settings must be checked before external claim | + +| Category | Examples | Security role | +|---|---|---| +| Hosting and storage | Vercel, Cloudflare, managed PostgreSQL, runtime hosting, caches, vector indexes, Snowflake | Hosting, edge, persistence, runtime, indexing, and analytics | +| Identity and source control | Google, Apple, GitHub, GitLab, Discord, LinkedIn, WorkOS, Turnstile, Stytch, Google Web Risk | Sign-in, SSO, abuse prevention, repositories, webhooks, and deployment scanning | +| Models and search | OpenRouter, Vercel AI Gateway, direct providers, BYOK, custom endpoints, Exa, Mistral | Inference, search, embeddings, and customer-selected outbound boundaries | +| Billing and messaging | Stripe, Apple App Store, Churnkey, Impact.com, Mailgun, Customer.io, Expo, Gmail, Slack, Discord, Telegram, Linear | Billing, messages, mobile push, email, and customer-configured communication | +| Monitoring and operations | Sentry, PostHog, Axiom, Analytics Engine, Pipelines, Better Stack | Error reporting, analytics, logs, export, and heartbeat monitoring | + +## Privacy logging and retention + +Kilo Cloud includes user soft-delete flows that anonymize direct user PII, invalidate auth material, delete many user-owned records and integrations, remove selected object-storage content, and request deletion from selected downstream services. Financial, audit, anti-abuse, and product-specific records can have retention exceptions. + +Operational telemetry can contain customer-linked identifiers and diagnostic content. Telemetry-enabled product surfaces can also submit assistant-response feedback with limited correlation metadata. Production access, filtering, retention, and vendor config remain required review areas. Pay special attention to deployment-builder Sentry payloads, mobile diagnostics, replay masking, screenshots, object storage, Durable Object state, vector stores, analytical stores, runtime volumes, and backups. + +Data paths vary by product and enabled integration. State residency and retention commitments require validation against live production inventory. + +## Shared responsibility + +Kilo Cloud provides platform controls for auth, scoped authorization, internal-service separation, durable coordination, execution isolation, and protected handling of supported secrets. + +Customers remain responsible for decisions that expand enabled trust boundaries: + +- Repositories, organizations, users, and source-control installations they authorize. +- Models, BYOK credentials, custom endpoints, and optional integrations they enable. +- Setup commands, repository code, MCP servers, and third-party tools they permit inside isolated session or owner-scoped runtime. +- Customer-configured endpoints and credentials meeting customer security, privacy, and compliance needs. +- Generated changes reviewed before merge or deployment. + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - local and hosted execution map +- [Cloud Platform](/docs/contributing/architecture/cloud-platform) - hosted layers, Cloudflare terms, Cloud Agent topology, and adjacent hosted runtimes +- [Automation Services](/docs/contributing/architecture/automation-services) - trigger-to-execution workflows, queue ownership, callbacks, and recovery +- [Development Patterns](/docs/contributing/architecture/development-patterns) - choose code-ownership seam before changing architecture-facing contracts diff --git a/packages/kilo-docs/pages/contributing/architecture/config-schema.md b/packages/kilo-docs/pages/contributing/architecture/config-schema.md index c4cba931c05..2fa79869a20 100644 --- a/packages/kilo-docs/pages/contributing/architecture/config-schema.md +++ b/packages/kilo-docs/pages/contributing/architecture/config-schema.md @@ -1,33 +1,105 @@ --- title: "CLI Config Schema" -description: "How the Kilo CLI config JSON Schema is served at app.kilo.ai/config.json" +description: "How CLI runtime config and editor-facing JSON Schema stay aligned" --- # CLI Config Schema -The JSON Schema referenced by `"$schema": "https://app.kilo.ai/config.json"` in `kilo.json` files is served by the cloud repo. It is a runtime overlay of the upstream opencode schema with Kilo-specific additions on top. +Kilo config has two related but separate paths: -## Flow +- Kilo CLI runtime loads and merges config locally. +- Cloud-served JSON Schema gives editors validation and completion for `kilo.json` and `kilo.jsonc`. -1. Client fetches `https://app.kilo.ai/config.json`. -2. Cloud route `apps/web/src/app/config.json/route.ts` fetches `https://opencode.ai/config.json`, runs `merge()` on it, and returns the result. -3. `merge()` overlays three sections from `apps/web/src/app/config.json/extras.ts`: - - `top` — top-level keys like `commit_message`, `remote_control`, nullable `model` / `small_model` - - `agents` — Kilo primary agents (`ask`, `debug`, `orchestrator`) - - `experimental` — `codebase_search`, `openTelemetry` +JSON Schema does not load, apply, or override runtime config. -## Adding a new Kilo-only config key +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json" +} +``` -The source of truth is the zod schema in `packages/opencode/src/config/config.ts`. The cloud overlay must match it. +## Two separate paths -1. Add the zod field with a `kilocode_change` marker in `config.ts`. -2. Generate the JSON Schema shape: `bun --bun packages/opencode/script/schema.ts /tmp/kilo.json`, then `jq '.properties.' /tmp/kilo.json`. -3. Paste the shape into the correct bucket in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). - - Top-level → `top`; under `experimental` → `experimental`; new primary agent → `agents`; anywhere else → add a new bucket and extend `merge()` in `route.ts`. -4. Add an assertion in `apps/web/src/tests/cli-config-schema.test.ts`. +```mermaid +flowchart LR + subgraph runtime ["Runtime config loading"] + files["Global, project, organization,
    managed, and runtime config sources"] --> loader["Kilo CLI config loader"] --> effective["Effective runtime config"] + end -If step 3 is skipped, users with `$schema: https://app.kilo.ai/config.json` will see "unknown property" warnings for the new key. + subgraph schema ["Editor validation and completion"] + info["Config.Info
    Effect Schema"] --> generated["Locally generated schema
    for verification"] + upstream["https://opencode.ai/config.json"] --> overlay["Kilo Cloud merge route"] + extras["Kilo extras.ts overlay buckets"] --> overlay --> endpoint["https://app.kilo.ai/config.json"] --> editor["Editor validation and completion"] + generated -. "Keep aligned" .-> extras + end +``` -## Caching +Changing runtime config precedence affects first path. Adding or changing config key affects both paths because editor schema must describe keys CLI accepts. See [CLI Runtime config precedence](/docs/contributing/architecture/cli-runtime#config-precedence) for runtime merge order. -The cloud route caches the upstream fetch for 1 hour (`next: { revalidate: 3600 }`) and emits `s-maxage=3600, stale-while-revalidate=3600`, so the response is served from the Cloudflare + Vercel edge cache for all but one request per hour per region. +## Source of truth + +Canonical CLI config source is Effect Schema `Config.Info` in `packages/opencode/src/config/config.ts` in [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). CLI derives `.zod` compatibility surface from Effect Schema for plugin and SDK consumers. Do not maintain separate handwritten Zod definition for Kilo config fields. + +## Cloud schema endpoint + +Static source review of [`Kilo-Org/cloud`](https://github.com/Kilo-Org/cloud) shows this route behavior: + +1. Editor fetches `https://app.kilo.ai/config.json` because config file references `$schema`. +2. Cloud route `apps/web/src/app/config.json/route.ts` fetches `https://opencode.ai/config.json`. +3. Route runs `merge()` and returns upstream schema with Kilo additions and overrides. +4. `merge()` overlays buckets from `apps/web/src/app/config.json/extras.ts`. + +Cloud source defines 1-hour upstream revalidation and edge-cache headers. This describes checked-in route behavior, not live deployment or cache state. + +## Overlay buckets + +Reviewed cloud source overlays: + +| Bucket | Purpose | +|---|---| +| `top` | Top-level Kilo keys and overrides | +| `agents` | Kilo primary agents under `agent` | +| `experimental` | Kilo experimental keys under `experimental` | + +Nested CLI fields outside these buckets need dedicated overlay bucket and matching `merge()` logic. + +## Failure mode + +If cloud overlay misses valid CLI field, CLI can accept config while editor reports `unknown property`. Opposite drift is also possible: cloud schema can advertise field that runtime no longer accepts. + +Treat schema synchronization as cross-repository contract. Tests should detect both missing valid fields and stale overlay entries. Keep branch-specific drift findings in tracked issues or test output, not this architecture page. + +## Adding or changing Kilo-only config key + +1. Add or update Effect Schema field with `kilocode_change` marker in `packages/opencode/src/config/config.ts`. +2. Generate JSON Schema shape: + +```sh +bun --bun packages/opencode/script/schema.ts /tmp/kilo.json +jq '.properties.' /tmp/kilo.json +``` + +3. Update matching bucket in `apps/web/src/app/config.json/extras.ts` in [cloud repo](https://github.com/Kilo-Org/cloud). +4. Extend `merge()` in `apps/web/src/app/config.json/route.ts` when new nested bucket is required. +5. Add assertion in `apps/web/src/tests/cli-config-schema.test.ts`. +6. Audit stale overlay entries as well as missing additions. + +{% callout type="warning" title="Cross-repository change" %} +CLI schema source lives in `Kilo-Org/kilocode`. Public editor schema overlay lives in `Kilo-Org/cloud`. Config-key change is incomplete until both repositories agree. +{% /callout %} + +## Source map + +Repository column identifies source root for each relative path. + +| Repository | Source path | Role | +|---|---|---| +| `Kilo-Org/kilocode` | `packages/opencode/src/config/config.ts` | Canonical Effect Schema and derived `.zod` surface | +| `Kilo-Org/cloud` | `apps/web/src/app/config.json/route.ts` | Cloud overlay route | +| `Kilo-Org/cloud` | `apps/web/src/app/config.json/extras.ts` | Kilo overlay buckets | +| `Kilo-Org/cloud` | `apps/web/src/tests/cli-config-schema.test.ts` | Cloud schema assertions | + +## Related pages + +- [CLI Runtime](/docs/contributing/architecture/cli-runtime#config-precedence) - runtime config loading and precedence +- [Development Patterns](/docs/contributing/architecture/development-patterns) - shared-file markers, Kilo-owned boundaries, and cross-repository contributor workflow diff --git a/packages/kilo-docs/pages/contributing/architecture/development-patterns.md b/packages/kilo-docs/pages/contributing/architecture/development-patterns.md new file mode 100644 index 00000000000..574182c09d1 --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/development-patterns.md @@ -0,0 +1,195 @@ +--- +title: "Development Patterns" +description: "Contributor patterns for Kilo architecture implementation and fork maintenance" +--- + +# Development Patterns + +This page turns architecture boundaries into contributor decisions. Read [Architecture Overview](/docs/contributing/architecture) and relevant subsystem page first, then use this guide before editing architecture-facing code in `Kilo-Org/kilocode` or its cross-repository contracts. + +{% callout type="info" title="Default rule" %} +Prefer Kilo-owned seams over broad changes to shared OpenCode files. Follow neighboring style when changing existing modules. +{% /callout %} + +## How to use this page + +1. Identify owning subsystem in architecture docs. +2. Choose narrowest source boundary that can hold change. +3. Update generated or cross-repository contracts when public surface changes. +4. Run smallest relevant checks plus affected repository guards. + +## Where should change live? + +| Change shape | Preferred location or action | Reason | +|---|---|---| +| Additive Kilo CLI behavior | `packages/opencode/src/kilocode/` | Keeps Kilo-only behavior out of upstream-owned files | +| Kilo CLI test for additive behavior | `packages/opencode/test/kilocode/` | Avoids shared tests that encode only Kilo behavior | +| Required shared OpenCode edit | Small import, route, or injection seam in shared file plus `kilocode_change` marker | Keeps upstream diff narrow and merge review obvious | +| VS Code, JetBrains, docs, indexing, UI, gateway, or telemetry change | Existing Kilo-owned package | These packages are Kilo-owned; do not add `kilocode_change` markers | +| CLI server endpoint change | Effect `HttpApi` route plus handler; then run root SDK generator | Keeps server contract and generated JavaScript SDK aligned | +| JetBrains API contract change | Shared CLI OpenAPI change; let Gradle regenerate build-local Kotlin client | Kotlin client is generated during JetBrains build | +| Kilo-only config-key change | Update CLI Effect Schema and cloud JSON Schema overlay | Runtime acceptance and editor validation are separate cross-repository paths | +| Docs page move or removal | Update nav and add permanent redirect | Preserves external links and bookmarks | + +## Kilo-owned boundaries + +Kilo CLI forks upstream OpenCode. Prefer Kilo-owned directories and packages for additive behavior: + +| Prefer | Avoid unless necessary | +|---|---| +| `packages/opencode/src/kilocode/` | Broad edits to shared `packages/opencode/src/` files | +| `packages/opencode/test/kilocode/` | Shared tests that encode only Kilo behavior | +| `packages/kilo-vscode/`, `packages/kilo-jetbrains/`, `packages/kilo-docs/`, `packages/kilo-indexing/` | Moving Kilo-only behavior into upstream-owned modules | +| Narrow import or route seams in shared files | Refactors that enlarge upstream merge conflicts | + +## Shared OpenCode files + +Use `kilocode_change` markers when Kilo-specific code must modify shared upstream files. + +| Change shape | Marker | +|---|---| +| One line | Trailing `// kilocode_change` | +| Multi-line block | `// kilocode_change start` and `// kilocode_change end` | +| New file in shared path | Top-level `// kilocode_change - new file` | +| JSX or TSX | JSX comment equivalents | + +Marker exemptions apply to paths already owned by Kilo, including paths whose names contain `kilocode` and Kilo packages such as `packages/kilo-vscode/` or `packages/kilo-ui/`. Do not add markers there. + +| Guard | When to run | +|---|---| +| `bun run script/check-opencode-annotations.ts` | PR touches `packages/opencode/`; verifies shared OpenCode Kilo edits are annotated | +| `bun run script/check-opencode-promise-facades.ts` | Service adapter changes; prevents new runtime-backed Promise facades in shared Effect services | +| `bun run check-kilocode-change` from `packages/kilo-vscode/` | VS Code or Kilo UI changes; markers must not appear in fully Kilo-owned packages | +| `bun run script/check-workflows.ts` | Workflow add or remove changes; keeps workflow allowlist explicit | + +## CLI server API + +CLI server uses Effect `HttpApi` and publishes OpenAPI-compatible HTTP + SSE surfaces consumed by JavaScript SDK and JetBrains build-local Kotlin client. + +| Rule | Reason | +|---|---| +| Define shared routes under `packages/opencode/src/server/routes/instance/httpapi/` | Keeps route contract close to runtime handlers | +| Normalize public spec in `packages/opencode/src/server/routes/instance/httpapi/public.ts` | Preserves legacy-compatible request and response shapes during Effect migration | +| Put additive Kilo groups and handlers under `packages/opencode/src/kilocode/server/httpapi/` | Reduces edits in shared upstream-owned files | +| Inject Kilo APIs through narrow shared seam | Keeps upstream diff small and marker placement obvious | +| Preserve route spans and stable attributes | Keeps diagnostics and telemetry understandable | + +## SDK generation + +[CLI Runtime SDK contract](/docs/contributing/architecture/cli-runtime#sdk-contract) owns generation pipeline detail. Contributor rules are short: + +| Change | Action | +|---|---| +| Add or change CLI server endpoint | Run root `./script/generate.ts` after route and handler edits | +| JavaScript SDK generated files under `packages/sdk/js/src/v2/gen/` | Do not edit by hand | +| JavaScript SDK wrapper behavior | Edit handwritten `packages/sdk/js/src/v2/client.ts` | +| JetBrains generated Kotlin client | Let Gradle regenerate build-local client from normalized OpenAPI | + +## CLI config schema + +Runtime config loading and editor validation are separate paths. New Kilo-only config key requires CLI Effect Schema change in `Kilo-Org/kilocode` and JSON Schema overlay change in `Kilo-Org/cloud`. Follow [CLI Config Schema](/docs/contributing/architecture/config-schema) for exact workflow. + +## Module export pattern + +For new public APIs, prefer flat ESM exports inside module, then namespace re-exports from index files when grouped access helps callers. + +```typescript +// packages/opencode/src/session/session.ts +export const create = fn(CreateSchema, async (input) => { + // ... +}) + +export const list = fn(ListSchema, async (input) => { + // ... +}) + +// packages/opencode/src/session/index.ts +export * as Session from "./session" +``` + +Import specific export when practical. Use namespace shape (`Session.create`) when preserving existing API or grouped module access improves clarity. Existing Kilo-owned namespaces remain valid; do not refactor them solely for style. + +## Tool implementation + +Tools use `Tool.define("id", Effect.gen(...))` with Effect Schema validation and typed execution. + +```typescript +export const ExampleTool = Tool.define( + "example", + Effect.gen(function* () { + return { + description: "Example tool", + parameters: Schema.Struct({ + value: Schema.String, + }), + execute(args) { + return Effect.succeed({ + title: args.value, + metadata: {}, + output: args.value, + }) + }, + } + }), +) +``` + +Reuse tool helpers, permission gates, and telemetry conventions before adding abstractions. Tests should exercise implementation behavior rather than duplicating logic in mocks. + +## Build system + +| Area | Tooling | +|---|---| +| Package manager | Bun workspaces | +| Task orchestration | Turborepo | +| CLI executable | Bun compile build in `packages/opencode/script/build.ts` | +| VS Code extension and webviews | esbuild | +| JetBrains plugin | Gradle, Kotlin JVM toolchain 21, build-local OpenAPI generation | +| Type checking | `tsgo` through `bun turbo typecheck`; Gradle compile checks for JetBrains | +| Tests | Package-level Bun test, Vitest, or Gradle test depending on package | +| Docs | Next.js, Markdoc, Mermaid, and custom Markdoc components | + +## Documentation changes + +When adding or moving docs pages: + +- Create page under `pages/`. +- Update matching navigation file in `lib/nav/`. +- Add redirects when removing or moving routes. +- Use compact markdown tables with unpadded cells. +- Use `/docs` prefix for docs image paths. + +## Source map + +Paths below are relative to [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). + +| Concern | Source path | +|---|---| +| Tool definition API | `packages/opencode/src/tool/tool.ts` | +| Tool example | `packages/opencode/src/tool/read.ts` | +| Server APIs | `packages/opencode/src/server/routes/instance/httpapi/` | +| Public OpenAPI normalization | `packages/opencode/src/server/routes/instance/httpapi/public.ts` | +| Kilo route seam | `packages/opencode/src/kilocode/server/httpapi/` | +| JavaScript SDK generation | `packages/sdk/js/script/build.ts`{% linebreak /%}`script/generate.ts` | +| JetBrains client generation | `packages/kilo-jetbrains/backend/build.gradle.kts` | +| Upstream merge automation | `script/upstream/` | + +## Upstream merge workflow + +`bun install` runs `script/setup-git.ts`, which sets repo-local merge conflict style to `zdiff3`. Base-aware markers make manual resolution and syntax-aware tooling more useful. Upstream automation under `script/upstream/` applies transforms before merge, forces `zdiff3` for merge operation, and runs `mergiraf` against remaining textual conflicts. `mergiraf` is required by merge script. + +From `script/upstream/`, use: + +```bash +bun run analyze.ts --version +bun run merge.ts --version --dry-run +bun run merge.ts --version +``` + +Keep Kilo-specific logic extracted, shared seams narrow, markers accurate, and CI guards green before upstream merge work lands. + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - system layers and reading paths +- [CLI Runtime](/docs/contributing/architecture/cli-runtime) - local runtime ownership and SDK contract +- [CLI Config Schema](/docs/contributing/architecture/config-schema) - cross-repository config-key workflow diff --git a/packages/kilo-docs/pages/contributing/architecture/enterprise-mcp-controls.md b/packages/kilo-docs/pages/contributing/architecture/enterprise-mcp-controls.md deleted file mode 100644 index 682bb26c326..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/enterprise-mcp-controls.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: "Enterprise MCP Controls" -description: "Enterprise MCP controls architecture" ---- - -# Enterprise MCP Controls - -### Overview - -Enterprise customers need to maintain control over the tools their developers use to ensure security, compliance, and cost management. Developers using Kilo Code can configure and use any MCP (Model Context Protocol) server, including public marketplace offerings or arbitrary custom servers. This lack of administrative oversight introduces risk for our enterprise customers, as it allows for the potential use of unvetted, insecure, or costly tool calls. - -This document specifies a new feature, **Enterprise MCP Controls**, which allows organization administrators to define an **allowlist** of approved MCP servers. Kilo Code (CLI/Extension) can enforce this allowlist, ensuring that developers within the organization can only use sanctioned MCPs. - -### MVP Requirements - -#### 1. Dashboard App - -- **View and Manage Allowlist:** Organization administrators must have a dedicated section in the dashboard to manage their MCP allowlist. -- **Default Configuration:** By default, new and existing organizations will have **all** marketplace MCPs enabled to ensure no disruption of service. -- **Marketplace MCPs:** The dashboard must display a comprehensive list of all MCPs available in the official Kilo Code Marketplace. -- **Selection UI:** Administrators must be able to easily select and deselect MCPs to add or remove them from the organization's allowlist. -- **Audit Logs:** Any changes made to MCP allow list must show up in the Audit Logs - -#### 2. Extension - -- **Allowlist Enforcement:** The VS Code extension and future CLI must strictly enforce the organization's MCP allowlist. -- **Filtered Marketplace:** The in-extension "MCP Marketplace" view must **only** display MCPs that are on the organization's allowlist. -- **Ignore Disallowed MCPs:** If an MCP server configured in `mcp.json` is **not** on the allowlist, the extension must ignore it. It should not be activated, displayed as an option, or used for any operations. -- **User Feedback:** The extension should provide clear, non-blocking visual feedback to the developer indicating which locally configured MCPs are disallowed by their organization's policy (e.g., graying out the entry, showing a warning icon). - -## System Design - -When the Enterprise MCP Controls feature is enabled, extension users can no longer use locally configured MCP definitions. Instead of pulling MCP configurations from the end-user's filesystem, the configuration will be pulled from the Kilo Code API, scoped to the organization. - -#### How Kilo/MCP works today - -!![How MCP works today](/docs/img/enterprise-mcp-controls-today.png) - -#### How Kilo/MCP works with enterprise controls - -!![How MCP works with enterprise controls](/docs/img/enterprise-mcp-controls-with-ent-control.png) - -### Schema - -We will piggy-back off of the existing organization.settings jsonb field for administrator to configure MCP Controls: - -```ts -const OrganizationSettings_MCPControls = z.object({ - mcp_controls_enabled: z.boolean().optional(), - mcp_controls_allowed_marketplace_servers: z.string().optional(), -}) -``` - -For end-users, since the mcp.json payload is no longer configurable locally, they will need to configure it via the Kilo Code dashboard. Since these configurations often contain API keys, we will encrypt the entire payload prior to insertion: - -```sql -create table if not exists organization_member_mcp_configs ( - id uuid not null default uuid_generate_v4(), - organization_id uuid not null references organizations(id), - kilo_user_id text not null references kilocode_users(id), - config bytea not null, - created_at timestamptz not null default now() -) -``` - -The config payload definition should look something like: - -```ts -const OrganizationMemberMCPConfig = z - .object({ mcp_id: z.string(), parameters: z.record(z.string(), z.string()) }) - .array() -``` - -### Dashboard App - -#### Owner experience - -There will be a new page in the left-hand navigation for Enterprise users only called "MCP Control" `/organizations/:id/mcp-control`. For owners, this page will allow control of which MCP marketplace items are allowed. It will `GET /api/marketplace/mcps` to retrieve the canonical list of MCP servers in our marketplace. It will also call the relevant getOrganization trpc function to get the org settings. By default, this feature is turned off. Also by default, all MCP servers will be selected. - -#### Organization user experience - -!![Organization user experience](/docs/img/enterprise-mcp-controls-org-user-install.png) - -When org users want to configure and use an MCP server and if organizations.settings.mcp_controls_enabled is true, they will be directed to the Kilo Code dashboard application `/organizations/:id/mcp-control`. Users will be able to enable, disable, and configure approved MCP servers. - -There will be a configuration UI similar to what's in the extension today. All configurations are encrypted and saved in our database. - -### Extension - -When organizations.settings.mcp_controls_enabled is true, the MCP marketplace view should be replaced with a link to configure MCP on the Kilo Code dashboard. When it is false-y, the experience is the same as it is today. - -## Scope and implementation plan - -Rough plan. These action items will become tickets after spec is approved: - -- Backend - - Schema changes for new organization_member_mcp_configs table - - Implement org settings endpoint changes to allow for mcp-control features (enabled, allow list) - - Implement TRPC routes for org members to update approved mcp installation settings - - Implement mcp-control UI for administrators - - Implement mcp server installation UI for end users -- Extension - - When organizations.settings.mcp_controls_enabled is true, the MCP marketplace view should be replaced with a link to configure MCP on the Kilo Code dashboard - -## Features for the future - -- Org-provided custom MCP server configurations (i.e. non-marketplace MCPs) -- Project-level MCP configurations -- Tool call audits - who is running what tool and why? - - Split out by user, project, MCP server (if applicable) - - Why? If you're really concerned about locking down MCP servers then the only way to know if our product is truly doing what it's saying it is is to provide admins with tool call audit logs diff --git a/packages/kilo-docs/pages/contributing/architecture/feature-template.md b/packages/kilo-docs/pages/contributing/architecture/feature-template.md deleted file mode 100644 index 3552a4cbce6..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/feature-template.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: "Spec Template" -description: "Template for proposing new feature designs" ---- - -# Template - -# Overview - -This section provides a concise description of the problem being addressed and the proposed solution. - -What is important for the solution to accomplish? What can be left out of scope for now? Scope projects as tightly as possible, because smaller projects let us ship faster, get feedback faster, and avoid snowballing scope creep. - -# Requirements - -This section outlines the requirements that the solution will fulfill. Be comprehensive and detailed. - -Find the minimum requirements that will deliver the minimal solution described in the Overview. Avoid the urge to solve all the problems at once. - -- - -### Non-requirements - -- - -# System Design - -This is the core of the technical specification, detailing the architectural decisions and implementation plan. If possible, include diagrams! - -## Scope/Implementation - -This section should be a bulleted list of tasks that will eventually become github issues. - -- - -# Compliance Considerations - -This section addresses any relevant compliance aspects, specifically regarding SOC 2. - -# Features for the future - -Talks about what we might want to build or improve upon in the future, but is out-of-scope of this spec. diff --git a/packages/kilo-docs/pages/contributing/architecture/features.md b/packages/kilo-docs/pages/contributing/architecture/features.md deleted file mode 100644 index 2b187d17d45..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/features.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Architecture Features" -description: "Overview of current and planned features in Kilo Code" ---- - -# Architecture Features - -These pages document the architecture and design of current or planned features, as well as any unique development patterns. - -| Feature | Description | -|---|---| -| [Agent Observability](/docs/contributing/architecture/agent-observability) | Observability and monitoring for agentic systems | -| [Auto Model Tiers](/docs/contributing/architecture/auto-model-tiers) | Multi-tier auto model routing (Frontier, Free, Open) | -| [Benchmarking](/docs/contributing/architecture/benchmarking) | Benchmarking Kilo Code across models and agents | -| [Enterprise MCP Controls](/docs/contributing/architecture/enterprise-mcp-controls) | Admin controls for MCP server allowlists | -| [MCP OAuth Authorization](/docs/contributing/architecture/mcp-oauth-authorization) | OAuth 2.1-based authorization for MCP servers | -| [Onboarding Improvements](/docs/contributing/architecture/onboarding-improvements) | User onboarding and engagement features | -| [Organization Modes Library](/docs/contributing/architecture/organization-modes-library) | Shared modes for teams and enterprise | -| [Agentic Security Reviews](/docs/deploy-secure/security-reviews) | AI-powered security vulnerability analysis | -| [Track Repo URL](/docs/contributing/architecture/track-repo-url) | Usage tracking by repository/project | -| [Voice Transcription](/docs/contributing/architecture/voice-transcription) | Live voice input for chat | - -To propose a new feature design, consider using the [Spec Template](/docs/contributing/architecture/feature-template). diff --git a/packages/kilo-docs/pages/contributing/architecture/index.md b/packages/kilo-docs/pages/contributing/architecture/index.md index bbc3e1fe482..dee4cb93f9a 100644 --- a/packages/kilo-docs/pages/contributing/architecture/index.md +++ b/packages/kilo-docs/pages/contributing/architecture/index.md @@ -1,280 +1,222 @@ --- title: "Architecture Overview" -description: "Overview of the Kilo platform architecture" +description: "Overview of the Kilo Code platform architecture" --- # Architecture Overview -This document provides a high-level overview of the Kilo platform architecture to help contributors understand how the different components fit together. +This page maps Kilo Code's repository-defined architecture. It introduces the local runtime, editor clients, cloud service boundaries, and hosted execution products before the subsystem pages add implementation detail. -## System Architecture +{% callout type="info" title="Scope" %} +Use these pages for stable system boundaries and contributor-wide contracts. Source code remains the reference for feature-level implementation details. Static source shows code paths and deployable surfaces, not production enablement, traffic, retention, or vendor configuration. +{% /callout %} -Kilo is an AI coding platform built around a central CLI engine that powers every client surface — the terminal, VS Code, and the cloud. The architecture follows a layered approach where all clients communicate with the CLI over HTTP + SSE, and the CLI connects to AI providers either directly or through Kilo Cloud. +## How to read these pages -```mermaid -graph LR - tui["Kilo CLI (TUI)"] - vscode["VS Code Extension"] - - subgraph cli ["Kilo CLI Engine"] - provider["Provider Router"] - end - - subgraph cloud ["Kilo Cloud"] - gateway["Kilo Gateway"] - cloudagent["Cloud Agent"] - bot["Kilo Bot"] - claw["KiloClaw"] - gastown["Gas Town"] - review["Code Review"] - triage["Auto Triage"] - appbuilder["App Builder"] - end - - providers["Inference Providers: Anthropic, OpenAI, Google, OpenRouter + 500 more"] - - tui -->|SDK| cli - vscode -->|SDK| cli - cloudagent -->|Sandbox| cli - - provider -- Direct --> providers - provider -- Gateway --> gateway - gateway --> providers - claw --> gateway - gastown -->|Container| cli - gastown --> gateway - - bot --> cloudagent - review --> cloudagent - triage --> cloudagent - appbuilder --> cloudagent -``` - -## Kilo CLI — The Foundation - -The CLI (`packages/opencode/`) is the core engine that all products are built on. It contains the AI agent runtime, tool execution, session management, provider integrations, and an HTTP server. Each client spawns or connects to a `kilo serve` process and communicates via HTTP + SSE using the `@kilocode/sdk`. - -The CLI can run in several modes: - -- **`kilo`** — Interactive TUI for terminal-based coding -- **`kilo run`** — Headless single-prompt execution -- **`kilo serve`** — HTTP server mode for client integrations - -Key subsystems inside the CLI: - -| Subsystem | Purpose | -|---|---| -| Agent Runtime | Orchestrates AI conversations, tool calls, and multi-step task execution | -| Tools Service | Built-in tools for file editing, shell execution, search, and more | -| MCP Servers | Model Context Protocol support for extending with external tools | -| LSP Client | Language Server Protocol integration for code intelligence | -| Session Manager | Persistent session state, conversation history, and checkpoints | -| Provider Router | Connects to 500+ AI models via direct APIs or Kilo Gateway | -| HTTP Server | REST API + SSE streaming for client communication | -| Config System | Project and global configuration, modes, and permissions | - -## Client Layer - -All clients are thin wrappers over the CLI engine. - -### VS Code Extension - -The VS Code extension (`packages/kilo-vscode/`) bundles the CLI binary and spawns `kilo serve` as a child process. It includes: - -- **Sidebar Chat** — Primary coding assistant interface -- **Agent Manager** — Multi-session orchestration panel with git worktree isolation for running parallel tasks - -### TUI - -The built-in terminal UI ships with the CLI itself — a SolidJS interface rendered in the terminal via OpenTUI. - -## Kilo Cloud - -Kilo Cloud is the hosted platform layer that provides authentication, provider routing, and autonomous agent services. The cloud infrastructure lives in a separate repository. - -### Kilo Gateway - -The gateway (`packages/kilo-gateway/` in this repo, plus API routes in the cloud) handles: - -- **Authentication** — Device flow auth, token management, and account linking -- **Provider Routing** — Routes AI requests through Kilo's managed API keys or the user's own keys -- **Model Catalog** — Serves the available model list and provider configuration -- **Usage & Billing** — Tracks token consumption and manages credits - -### Cloud Agent - -A Cloudflare Worker within Kilo Cloud that runs the Kilo CLI in isolated sandbox environments. It powers cloud-based AI coding tasks triggered via the web dashboard, webhooks, or automation workflows. It provides a secure API for: - -- Creating and managing coding sessions with full GitHub/GitLab integration -- Running AI tasks in Docker containers with the CLI pre-installed -- Streaming results back via WebSocket - -### Kilo Bot - -The GitHub/GitLab bot that responds to issue comments and PR mentions. It dispatches work to the Cloud Agent, enabling users to trigger AI coding tasks directly from their repositories. +Choose the path closest to the change you are making: -### KiloClaw - -A multi-tenant compute platform running on Fly.io, orchestrated by a Cloudflare Worker. Each user gets a dedicated persistent machine running an OpenClaw gateway, coordinated via Durable Objects for state management and self-healing reconciliation. - -{% image src="/docs/img/kiloclaw/kiloclaw-architecture.png" alt="KiloClaw infrastructure architecture diagram" width="800" caption="KiloClaw infrastructure architecture" /%} - -### Code Review - -An automated code review service that subscribes to GitHub webhooks, dispatches reviews through the Cloud Agent, and posts feedback directly on pull requests. Supports per-organization concurrency limits and automatic queuing. - -### Auto Triage - -An automated issue triage service that classifies GitHub issues (bug, feature, question), detects duplicates via vector similarity search, and optionally creates fix PRs for high-confidence actionable issues. - -### App Builder - -A service that builds and deploys user applications via the Cloud Agent. Users can generate full applications from prompts, with the App Builder orchestrating the Cloud Agent to scaffold, iterate, and deploy the result. - -### Gas Town - -A multi-agent orchestration platform that coordinates autonomous AI coding agents working on real Git repositories. Gas Town runs entirely on Cloudflare — a central Durable Object manages all state, while Docker containers on Cloudflare Containers run agent processes via the Kilo CLI. - -Key concepts: - -- **Town** — A workspace/project that contains one or more rigs (repositories) -- **Rig** — A Git repository attached to a town where agents perform work -- **Bead** — A unit of work (issue, task, merge request, or message) -- **Convoy** — A batch of related beads with dependency tracking, dispatched together - -Agents operate in a hierarchy: - -| Agent | Role | +| Contributor path | Suggested order | |---|---| -| Mayor | Persistent conversational coordinator — decomposes tasks and delegates to worker agents | -| Polecat | Worker agent — clones repo worktrees, writes code, commits, pushes, and creates PRs | -| Refinery | Code review agent — reviews polecat branches, runs quality gates, merges or requests rework | -| Triage | Ephemeral agent that resolves ambiguous situations detected by automated patrol checks | +| Local CLI or editor client | Architecture Overview -> [CLI Runtime](/docs/contributing/architecture/cli-runtime) -> [VS Code Extension](/docs/contributing/architecture/vscode-extension) or [JetBrains Plugin](/docs/contributing/architecture/jetbrains-plugin) | +| Hosted platform or automation | Architecture Overview -> [Cloud Platform](/docs/contributing/architecture/cloud-platform) -> [Automation Services](/docs/contributing/architecture/automation-services) | +| Security review | Architecture Overview -> [Cloud Platform](/docs/contributing/architecture/cloud-platform) -> [Cloud Security](/docs/contributing/architecture/cloud-security) | +| Architecture-facing implementation | Relevant architecture page -> [Development Patterns](/docs/contributing/architecture/development-patterns) | +| CLI config ownership or key change | [CLI Runtime](/docs/contributing/architecture/cli-runtime#config-precedence) -> [CLI Config Schema](/docs/contributing/architecture/config-schema) -> [Development Patterns](/docs/contributing/architecture/development-patterns) | -A reconciler loop running every 5 seconds drives all state transitions: dispatching agents, transitioning beads, polling PR status, managing convoys, and recovering from failures. +## Repository boundaries -### Supporting Services +Architecture pages cross two repositories: -| Service | Purpose | +| Repository | Contents | |---|---| -| Webhook Agent Ingest | Named webhook endpoints that capture HTTP requests and queue delivery to Cloud Agent | -| AI Attribution | Tracks line-level AI-generated code attribution when users accept or reject edits | -| Session Ingest | Ingests and stores CLI session data for analytics | -| Observability | Telemetry pipelines for monitoring cloud services | - -## Key Concepts - -### Modes - -Modes are configurable presets that customize the agent's behavior: +| [Kilo‑Org/kilocode](https://github.com/Kilo-Org/kilocode) | Kilo CLI runtime, local daemon, Kilo Console, VS Code extension, JetBrains plugin, JavaScript SDK, codebase indexing, Kilo Gateway client, telemetry, docs, and shared UI packages | +| [Kilo‑Org/cloud](https://github.com/Kilo-Org/cloud) | Web control plane, Kilo Gateway routes, Cloud Agent session runtime, automation, generated-application preview and deployment services, KiloClaw, Gas Town, billing, and supporting Workers | -- Define which tools are available -- Set custom system prompts -- Configure file restrictions -- Examples: Code, Architect, Debug, Ask +## Three architecture layers -### Model Context Protocol (MCP) +| Layer | Responsibility | Typical boundaries | +|---|---|---| +| Local runtime and clients | Runs local coding sessions and connects editor surfaces to one local agent engine | Kilo CLI runtime, `kilo serve` server, local daemon, Kilo Console, VS Code extension, JetBrains plugin | +| Kilo Cloud shared services | Handles hosted identity, authorization, model routing, billing, orchestration, and shared product services | Web control plane, Kilo Gateway, Workers, queues, Durable Objects, persistence | +| Hosted product runtimes and automation | Runs scoped cloud work for coding, app generation, assistants, security analysis, and multi-agent orchestration | Cloud Agent, Automation Services, App Builder, Security Agent, KiloClaw, Gas Town, Wasteland | -MCP enables extending the agent with external tools: +Local execution and hosted execution are separate boundaries. Editor clients use a local `kilo serve` server. Hosted automation can launch Cloud Agent execution sessions when cloud coding work is required. -- Servers provide additional capabilities -- Standardized protocol for tool communication -- Configured via `mcp.json` +## Terms used throughout -### Checkpoints +| Term | Meaning | +|---|---| +| Kilo Code | Umbrella product across local clients, Kilo CLI runtime, and Kilo Cloud services | +| Kilo CLI runtime | Local agent engine in `packages/opencode/`; owns tools, sessions, config, persistence, and provider routing | +| `kilo serve` server | Local HTTP and SSE process used by editor clients and Kilo Console; selected browser-oriented paths also use WebSocket | +| Local daemon | Detached reusable `kilo serve` server managed by `kilo daemon` commands | +| Directory context | Normalized local filesystem directory used to select local runtime state | +| Local runtime instance | Directory-keyed runtime context inside one Kilo CLI process | +| Local routing workspace | Optional routing context that can resolve to a local directory or remote target | +| Worktree directory | Alternate git worktree path used as a directory context for isolated concurrent work | +| Web control plane | Hosted Kilo Cloud application layer for identity, organization authorization, billing, product configuration, and API orchestration | +| Kilo Gateway | First-party hosted model-routing boundary | +| Cloud Agent | Hosted coding-session capability. A Cloud Agent execution session is one hosted run; current session runtime implementation lives in `services/cloud-agent-next/`. | + +## Core execution spine + +The three layers appear in two primary execution shapes: local client requests and hosted cloud work. -Git-based state management for safe exploration: +```mermaid +flowchart LR + subgraph clients ["Local clients"] + tui["Kilo CLI TUI"] + run["kilo run"] + console["Kilo Console"] + editors["VS Code and JetBrains"] + end -- Creates commits to track changes -- Enables rolling back to previous states -- Shadow repository for isolation + subgraph local ["Local Kilo CLI boundary"] + daemon["Local daemon manager"] + server["kilo serve server"] + runtime["Kilo CLI runtime"] + router["Provider router"] + end -### Worktrees + subgraph cloud ["Kilo Cloud shared services"] + web["Web control plane"] + workers["Automation Workers, queues, and Durable Objects"] + gateway["Kilo Gateway"] + agent["Cloud Agent"] + end -Git worktree isolation for parallel task execution: + trigger["Hosted product or automation trigger"] + repos["Repositories"] + models["Model providers and external gateways"] + + tui -->|"daemon attach when available"| server + tui -->|"worker-backed fallback"| runtime + run -->|"attach when available"| server + run -->|"embedded fallback"| runtime + console -->|"starts or reuses"| daemon + daemon -->|"owns detached child"| server + editors -->|"start editor-owned child over HTTP + SSE"| server + server --> runtime --> router + router -->|"direct provider"| models + router --> gateway --> models + + trigger --> web + trigger --> workers + web --> workers --> agent + web --> agent + agent --> repos + agent --> models +``` -- Each agent session can operate in its own worktree -- Prevents conflicts between concurrent tasks -- Used by the Agent Manager in VS Code for multi-session workflows +### Two execution paths -## Development Patterns +| Path | Starts from | Runs in | What to remember | +|---|---|---|---| +| Local coding | Kilo CLI, Kilo Console, VS Code, or JetBrains | Kilo CLI runtime on developer machine | Editor clients talk to local `kilo serve` server. Local runtime owns coding session and sends model requests directly or through Kilo Gateway. | +| Hosted work | Webhook, source-control event, command, schedule, or hosted product | Kilo Cloud services; Cloud Agent when coding is required | Cloud services coordinate work. Only flows that need repository changes launch Cloud Agent execution session. | -### Client-Server Communication +This distinction is central: using editor does not move coding session into Cloud Agent. Cloud services also route model requests, deliver chat events, dispatch notifications, serve generated applications, and coordinate adjacent hosted boundaries without launching Cloud Agent. -All clients communicate with the CLI via its HTTP + SSE API. The `@kilocode/sdk` package provides a TypeScript client: +## Adjacent hosted boundaries -```typescript -import { KiloClient } from "@kilocode/sdk" +The core execution spine is not the full cloud product catalog. These service families and hosted runtimes attach to it for specific product flows: -const client = new KiloClient({ baseUrl: "http://localhost:3000" }) -const session = await client.session.create({ ... }) +```mermaid +flowchart LR + web["Web control plane"] + workers["Automation Services"] + agent["Cloud Agent"] + builder["App Builder"] + preview["Generated-application preview"] + deploy["Generated-application deployment"] + security["Security Agent"] + chat["Kilo Chat, events, and notifications"] + claw["KiloClaw"] + town["Gas Town"] + wasteland["Wasteland"] + + web --> workers --> agent + web --> builder --> agent + builder --> preview + builder --> deploy + web --> security + security -->|"optional deep analysis"| agent + web --> claw + chat --> claw + web --> town --> wasteland ``` -### Module Export Pattern +| Boundary | Role | Topology or workflow | Security review | +|---|---|---|---| +| Automation Services | Turns commands, source-control events, labels, webhooks, and schedules into scoped work | [Automation Services](/docs/contributing/architecture/automation-services) | [Trust boundaries](/docs/contributing/architecture/cloud-security#trust-boundaries) | +| App Builder | Coordinates generated-application coding, preview, build, and deployment boundaries | [Cloud Platform](/docs/contributing/architecture/cloud-platform#app-generation-boundaries) | [Preview and deployment](/docs/contributing/architecture/cloud-security#generated-application-preview-and-deployment) | +| Security Agent | Syncs findings and analyzes risk; selected deep analysis can launch Cloud Agent | [Cloud Platform](/docs/contributing/architecture/cloud-platform#security-agent) | [Sync and cleanup](/docs/contributing/architecture/cloud-security#security-agent-sync-and-cleanup) | +| KiloClaw | Coordinates owner-scoped hosted assistant runtimes | [Cloud Platform](/docs/contributing/architecture/cloud-platform#kiloclaw) | [KiloClaw ingress](/docs/contributing/architecture/cloud-security#kiloclaw-ingress) | +| Gas Town and Wasteland | Coordinate multi-agent repository work and collaborative commons paths | [Cloud Platform](/docs/contributing/architecture/cloud-platform#gas-town-and-wasteland) | [Trust boundaries](/docs/contributing/architecture/cloud-security#trust-boundaries) | -The CLI uses flat ESM exports inside each module, then re-exports the module as a namespace from an index file when callers need grouped access. Avoid adding new `export namespace` declarations; top-level exports are easier to tree-shake and work better with Node's type-stripping runtime. +## Local entry points and clients -```typescript -// packages/opencode/src/session/session.ts -export const create = fn(CreateSchema, async (input) => { - // ... -}) +These local surfaces live in [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). Package paths below are relative to that repository root. -export const list = fn(ListSchema, async (input) => { - // ... -}) +| Surface | Package in `Kilo-Org/kilocode` | Runtime model | +|---|---|---| +| Kilo CLI TUI | `packages/opencode/` | Interactive local client with daemon attach and worker-backed fallback paths | +| `kilo run` | `packages/opencode/` | Headless prompt execution through explicit attach, daemon attach, or embedded fallback | +| `kilo serve` | `packages/opencode/` | Local HTTP + SSE server for local clients | +| Kilo Console | `packages/kilo-console/`{% linebreak /%}`packages/opencode/` | Browser UI served at `/console` by a started or reused local daemon | +| VS Code extension | `packages/kilo-vscode/` | Extension host starts one shared editor-owned `kilo serve` server and routes webviews through HTTP + global SSE; SDK directory selects local runtime instance | +| JetBrains plugin | `packages/kilo-jetbrains/` | Split-mode Swing plugin; backend module starts one editor-owned `kilo serve` server and caches workspace clients by directory | -// packages/opencode/src/session/index.ts -export * as Session from "./session" -``` +## Cloud service families -Prefer importing the specific export when possible. Use the namespace re-export (`Session.create`, `Session.list`) when a caller benefits from grouped module access or when preserving the existing public shape. +Hosted service families live in [`Kilo-Org/cloud`](https://github.com/Kilo-Org/cloud). Paths below are relative to that repository root unless another repository is named. -### CLI Server API +| Boundary | Primary source paths | Role | +|---|---|---| +| Kilo Cloud | `apps/web/`{% linebreak /%}`services/` | Hosted platform repository for identity, billing, routing, product configuration, automation, and scoped execution services | +| Web control plane | `apps/web/` | Hosted application layer for authorization, configuration, and API orchestration | +| Kilo Gateway | `apps/web/src/app/api/gateway/`{% linebreak /%}`apps/web/src/lib/ai-gateway/`{% linebreak /%}Local integration: `Kilo-Org/kilocode/packages/kilo-gateway/` | First-party model-routing boundary and local client integration | +| Cloud Agent | `services/cloud-agent-next/` | Hosted coding-session capability with policy-selected sandbox allocation | +| Automation Services | `services/code-review-infra/`{% linebreak /%}`services/auto-triage-infra/`{% linebreak /%}`services/auto-fix-infra/`{% linebreak /%}`services/security-auto-analysis/`{% linebreak /%}`services/security-sync/`{% linebreak /%}`services/webhook-agent-ingest/` | Trigger-driven review, triage, fix, security, and configured webhook flows | +| Adjacent hosted boundaries | `services/app-builder/`{% linebreak /%}`services/kiloclaw/`{% linebreak /%}`services/gastown/`{% linebreak /%}`services/wasteland/`{% linebreak /%}Supporting services | App Builder, KiloClaw, Gas Town, Wasteland, chat, notifications, and supporting services | -The CLI server is Hono-based and publishes an OpenAPI-compatible HTTP + SSE API consumed by `@kilocode/sdk`. Some route groups are being migrated behind the experimental Effect `HttpApi` bridge while preserving the generated SDK shape. +## Supporting packages -- Keep the SDK output stable when moving routes between Hono and Effect `HttpApi`. -- Use `KILO_EXPERIMENTAL_HTTPAPI` only for migration testing; public clients should not depend on the bridge detail. -- Regenerate `packages/sdk/js/` after server endpoint changes. -- Keep request handling observable with route spans and stable attributes where possible. +These supporting packages also live in [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). Package paths below are relative to that repository root. -### Tool Implementation - -Tools follow a consistent pattern with Zod schema validation: - -```typescript -export const ReadTool = Tool.define({ - name: "read", - description: "Read a file", - parameters: z.object({ - path: z.string(), - }), - async execute(params) { - // ... - }, -}) -``` +| Package in `Kilo-Org/kilocode` | Role | +|---|---| +| `packages/kilo-indexing/` | Per-directory asynchronous codebase indexing engine behind Kilo CLI bridge | +| `packages/sdk/js/` | Generated JavaScript client and handwritten wrapper for local server APIs | +| `packages/kilo-gateway/` | Local Kilo Gateway client integration used by Kilo CLI runtime | +| `packages/kilo-console/` | Browser UI served by local daemon at `/console` | -## Build System +## Architecture pages -The project uses: +| Page | What it covers | +|---|---| +| [CLI Runtime](/docs/contributing/architecture/cli-runtime) | Local execution modes, daemon, server authentication, routing, persistence, snapshots, SDK, config, SSE, Kilo Console, and indexing | +| [VS Code Extension](/docs/contributing/architecture/vscode-extension) | Shared local `kilo serve` ownership, webview bridge, Agent Manager, PTYs, recovery, bundled resources, and build outputs | +| [JetBrains Plugin](/docs/contributing/architecture/jetbrains-plugin) | Split-mode modules, RPC, bundled local `kilo serve` lifecycle, Kotlin SDK, recovery, and remote-development constraints | +| [Cloud Platform](/docs/contributing/architecture/cloud-platform) | Hosted service inventory, Cloud Agent topology, shared cloud boundaries, and adjacent hosted runtimes | +| [Automation Services](/docs/contributing/architecture/automation-services) | Trigger-driven Workers, queues, callbacks, ownership, and scoped execution paths | +| [Cloud Security](/docs/contributing/architecture/cloud-security) | Cloud trust boundaries, data flows, persistence, isolation, controls, and third-party categories | -- **Bun** — Package management (monorepo workspaces) and runtime -- **Turborepo** — Monorepo task orchestration -- **esbuild** — Bundling for the CLI and VS Code extension -- **TypeScript** — Type checking via `tsgo` across all packages -- **Vitest / Bun test** — Test runner +## Development pages -## Repositories +After system-boundary pages, continue with Development Patterns for implementation rules. Use CLI Config Schema when changing config keys or editor-facing schema publication. -| Repository | Contents | +| Page | What it covers | |---|---| -| [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode) | CLI engine, VS Code extension, SDK, gateway client, telemetry, docs, UI components | -| Cloud (private) | Web dashboard, Cloud Agent, Kilo Bot, KiloClaw, Gas Town, code review, auto triage, billing, and supporting Cloudflare Workers | - -## Further Reading - -- [Development Environment](/docs/contributing/development-environment) — Setup guide -- [Architecture Features](/docs/contributing/architecture/features) — Detailed feature specs -- [Ecosystem](/docs/contributing/ecosystem) — Related projects and integrations +| [Development Patterns](/docs/contributing/architecture/development-patterns) | Code-ownership decisions, shared-file seams, SDK generation, validation guards, and fork maintenance | +| [CLI Config Schema](/docs/contributing/architecture/config-schema) | Separate runtime-loading and editor-validation paths for cross-repository config contract | + +## Related pages + +- [CLI Runtime](/docs/contributing/architecture/cli-runtime) - local runtime, server, routing, persistence, and SDK contracts +- [Cloud Platform](/docs/contributing/architecture/cloud-platform) - hosted layers, Cloud Agent topology, and adjacent hosted boundaries +- [Cloud Security](/docs/contributing/architecture/cloud-security) - cross-cutting trust boundaries, controls, and shared responsibility +- [Development Patterns](/docs/contributing/architecture/development-patterns) - code-ownership decisions and contributor workflow +- [Development Environment](/docs/contributing/development-environment) - setup guide +- [Ecosystem](/docs/contributing/ecosystem) - related projects and integrations +- [KiloClaw Overview](/docs/kiloclaw/overview) - customer-facing KiloClaw docs diff --git a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md new file mode 100644 index 00000000000..d54224b8f8a --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md @@ -0,0 +1,160 @@ +--- +title: "JetBrains Plugin Architecture" +description: "Architecture of the Kilo JetBrains split-mode plugin" +--- + +# JetBrains Plugin Architecture + +The JetBrains plugin (`packages/kilo-jetbrains/`) is a split-mode Swing client of [Kilo CLI runtime](/docs/contributing/architecture/cli-runtime). Frontend module renders IDE UI. Backend module owns project-local logic and one bundled `kilo serve` server. Shared module defines cross-process RPC contracts and serializable payloads. + +{% callout type="info" title="Scope" %} +This page describes repository-defined plugin architecture and development checks. It does not claim Marketplace rollout state or remote-host deployment configuration. +{% /callout %} + +## Split-mode modules + +[CLI Runtime](/docs/contributing/architecture/cli-runtime) defines shared local-server authentication, directory routing, provider routing, persistence, and SSE contracts. This page starts at JetBrains client boundary. + +| Module | Runs where | Responsibility | +|---|---|---| +| `shared` | Frontend and backend | `@Rpc` interfaces, `RemoteApi` contracts, serializable DTOs, shared logging helpers | +| `frontend` | JetBrains frontend | Swing UI, typing assistance, latency-sensitive client work, backend RPC calls | +| `backend` | JetBrains backend | Project model, analysis, CLI extraction and process lifecycle, HTTP/SSE, workspace state, RPC implementations | + +In monolithic IDE mode, all modules load in one process and RPC calls remain in-process suspend calls. In remote development, frontend and backend can run in separate processes. Payloads crossing boundary use `kotlinx.serialization`. + +```mermaid +flowchart LR + subgraph frontend ["JetBrains frontend"] + swing["Swing UI"] + rpcClient["RPC clients"] + end + + subgraph backend ["JetBrains backend"] + rpcImpl["RPC providers"] + app["Backend app service"] + conn["KiloConnectionService"] + workspaces["Directory workspace cache"] + cli["Extracted kilo serve --port 0"] + end + + runtime["Kilo CLI runtime"] + + swing --> rpcClient --> rpcImpl --> app + app --> conn --> cli --> runtime + app --> workspaces --> cli +``` + +## Frontend-to-backend RPC + +Shared RPC surfaces separate app, workspace, session, and migration behavior. + +| Contract | Scope | Examples | +|---|---|---| +| `KiloAppRpcApi` | Application | Connect, state flow, health, retry, restart, reinstall, model state, profile, login, telemetry | +| `KiloWorkspaceRpcApi` | Directory | Resolve real backend project directory, workspace state flow, reload, file lookup, open file | +| `KiloSessionRpcApi` | Session and directory | Create/list sessions, prompt, stream events, permission and question replies, config update | +| `KiloMigrationRpcApi` | Legacy migration | Detect, run, and observe migration state | + +Frontend calls RPC from coroutines, not Event Dispatch Thread (EDT). Swing creation, mutation, and access remain on EDT. Long-lived RPC calls and flows should use JetBrains durable patterns so UI can survive reconnect and backend restart. + +## Bundled CLI lifecycle + +Backend extracts CLI resource from plugin JAR into IntelliJ system path: + +```text +/kilo/bin/kilo +/kilo/bin/kilo.exe # Windows +``` + +It chooses platform resource by OS and CPU architecture, reuses extracted binary when resource size matches, and can force re-extraction during reinstall flow. This editor-owned child is separate from detached local daemon managed by `kilo daemon`. + +| Area | Behavior | +|---|---| +| Spawn | Runs extracted binary as `kilo serve --port 0` | +| Port | CLI server prefers `4096`, then asks OS for free port; backend reads listening line from stdout | +| Authentication | Generates random 32-byte hex password and passes `KILO_SERVER_PASSWORD`; username defaults to `kilo` | +| Environment | Sets JetBrains client/platform metadata, question tool enablement, telemetry level, Claude Code disable flag, and default edit/bash ask permissions unless overridden | +| Ownership | Backend app service owns CLI manager and connection lifecycle | +| Shutdown | Kills process descendants, then process; uses forced termination after timeout when needed | + +## Generated Kotlin client + +JetBrains backend does not consume checked-in JavaScript SDK. Gradle owns build-local client flow: + +1. Generate CLI OpenAPI into backend build directory. +2. Normalize spec for Kotlin generation. +3. Run OpenAPI Kotlin generator with `jvm-okhttp4` library. +4. Compile generated Kotlin source with backend. + +Generated `DefaultApi` handles typed CLI endpoint calls. Selected paths use raw HTTP when generated client shape is unsuitable for specific request behavior. + +## Connection and recovery + +Backend connection service uses bundled OkHttp clients and `/global/event` SSE. + +| Signal or path | Behavior | +|---|---| +| API client | No call/read timeout for generated API and SSE | +| App-load client | Bounded timeout for startup requests | +| Health client | 3 second timeout for `/global/health` polling | +| SSE | OkHttp EventSource connects to `/global/event` | +| Heartbeat | Server emits every 10 seconds; watcher reconnects after 15 seconds without event | +| Health poll | Runs every 10 seconds and forces reconnect on failure | +| SSE failure | Waits 250 ms, reconnects stream if process lives, or delegates full backend reconnect | +| Process monitor | On child exit, clears process state, reports error, and schedules reconnect | + +## Workspace routing + +Backend workspace manager caches workspace clients by directory path. Root project and worktree are same routing shape: worktree is alternate directory key. First lookup creates workspace object and starts load; disconnect clears cache. + +This mirrors CLI `InstanceStore`: directory remains isolation key while one editor-owned `kilo serve` process serves multiple workspace contexts. + +## Remote development constraints + +Split mode changes path and UI assumptions: + +| Constraint | Rule | +|---|---| +| Project path | Frontend base path can be synthetic; resolve real project directory through backend RPC before CLI calls | +| UI toolkit | Use Swing and IntelliJ platform components; do not use JCEF because it does not work for remote split-mode host arrangement | +| RPC traffic | Debounce UI events, batch requests, cache results, and page large payloads | +| First paint | Render empty state promptly and fill backend data progressively | +| Blocking I/O | Keep in backend/background context; switch to `Dispatchers.IO` inside callee | + +## Development checks + +JetBrains Kotlin toolchain is Java 21. Check `java -version` before Gradle verification. + +| Check | Command from `packages/kilo-jetbrains/` | +|---|---| +| Typecheck | `./gradlew typecheck` | +| Tests | `./gradlew test` | +| Full plugin build | `bun run build` | +| Gradle plugin assembly with prepared CLI binaries | `./gradlew buildPlugin` | +| Sandbox IDE | `./gradlew runIde` | +| Split backend sandbox | `./gradlew runIdeBackend` | +| Split-mode run configs | `./gradlew generateSplitModeRunConfigurations` | + +Run `Plugin DevKit | Code | Frontend and Backend API Usage` inspection when moving code across split boundary. + +## Source map + +Paths below are relative to [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). + +| Concern | Source path | +|---|---| +| Split modules | `packages/kilo-jetbrains/settings.gradle.kts` and module XML descriptors | +| Contributor constraints | `packages/kilo-jetbrains/AGENTS.md` | +| CLI lifecycle | `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt` | +| Connection recovery | `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt` | +| Workspace cache | `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt` | +| Kotlin client generation | `packages/kilo-jetbrains/backend/build.gradle.kts` | +| RPC contracts | `packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/` | + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - local and hosted execution map +- [CLI Runtime](/docs/contributing/architecture/cli-runtime) - shared local-server, routing, persistence, and SSE behavior +- [VS Code Extension](/docs/contributing/architecture/vscode-extension) - corresponding editor-client architecture for VS Code +- [Development Patterns](/docs/contributing/architecture/development-patterns) - choose code-ownership seam and validation workflow before editing plugin contracts diff --git a/packages/kilo-docs/pages/contributing/architecture/mcp-oauth-authorization.md b/packages/kilo-docs/pages/contributing/architecture/mcp-oauth-authorization.md deleted file mode 100644 index 79c16269a76..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/mcp-oauth-authorization.md +++ /dev/null @@ -1,572 +0,0 @@ ---- -title: "MCP OAuth Authorization" -description: "OAuth 2.1-based authorization flow for MCP servers" ---- - -# MCP OAuth Authorization - -### Overview - -Many MCP servers require authentication to access protected resources. Currently, Kilo Code only supports static credential configuration (API keys, tokens) which must be manually entered and stored. This creates friction for users and security concerns for enterprises. - -The MCP specification defines an OAuth 2.1-based authorization flow that enables secure, user-friendly authentication without requiring users to manually manage credentials. This document specifies how Kilo Code will implement the MCP Authorization specification to support OAuth-enabled MCP servers. - -### Goals - -1. **Eliminate manual credential management** - Users authenticate via browser-based OAuth flows instead of copying/pasting API keys -2. **Improve security** - Tokens are obtained through secure OAuth flows with PKCE, reducing credential exposure -3. **Support enterprise SSO** - Organizations can use their existing identity providers -4. **Maintain compatibility** - Continue supporting static credentials for servers that don't implement OAuth - -### Non-Goals (MVP) - -- Token refresh automation (will use re-authentication flow initially) -- Dynamic Client Registration (will rely on Client ID Metadata Documents) -- Multiple authorization server selection (will use first available) - -## MCP Authorization Specification Summary - -The MCP Authorization spec (Protocol Revision 2025-11-25) defines an OAuth 2.1-based flow for HTTP-based MCP transports. Key components: - -### Roles - -- **MCP Server** - Acts as OAuth 2.1 Resource Server, accepts access tokens -- **MCP Client** (Kilo Code) - Acts as OAuth 2.1 Client, obtains tokens on behalf of users -- **Authorization Server** - Issues access tokens (may be hosted with MCP server or separate) - -### Discovery Flow - -1. Client makes unauthenticated request to MCP server -2. Server returns `401 Unauthorized` with `WWW-Authenticate` header containing `resource_metadata` URL -3. Client fetches Protected Resource Metadata (RFC 9728) to discover authorization server(s) -4. Client fetches Authorization Server Metadata (RFC 8414 or OpenID Connect Discovery) -5. Client initiates OAuth authorization flow - -### Client Registration - -The spec supports three approaches (in priority order): - -1. **Pre-registration** - Client has existing credentials for the server -2. **Client ID Metadata Documents** - Client uses HTTPS URL as client_id pointing to metadata JSON -3. **Dynamic Client Registration** - Client registers dynamically via RFC 7591 - -### Authorization Flow - -1. Generate PKCE code verifier and challenge -2. Open browser with authorization URL including `resource` parameter (RFC 8707) -3. User authenticates and authorizes -4. Receive authorization code via redirect -5. Exchange code for access token -6. Use access token in `Authorization: Bearer` header for MCP requests - -## System Design - -### Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────────────────┐ -│ MCP OAuth Authorization Flow │ -├─────────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ 1. MCP Request ┌──────────────────┐ │ -│ │ │ ───────────────────► │ │ │ -│ │ Kilo Code │ │ MCP Server │ │ -│ │ Extension │ ◄─────────────────── │ (Resource │ │ -│ │ │ 2. 401 + metadata │ Server) │ │ -│ └──────┬───────┘ └──────────────────┘ │ -│ │ │ -│ │ 3. Fetch resource metadata │ -│ │ 4. Fetch auth server metadata │ -│ ▼ │ -│ ┌──────────────┐ ┌──────────────────┐ │ -│ │ OAuth │ 5. Auth Request │ │ │ -│ │ Service │ ───────────────────► │ Authorization │ │ -│ │ │ │ Server │ │ -│ │ - Discovery │ ◄─────────────────── │ │ │ -│ │ - PKCE │ 8. Token Response │ - User Auth │ │ -│ │ - Tokens │ │ - Consent │ │ -│ └──────┬───────┘ └──────────────────┘ │ -│ │ ▲ │ -│ │ 6. Open browser │ 7. User authenticates │ -│ ▼ │ │ -│ ┌──────────────┐ ┌────────┴─────────┐ │ -│ │ Browser │ ─────────────────────►│ User │ │ -│ │ │ │ │ │ -│ └──────────────┘ └──────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────────┘ -``` - -### New Components - -#### 1. McpOAuthService - -A new service responsible for managing OAuth flows for MCP servers: - -```typescript -// src/services/mcp/oauth/McpOAuthService.ts - -interface McpOAuthService { - /** - * Initiates OAuth flow for an MCP server that returned 401 - * @param serverUrl The MCP server URL - * @param wwwAuthenticateHeader The WWW-Authenticate header from 401 response - * @returns Promise resolving to access token - */ - initiateOAuthFlow(serverUrl: string, wwwAuthenticateHeader: string): Promise - - /** - * Gets stored tokens for a server, if available and valid - */ - getStoredTokens(serverUrl: string): Promise - - /** - * Clears stored tokens for a server (for logout/re-auth) - */ - clearTokens(serverUrl: string): Promise - - /** - * Refreshes tokens if refresh token is available - */ - refreshTokens(serverUrl: string): Promise -} - -interface OAuthTokens { - accessToken: string - tokenType: string - expiresAt?: number - refreshToken?: string - scope?: string -} -``` - -#### 2. McpAuthorizationDiscovery - -Handles the discovery of authorization server metadata: - -```typescript -// src/services/mcp/oauth/McpAuthorizationDiscovery.ts - -interface McpAuthorizationDiscovery { - /** - * Discovers authorization server from WWW-Authenticate header or well-known URIs - */ - discoverAuthorizationServer(serverUrl: string, wwwAuthenticateHeader?: string): Promise - - /** - * Fetches Protected Resource Metadata (RFC 9728) - */ - fetchResourceMetadata(metadataUrl: string): Promise - - /** - * Fetches Authorization Server Metadata (RFC 8414 / OIDC Discovery) - */ - fetchAuthServerMetadata(issuerUrl: string): Promise -} - -interface ProtectedResourceMetadata { - resource: string - authorization_servers: string[] - scopes_supported?: string[] - // ... other RFC 9728 fields -} - -interface AuthorizationServerMetadata { - issuer: string - authorization_endpoint: string - token_endpoint: string - scopes_supported?: string[] - response_types_supported: string[] - code_challenge_methods_supported?: string[] - client_id_metadata_document_supported?: boolean - registration_endpoint?: string - // ... other RFC 8414 fields -} -``` - -#### 3. McpOAuthTokenStorage - -Secure storage for OAuth tokens: - -```typescript -// src/services/mcp/oauth/McpOAuthTokenStorage.ts - -interface McpOAuthTokenStorage { - /** - * Stores tokens securely using VS Code SecretStorage - */ - storeTokens(serverUrl: string, tokens: OAuthTokens): Promise - - /** - * Retrieves stored tokens - */ - getTokens(serverUrl: string): Promise - - /** - * Removes stored tokens - */ - removeTokens(serverUrl: string): Promise - - /** - * Lists all servers with stored tokens - */ - listServers(): Promise -} -``` - -#### 4. Client ID Metadata Document Hosting - -For Client ID Metadata Documents, Kilo Code needs to host a metadata document. We will use static hosting on kilocode.ai: - -- Host at `https://kilocode.ai/.well-known/oauth-client/vscode-extension.json` -- Simple, reliable, no runtime dependencies -- Authorization servers can cache the document effectively -- No attack surface from dynamic generation logic - -Metadata document: - -```json -{ - "client_id": "https://kilocode.ai/.well-known/oauth-client/vscode-extension.json", - "client_name": "Kilo Code", - "client_uri": "https://kilocode.ai", - "logo_uri": "https://kilocode.ai/logo.png", - "redirect_uris": ["http://127.0.0.1:0/callback", "vscode://kilocode.kilo-code/oauth/callback"], - "grant_types": ["authorization_code"], - "response_types": ["code"], - "token_endpoint_auth_method": "none" -} -``` - -### Integration with McpHub - -The existing `McpHub` class needs modifications to support OAuth: - -```typescript -// Modifications to McpHub.ts - -class McpHub { - private oauthService: McpOAuthService - - private async connectToServer(name: string, config: ServerConfig, source: "global" | "project"): Promise { - // ... existing connection logic ... - - // For HTTP-based transports, handle OAuth - if (config.type === "sse" || config.type === "streamable-http") { - try { - await this.connectWithOAuth(name, config, source) - } catch (error) { - if (this.isOAuthRequired(error)) { - // Initiate OAuth flow - const tokens = await this.oauthService.initiateOAuthFlow(config.url, error.wwwAuthenticateHeader) - // Retry connection with token - await this.connectWithToken(name, config, source, tokens) - } else { - throw error - } - } - } - } - - private isOAuthRequired(error: unknown): boolean { - // Check if error is 401 with WWW-Authenticate header - return error instanceof HttpError && error.status === 401 && error.headers?.["www-authenticate"] - } -} -``` - -### Configuration Schema Updates - -Update the server configuration schema to support OAuth: - -```typescript -// Extended server config for OAuth-enabled servers -const OAuthServerConfigSchema = BaseConfigSchema.extend({ - type: z.enum(["sse", "streamable-http"]), - url: z.string().url(), - headers: z.record(z.string()).optional(), - - // OAuth configuration - oauth: z - .object({ - // Override client_id if pre-registered - clientId: z.string().optional(), - clientSecret: z.string().optional(), - - // Override scopes to request - scopes: z.array(z.string()).optional(), - - // Disable OAuth for this server (use static headers instead) - disabled: z.boolean().optional(), - }) - .optional(), -}) -``` - -### Browser-Based Authorization Flow - -The OAuth flow requires opening a browser for user authentication: - -```typescript -// src/services/mcp/oauth/McpOAuthBrowserFlow.ts - -interface McpOAuthBrowserFlow { - /** - * Opens browser for authorization and waits for callback - */ - authorize(params: AuthorizationParams): Promise -} - -interface AuthorizationParams { - authorizationEndpoint: string - clientId: string - redirectUri: string - scope: string - state: string - codeChallenge: string - codeChallengeMethod: "S256" - resource: string -} - -interface AuthorizationResult { - code: string - state: string -} -``` - -**Redirect URI Handling:** - -Two approaches for receiving the OAuth callback: - -1. **Local HTTP Server** (Primary) - - Start temporary HTTP server on random port - - Use `http://127.0.0.1:{port}/callback` as redirect URI - - Server receives callback, extracts code, closes - -2. **VS Code URI Handler** (Fallback) - - Register `vscode://kilocode.kilo-code/oauth/callback` URI handler - - Works when local server isn't possible - - Requires VS Code to be running - -### Token Management - -#### Storage - -Tokens are stored using VS Code's SecretStorage API: - -```typescript -// Key format: mcp-oauth-{serverUrlHash} -const storageKey = `mcp-oauth-${hashServerUrl(serverUrl)}` - -// Stored value (encrypted by VS Code) -interface StoredTokenData { - accessToken: string - refreshToken?: string - expiresAt?: number - scope?: string - serverUrl: string - issuedAt: number -} -``` - -#### Token Lifecycle - -1. **Initial Authentication** - - User triggers connection to OAuth-enabled MCP server - - Server returns 401, OAuth flow initiated - - User authenticates in browser - - Tokens stored securely - -2. **Subsequent Connections** - - Check for stored tokens - - If valid, use directly - - If expired and refresh token available, attempt refresh - - If refresh fails or no refresh token, re-authenticate - -3. **Token Refresh** (Future Enhancement) - - Background refresh before expiry - - Automatic retry on 401 with new token - -### Error Handling - -```typescript -// OAuth-specific errors -class McpOAuthError extends Error { - constructor( - message: string, - public code: OAuthErrorCode, - public serverUrl: string, - public details?: Record, - ) { - super(message) - } -} - -enum OAuthErrorCode { - DISCOVERY_FAILED = "discovery_failed", - AUTHORIZATION_FAILED = "authorization_failed", - TOKEN_EXCHANGE_FAILED = "token_exchange_failed", - TOKEN_REFRESH_FAILED = "token_refresh_failed", - PKCE_NOT_SUPPORTED = "pkce_not_supported", - USER_CANCELLED = "user_cancelled", - TIMEOUT = "timeout", -} -``` - -### User Experience - -#### Connection Flow - -1. User adds/enables OAuth-enabled MCP server -2. Extension detects OAuth requirement (401 response) -3. Notification: "MCP server requires authentication. Click to sign in." -4. User clicks -> Browser opens to authorization server -5. User authenticates and authorizes -6. Browser redirects back -> Extension receives token -7. Connection completes -> Server shows as connected - -#### UI Indicators - -- **Authenticated servers**: Show lock icon with "Authenticated" status -- **Authentication required**: Show warning icon with "Sign in required" action -- **Authentication expired**: Show refresh icon with "Re-authenticate" action - -#### Settings UI - -Add OAuth status to MCP server settings: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ MCP Server: github-mcp │ -├─────────────────────────────────────────────────────────────┤ -│ Status: Connected │ -│ Type: streamable-http │ -│ URL: https://mcp.github.com │ -│ │ -│ Authentication │ -│ - Method: OAuth 2.0 │ -│ - Status: Authenticated │ -│ - Expires: 2024-01-15 10:30 AM │ -│ - [Sign Out] [Re-authenticate] │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Security Considerations - -### PKCE Requirement - -All OAuth flows MUST use PKCE with S256 challenge method: - -```typescript -function generatePKCE(): { verifier: string; challenge: string } { - // Generate 32-byte random verifier - const verifier = base64UrlEncode(crypto.randomBytes(32)) - - // Create S256 challenge - const challenge = base64UrlEncode(crypto.createHash("sha256").update(verifier).digest()) - - return { verifier, challenge } -} -``` - -### State Parameter - -Generate cryptographically random state to prevent CSRF: - -```typescript -const state = base64UrlEncode(crypto.randomBytes(32)) -// Store state locally and verify on callback -``` - -### Token Storage Security - -- Use VS Code SecretStorage (encrypted, per-workspace) -- Never log tokens -- Clear tokens on extension uninstall -- Support manual token revocation - -### Resource Parameter - -Always include `resource` parameter to bind tokens to specific MCP server: - -```typescript -const authUrl = new URL(authorizationEndpoint) -authUrl.searchParams.set("resource", mcpServerUrl) -``` - -### Redirect URI Validation - -- Only accept callbacks on registered redirect URIs -- Validate state parameter matches -- Use localhost with random port (not predictable) - -## Scope and Implementation Plan - -### Phase 1: Core OAuth Infrastructure - -- [ ] Create `McpOAuthService` with basic flow support -- [ ] Implement `McpAuthorizationDiscovery` for metadata fetching -- [ ] Implement `McpOAuthTokenStorage` using SecretStorage -- [ ] Add PKCE generation utilities -- [ ] Create local HTTP server for OAuth callbacks - -### Phase 2: McpHub Integration - -- [ ] Modify `McpHub.connectToServer()` to detect OAuth requirements -- [ ] Add OAuth retry logic for 401 responses -- [ ] Update server configuration schema for OAuth options -- [ ] Add token injection to HTTP transports - -### Phase 3: Client ID Metadata Document - -- [ ] Host Kilo Code client metadata at kilocode.ai -- [ ] Implement client_id URL generation -- [ ] Add fallback to pre-registration for unsupported servers - -### Phase 4: User Experience - -- [ ] Add OAuth status indicators to MCP server UI -- [ ] Implement "Sign in" / "Sign out" actions -- [ ] Add authentication expiry notifications -- [ ] Create re-authentication flow - -### Phase 5: Testing & Documentation - -- [ ] Unit tests for OAuth service components -- [ ] Integration tests with mock OAuth server -- [ ] End-to-end tests with real OAuth-enabled MCP servers -- [ ] User documentation for OAuth-enabled servers - -## Future Enhancements - -- **Automatic token refresh** - Background refresh before expiry -- **Dynamic Client Registration** - Support RFC 7591 for servers that require it -- **Multiple authorization servers** - UI for selecting preferred auth server -- **Enterprise SSO integration** - Support for organization identity providers -- **Token sharing across workspaces** - Optional global token storage -- **Offline token caching** - Support for offline scenarios with cached tokens - -## Appendix: MCP Authorization Spec Compliance Checklist - -### Required (MUST) - -- [ ] Use PKCE with S256 for all authorization requests -- [ ] Include `resource` parameter in authorization and token requests -- [ ] Support WWW-Authenticate header parsing for resource metadata discovery -- [ ] Support well-known URI fallback for resource metadata -- [ ] Support both OAuth 2.0 and OpenID Connect discovery endpoints -- [ ] Use Authorization header with Bearer scheme for token transmission -- [ ] Validate PKCE support before proceeding with authorization - -### Recommended (SHOULD) - -- [ ] Support Client ID Metadata Documents -- [ ] Use scope from WWW-Authenticate header when provided -- [ ] Fall back to scopes_supported when scope not in challenge -- [ ] Implement step-up authorization for insufficient_scope errors - -### Optional (MAY) - -- [ ] Support Dynamic Client Registration (RFC 7591) -- [ ] Support pre-registered client credentials -- [ ] Implement token refresh flows diff --git a/packages/kilo-docs/pages/contributing/architecture/onboarding-improvements.md b/packages/kilo-docs/pages/contributing/architecture/onboarding-improvements.md deleted file mode 100644 index 9dc9a222336..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/onboarding-improvements.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: "Onboarding Improvements" -description: "Onboarding and engagement improvements architecture" ---- - -# Onboarding Improvements - -# Overview - -New users get minimal onboarding with generic prompts and no feature guidance. This causes poor engagement and users miss key capabilities. Existing users lack visibility into new features. - -This spec proposes improved welcome screens, interactive tutorials, and in-product changelog to drive better activation and feature adoption. - -# Requirements - -- Replace generic "CSS gradient generator" prompt with 4+ contextually relevant starter prompts with visual icons -- Implement interactive tutorial system highlighting key UI elements (modes, mcp, settings) -- Display in-product changelog with smart visibility rules for returning users -- Remember tutorial completion state to avoid showing it repeatedly to users -- Implement analytics tracking for onboarding completion rates and user engagement metrics - -# Tasks - -## Welcome Screen Redesign - -Redesign welcome screen with visual appeal and actionable starter prompts. - -**Layout Structure:** - -``` -+----------------------------------+ -| [KiloCode Logo] | -| "Welcome to KiloCode" | -| | -| +--------+ +--------+ | -| | Card 1 | | Card 2 | | -| +--------+ +--------+ | -| | -| +--------+ +--------+ | -| | Card 3 | | Card 4 | | -| +--------+ +--------+ | -| | -| [Skip] [Start Tutorial] | -+----------------------------------+ -``` - -**Starter Prompt Cards Ideas** - -- **Debug Helper**: 🐛 "Help me fix a bug in my code" -- **Feature Builder**: ⚡ "Add a new feature to my project" -- **Documentation**: 📝 "Generate documentation for this file" -- **Code Review**: 🔍 "Review my current changes by running `git diff` and analyzing the output" - -Each card will have: - -- Hover state with subtle elevation -- Click to populate chat input -- Icon using VS Code's codicon library - -## In-App Tutorial Flow - -Users aren't guided through Kilo Code's modes or key features. The existing tab-based tutorial is easily dismissed, causing users to miss critical functionality. - -Replace the tab-based tutorial with an in-app experience using specific highlighting flows to guide users through core functionality. - -**Tutorial Flow** - -``` -Step 1: Welcome -├── Highlight: Entire interface -├── Content: "Welcome to KiloCode! Let's take a quick tour." -└── Actions: [Skip Tour] [Next] - -Step 2: Mode Selection -├── Highlight: Mode selector buttons -├── Content: "Choose between Chat, Edit, and Architect modes for different tasks" -└── Actions: [Back] [Next] - -Step 3: Side Panels & MCP Configuration -├── Highlight: Left sidebar -├── Content: "Access history, memory, and configure MCP servers for enhanced capabilities" -└── Actions: [Back] [Next] - -Step 4: Starting a Chat -├── Highlight: Input area -├── Content: "Type your request here or use @ to reference files" -└── Actions: [Back] [Next] - -Step 5: Starter Prompts -├── Highlight: Starter prompt area -├── Content: "Use these prompts to get started quickly with common tasks" -└── Actions: [Back] [Finish] -``` - -## Kilo Provider Settings UI Improvements - -The "Set API Key" button is at the bottom of settings, making Kilo Code setup hard to discover and complete. - -**Improvements:** - -- Move "Set API Key" button next to API key input field -- Rearrange layout for better flow -- Make Kilo Code provider setup prominent -- Reduce setup friction - -## Analytics Integration - -Track user interactions to identify where users drop off in the product funnel. This data enables targeted improvements to increase activation rates. - -**Key Funnel Events to Track:** - -**Onboarding Funnel:** - -- `onboarding.started` -- `onboarding.tutorial.completed` -- `onboarding.tutorial.skipped` -- `onboarding.prompt.selected` (with prompt type) -- `onboarding.finished` - Critical completion milestone - -**Product Engagement Funnel:** - -- `chat.started` - First interaction with core functionality -- `mode.changed` (with mode type) - Feature discovery and usage -- `changelog.viewed` - Re-engagement with new features -- `changelog.dismissed` -- `provider.configured` - Setup completion -- `file.referenced` - Advanced feature usage (@-mentions) -- `mcp.configured` - Power user feature adoption - -**Drop-off Analysis Goals:** - -- Identify at what point users stop progressing through onboarding -- Measure conversion from onboarding completion to first chat -- Track mode adoption rates and feature discovery patterns -- Understand re-engagement effectiveness through changelog interactions - -## In-Product Changelog - -Re-engage inactive users by highlighting new features and improvements. Acts as a reminder system to reactivate dormant users and keep active users informed. - -## Features for the Future - -- **User Drop-off Funnel Analysis**: Implement comprehensive PostHog funnel tracking to identify where users abandon the onboarding flow and create targeted recovery strategies -- **Contextual Project Analysis**: Detect and analyze user's project structure to provide personalized first-action recommendations based on their codebase -- Progressive disclosure of advanced features over time -- Personalized onboarding flows based on user role (frontend dev, backend dev, DevOps) -- AI-powered prompt suggestions based on actual project code patterns -- Integration with Kilo Code teams for company/repo-personalized onboarding diff --git a/packages/kilo-docs/pages/contributing/architecture/organization-modes-library.md b/packages/kilo-docs/pages/contributing/architecture/organization-modes-library.md deleted file mode 100644 index bcabb1c18bf..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/organization-modes-library.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Organization Modes Library" -description: "Organization modes library architecture" ---- - -# Organization Modes Library - -# Overview - -We want to expand the value of teams & enterprise and make it more useful for collaboration and hopefully increase 'lock in' to the Kilo platform. We can build something _like_ a prompt library, but a bit more powerful. We can leverage Kilo's unique "modes" which already has "marketplace" support to enable teams & enterprises to define and manage modes on the backend webapp and have those modes show up in the modes marketplace if the user is using an organization in the extension. This feature is mostly valuable in larger organizations where they work on many different repositories. If you have very few repositories, then the value is low since you can also store custom modes within the git repo, effectively sharing it with anyone who uses the repo already. - -# Requirements - -This section outlines the detailed requirements that the solution will fulfill. - -- Ability for an organization to have custom modes visible in the web UI. -- Fetch the organization custom modes and show them by default if you switch to an organization alongside any other modes you have manually installed & the "base" modes like "code" "architect" etc. Important consideration here is the organization also has a "code" mode it should overwrite the built in one. This allows the organization owners to modify the built in prompts. -- Ability for team members (or owners only?) to do crud on modes on the UI of the web, including uploading/downloading yaml directly, editing the yaml, and having a form style editor as seen in the extension. -- Web ui showing a list of modes and common info like when created, who created, and when updated. -- Auditing of Custom Mode CRUD operations in the Kilo backend web UI. - -### Non-requirements - -- Disabling the mode marketplace or removing built-in modes. -- Disabling custom modes created locally by an organization member. -- Ability to upload modes from the extension into the web backend via a special extension button. -- Extending the mode definition to include a suggested model to use with the mode (that would be nice though) - -# System Design - -![Organization Modes Library UI](/docs/img/organization-modes-library-1.png) - -![Organization Modes Library Editor](/docs/img/organization-modes-library-2.png) - -Currently extension fetches available modes from the "mode marketplace" by downloading a "modes.yaml" file from our backend. We will add an endpoint the extension can call with a user & org id and it can return any organization modes. Those will be merged into the mode list and dropdown shown to the user. - -The organization modes themselves will be saved in postgres, and there will be both a form style editing UI based on what's in the extension. - -Will add a new section to the backend UI to view custom org modes, edit them, create new ones, etc. - -Schema change: - -```sql -CREATE TABLE organization_modes ( - id uuid primary key, - organization_id uuid not null, - name text not null, - slug text not null, - created_by text not null, - created_at timestamptz default now(), - updated_at timestamptz default now(), - config jsonb -) -``` - -We're recommending using jsonb for the non _critical_ pieces of the modes so it's easier to keep in sync with the extension vs a schema we have to migrate (not everyone updates to the most recent extension immediately, for example) - -# Scope and implementation - -- Schema migration -- Make CRUD ui on backend, feature flagged out to only our organization to begin with. Estimate this is 1 day of work. -- Make endpoint to return org modes -- Render org modes in extension. Estimating 2 days for this because we are both unfamiliar with how to work on extension, and there be dragons there. - -# Compliance Considerations - -Should log any mode CRUD operations to audit logs for enterprise. Otherwise, none. - -## Open questions - -- Teams or enterprise? My vote is teams diff --git a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md deleted file mode 100644 index 834906dc84e..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: "Per-Message Feedback" -description: "Thumbs up/down feedback on assistant messages sent to Kilo via telemetry" ---- - -# Per-Message Feedback (Thumbs Up / Down) - -## Problem - -We have no signal on which assistant responses are helpful and which aren't. Without per-response feedback, we can't: - -- Correlate model or prompt changes to user-perceived quality -- Identify specific bad responses in the Kilo Gateway logs for investigation -- Detect patterns where certain providers, models, or prompt paths consistently underperform - -Aggregate metrics like session completion rate or token cost are too coarse to understand individual response quality. A lightweight thumbs-up/down on each message can help close the feedback loop. - -## Proposal - -Add a thumbs-up / thumbs-down widget next to the existing copy button on every assistant message. Ratings are sent to Kilo via the existing PostHog telemetry pipeline. The UI is hidden entirely when telemetry is disabled. - -### Scope - -| Surface | Approach | -|---|---| -| VS Code extension | Thumbs buttons inline next to the copy button | -| TUI | Keybinds (`=` / `-`) on the last assistant message | - -### Telemetry Payload - -We deliberately collect fewer identifiers for non-Kilo providers, since those IDs can't be correlated to upstream data and add tracking surface without product benefit. Users of non-Kilo GW models would also not expect or want us to collect that information in Kilo GW from other providers. - -**Third party providers (Anthropic, OpenAI, local, etc.):** -`providerID`, `modelID`, `variant?`, `rating`, `previousRating?` - -**Kilo Gateway turns (`providerID` starts with `"kilo"`):** -Same fields plus `sessionID`, `messageID`, and `parentMessageID` (= the `x-kilo-request` header the gateway already saw). This lets backend analysts join feedback against gateway logs to diagnose specific bad responses. - -Event name: `"Feedback Submitted"` — a single event string in both telemetry enum registries so PostHog sees one event regardless of source. - -### UX - -- **Toggleable**: click the same button again to clear, or click the opposite to switch. Each change fires a new event with `rating` and `previousRating`. -- **In-memory state**: ratings are keyed by message ID and held in the webview/TUI session. Persisting across reloads is deferred to a follow-up. -- **Gated on telemetry**: if the user has VS Code telemetry disabled, the buttons don't render at all. For the CLI when telemetry is off, the keybinds are no-ops. - -### Architecture (high level) - -``` -[webview button / TUI keybind] - → existing telemetry proxy or Telemetry.track() - → POST /telemetry/capture (webview path) - → Telemetry.track("Feedback Submitted", {…}) - → PostHog -``` - -No new server endpoints, no SDK regeneration, no PostHog-side changes. The `/telemetry/capture` route and both telemetry proxy paths already exist and accept arbitrary event names. - -### Kilo Gateway Detection - -The webview uses `providerID.startsWith("kilo")` to decide whether to include correlation IDs — this matches the outbound header gating in `packages/opencode/src/session/llm.ts`. The TUI can use the more precise `model.api.npm === "@kilocode/kilo-gateway"` check since it has access to the full provider resolution in-process. - -## What's Out of Scope - -- Free-text comments on thumbs-down -- 1–5 scale or star rating -- Persisting ratings across page reloads / session switches -- Changing prior-message actions (copy + thumbs) to hover-only -- Shared web UI / desktop surface - -## Open Questions - -- Should ratings persist on the `MessageV2.Assistant` schema so they survive reloads? -- Confirm with the PostHog dashboard owner that the proposed event + property names fit existing conventions. -- Whether to add free-text comments for thumbs-down in a follow-up. diff --git a/packages/kilo-docs/pages/contributing/architecture/track-repo-url.md b/packages/kilo-docs/pages/contributing/architecture/track-repo-url.md deleted file mode 100644 index e080af94e96..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/track-repo-url.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: "Track Repo URL" -description: "Track repository URL architecture" ---- - -# Track Usage by Project - -# Overview - -We will define a "project" as a **repository** and will be identified by `project.id`. We can automatically get the `project.id` from the git remote `origin` if it doesn't exist, but also introduce the concept of a `.kilocode/config.json` file which you can use to manually set (and override in the case of an `origin` remote existing) `project.id`. This allows for "automagic" configuration in most cases, but for an override and helps with things like monorepos which can contain multiple "projects." It also stands in for places where the code structure is less defined like using kilo-cli or running Kilo cloud agents on checked out pieces of code, etc. - -This will allow us to track which projects are used for every LLM call in the `microdollar_usage` table. We can then add this very easily to reporting to show how much of your costs are going to each "project" (identified by unique `project.id`). This feature is a prerequisite for "project based settings." - -## System Design - -![System Design](/docs/img/track-repo-url-system-design.png) - -### Example config - -```jsonc -{ - // Example configuration for project settings - "project": { - // Kilo Code project ID - "id": "my-project", - }, -} -``` - -## Implementation Plan - -- Modify extension to get the `project.id` by getting the `origin` url from the git remotes. -- Modify extension to support an optional `.kilocode/config.json` and add the addition of `project.id` to the config file there. -- Modify extension to send `project.id` in a header to our backend OpenRouter endpoint (maybe `X_KILOCODE_PROJECTID`) -- Add some kind of json-schema to this file for some auto-complete goodness. -- Modify **all** backend requests to include the `project.id` if it exists as an http header. -- Modify `microdollar_usage` and add the `project_id` column. -- Modify usage details to support grouping by `repo_url` and seeing "who worked on **what**, when, and how much did it cost." - -# Compliance Considerations - -I don't think it will hurt to save this, particularly since they can remove it by setting `project.id: ""` in `.kilocode/config.json`. diff --git a/packages/kilo-docs/pages/contributing/architecture/voice-transcription.md b/packages/kilo-docs/pages/contributing/architecture/voice-transcription.md deleted file mode 100644 index 7fcfd8d7d88..00000000000 --- a/packages/kilo-docs/pages/contributing/architecture/voice-transcription.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -title: "Voice Transcription" -description: "Voice transcription architecture" ---- - -# Voice Transcription - -# Overview - -Developers can code 3-5x faster by dictating rather than typing, yet Kilo Code currently has no voice input capability. This creates friction for users who want to quickly describe complex features or iterate on ideas hands-free. - -This spec proposes adding live voice transcription to the chat interface, replacing the send button with a microphone icon when the text box is empty. Users can speak naturally while seeing real-time transcription appear in the input field, dramatically improving coding velocity for voice-preferred workflows. - -The MVP will use OpenAI's Realtime API with FFmpeg-based audio streaming for low-latency transcription (~100ms). This mirrors the approach used by Cursor and Cline, proven to work well in VS Code environments. - -# Requirements - -- **Microphone Icon UI**: Add microphone icon button that allows starting/stopping the transcription -- **Live Transcription Display**: Show real-time transcription in the chat text box as user speaks -- **FFmpeg Audio Streaming**: Use FFmpeg to capture and stream audio to transcription API -- **Realtime API Integration**: Use OpenAI's Realtime API for near-instant transcription -- **Visual Recording Indicator**: Show clear UI state when recording is active (animated volume bars or similar) -- **Typing Stops Recording**: Any keyboard input immediately stops transcription and returns to normal mode -- **Cross-Platform FFmpeg Docs**: Provide installation instructions for Windows, macOS, and Linux -- **OpenAI Provider Required**: Feature only available when user has configured an OpenAI API key in their provider settings. (This uses the user's own OpenAI credits, not Kilo Code credits.) - -### Non-requirements - -- Custom glossary / file / workflow support (future enhancement) -- Real-time volume visualization (future enhancement) -- Alternative transcription providers beyond OpenAI (future) -- Kilo Code provider integration for voice transcription (future) -- **Usage cost tracking/display** (not in initial version, but should be added in a future version since costs are separate from Kilo Code credits) -- Server-side/backend transcription (future) -- FFmpeg automatic installation or bundling -- Voice commands or shortcuts beyond start/stop - -# System Design - -## Architecture Overview - -![Voice Transcription Architecture](/docs/img/voice-transcription-architecture.png) - -The system follows a straightforward streaming architecture where user voice input is captured by FFmpeg, streamed as PCM16 audio to OpenAI's Realtime API via WebSocket, and transcribed text is displayed live in the chat input box. Typing interrupts recording instantly. - -## Core Components - -### 1. Audio Capture Service - -- Spawn FFmpeg as child process from extension host -- Platform-specific audio input configuration: - - **macOS**: `avfoundation` - - **Windows**: `dshow` (DirectShow) - - **Linux**: `alsa` or `pulse` -- Stream PCM16 format at 24kHz mono (required by OpenAI) -- Handle permissions errors and FFmpeg availability checks - -### 2. WebSocket Connection - -- Direct WebSocket connection from extension to OpenAI Realtime API -- Secure API key storage in extension settings (existing provider system) -- Base64 encode audio chunks for transmission -- Handle connection lifecycle (connect, stream, disconnect) - -### 3. UI State Management - -- **Empty Input State**: Show microphone icon -- **Recording State**: Animate microphone, show "Recording..." indicator -- **Transcribing State**: Show live transcription with typing cursor -- **Manual Stop**: Typing any key stops recording and clears recording indicator -- **Error State**: Show clear error message if FFmpeg not found or permissions denied - -### 4. Cost Considerations - -- OpenAI Realtime API: **$0.60 per minute** -- **Cost is charged to user's OpenAI account**, not Kilo Code credits -- Display cost warning in settings or first-time use -- Consider adding usage tracking/warnings for high-volume users - -## FFmpeg Detection & Setup - -**Installation Check Flow**: - -1. On extension activation, verify FFmpeg is available via `ffmpeg -version` -2. If not found, show dismissible banner with installation instructions -3. Link to documentation with platform-specific guides -4. Gracefully disable voice feature if FFmpeg unavailable - -**Documentation Structure**: - -- `docs/user-guide/voice-transcription-setup.md` - - Prerequisites section - - Platform-specific installation - - Troubleshooting common issues - - Permissions setup (especially macOS) - -## Scope/Implementation - -### Phase 1: Core Infrastructure - -- Add FFmpeg detection on extension startup -- Create `AudioCaptureService` class with platform-specific FFmpeg spawning -- Implement WebSocket connection to OpenAI Realtime API -- Add basic error handling and cleanup - -### Phase 2: UI Integration - -- Add microphone icon component to chat input -- Implement state management for recording/transcribing modes -- Wire up transcription events to populate chat input box -- Add typing detection to stop recording -- Add visual recording indicator - -### Phase 3: Polish & Docs - -- Write cross-platform FFmpeg installation guide -- Add cost warning in settings UI -- Test on Windows, macOS, Linux -- Handle edge cases (permissions, no FFmpeg, API errors) -- Add analytics tracking for feature usage - -# Features for the future - -- **Custom Glossary**: Use OpenAI Whisper API's glossary parameter for code-specific terminology -- **Real-time Volume Indicator**: Show live audio input levels during recording -- **Chunked Whisper API Mode**: Add cheaper option ($0.06/min) for users who can tolerate 2-5s latency -- **Provider Flexibility**: Support alternative transcription providers (Deepgram, AssemblyAI) -- **Server-side Transcription**: Move transcription to backend for better security/control -- **Voice Commands**: Implement "stop recording," "send message," and other voice shortcuts -- **Automatic FFmpeg Installation**: Bundle or auto-install FFmpeg to reduce setup friction -- **Recording History**: Save voice recordings locally for debugging or replay -- **Multi-language Support**: Extend beyond English with language detection -- **Usage Cost Tracking**: Display voice transcription costs somewhere (since this would be separate from Kilo Code credits) diff --git a/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md b/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md new file mode 100644 index 00000000000..eff1bef6bb5 --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/vscode-extension.md @@ -0,0 +1,180 @@ +--- +title: "VS Code Extension Architecture" +description: "Architecture of the Kilo VS Code extension and Agent Manager" +--- + +# VS Code Extension Architecture + +The VS Code extension (`packages/kilo-vscode/`) is a client of [Kilo CLI runtime](/docs/contributing/architecture/cli-runtime). It bundles platform CLI binary, starts one shared editor-owned `kilo serve` server on demand, and drives that server through generated SDK HTTP calls plus global SSE. + +{% callout type="info" title="Scope" %} +This page covers extension-host ownership, webview routing, Agent Manager, local terminal paths, recovery, bundled resources, and build outputs. It is not full extension feature inventory. +{% /callout %} + +## Shared server ownership + +[CLI Runtime](/docs/contributing/architecture/cli-runtime) defines shared local-server authentication, directory routing, provider routing, persistence, and SSE contracts. This page starts at VS Code client boundary. + +Activation creates one `KiloConnectionService`. It owns one `ServerManager`, one active SDK client, and one SSE adapter. `ServerManager` owns child process lifecycle. This editor-owned child is separate from detached local daemon managed by `kilo daemon`. + +```mermaid +flowchart LR + subgraph host ["VS Code extension host"] + consumers["Sidebar, tabs, panels, services"] + service["KiloConnectionService"] + manager["ServerManager"] + sdk["Generated SDK client"] + sse["SdkSSEAdapter"] + end + + server["bin/kilo serve --port 0"] + runtime["Kilo CLI runtime"] + + consumers --> service + service --> manager --> server + service --> sdk --> server + service --> sse -->|/global/event| server + server --> runtime +``` + +| Area | Behavior | +|---|---| +| Startup | Lazy on client demand; autocomplete prewarm can start server during activation | +| Binary | Uses extension `bin/kilo`, or `bin/kilo.exe` on Windows | +| Port | Starts `kilo serve --port 0`; CLI server prefers `4096`, then asks OS for free port | +| Authentication | Generates random 32-byte hex password per spawn and passes it as `KILO_SERVER_PASSWORD`; username defaults to `kilo` | +| Reuse | Sidebar, editor tabs, panels, Agent Manager, and host services share active server | +| Exit | `ServerManager` clears dead child; connection service clears SDK/SSE state and enters error state | +| Replacement | Later retry or connection attempt starts replacement server | + +## Shared consumers + +Shared service has more consumers than chat tabs: + +| Family | Consumers | +|---|---| +| Chat | Sidebar provider and editor-tab providers | +| Panels | Settings, profile and marketplace surfaces, sub-agent viewers, Agent Manager, KiloClaw | +| Diff | Diff Viewer, Diff Virtual, and diff source catalog | +| Editor assistance | Autocomplete and commit-message generation | +| Integrations | Browser automation MCP registration and KiloClaw bootstrap | + +New mutable state must account for concurrent consumers and multiple directory contexts on one process. + +## Webview bridge + +Main chat webviews use host-mediated message bridge: + +```text +webview vscode.postMessage() + -> KiloProvider host handler + -> generated SDK HTTP request + -> CLI runtime + -> /global/event SSE + -> SdkSSEAdapter + -> KiloConnectionService subscribers + -> KiloProvider directory/session filtering and stream coalescing + -> webview postMessage() +``` + +Global SSE carries wrapped events for multiple directories. Connection service broadcasts incoming payload plus directory to subscribers. Providers resolve session scope, maintain message-to-session lookup where events omit direct session ID, filter for relevant views, and coalesce high-frequency stream updates before posting UI messages. + +## Agent Manager + +Agent Manager is extension feature, not separate product. It opens as editor tab and manages parallel sessions, optional worktrees, terminals, diffs, setup scripts, and extra editor windows. + +| Aspect | Sidebar | Agent Manager | +|---|---|---| +| Primary use | One active chat view | Multi-session orchestration | +| Git isolation | Workspace root by default | Optional worktree per session | +| Backend | Shared `kilo serve` process | Same shared process | +| Request routing | Workspace directory | Session worktree path passed as SDK `directory` | +| CLI instance key | Normalized workspace root | Normalized worktree directory | + +Agent Manager request path is: + +```text +session worktree path -> SDK directory -> CLI directory-routing middleware -> InstanceStore directory key +``` + +Agent Manager persists state in `.kilo/agent-manager.json` and worktrees under `.kilo/worktrees/`. Startup migration moves Agent Manager-owned data from legacy `.kilocode/` paths when target items do not already exist and repairs git worktree refs. + +## State boundaries + +Directory-keyed CLI state is isolated by worktree path. Process-owned state remains shared because all Agent Manager sessions use one CLI process. Snapshot implementation state is directory-keyed, but slow-snapshot prompt guard belongs to shared `Snapshot.Service` scope. Managed Agent Manager prompts pass `snapshotInitialization: "wait"` so slow baseline setup waits without interrupting concurrently started sessions. + +## Terminal surfaces + +VS Code extension has two terminal paths: + +| Surface | Owner | Use | +|---|---|---| +| VS Code integrated terminal | VS Code host | Shell terminals and setup-script execution surfaced through editor | +| CLI PTY WebSocket tab | Agent Manager and `kilo serve` server | Server-created PTY session streamed over loopback WebSocket | + +Agent Manager PTY WebSocket URL uses `auth_token=` query mode because browser WebSocket API cannot attach Basic header. Webview CSP permits loopback HTTP and WebSocket origins for active server port. CLI also exposes scope-bound short-lived PTY ticket API as alternate browser WebSocket auth mode. + +## Config split + +| Config owner | Examples | +|---|---| +| VS Code settings | `kilo-code.new.*` extension UI, proxy, autocomplete, and integration settings | +| CLI config | Global and project `kilo.jsonc`, `kilo.json`, compatible OpenCode files, provider auth, tools, permissions, modes | + +Extension-specific behavior belongs in VS Code settings. Agent runtime behavior belongs in CLI config so TUI, Console, VS Code, and JetBrains can share it. + +## Bundled resources + +| Resource | Behavior | +|---|---| +| CLI executable | Platform binary under extension `bin/`; Windows uses `kilo.exe` | +| CLI Tree-sitter WASM | Copied under `bin/tree-sitter`; backend spawn sets `KILO_TREE_SITTER_WASM_DIR` | +| FFmpeg helper | Bundled for supported targets for speech capture; capture code also checks system fallback paths | +| Empty-window cwd | Uses extension global storage directory when no VS Code workspace folder exists | +| Empty-window indexing | Sets `KILO_DISABLE_CODEBASE_INDEXING=vscode-no-workspace` so CLI reports indexing disabled | + +Speech-to-text captures audio locally, then sends completed recording through shared editor-owned `kilo serve` server to authenticated Kilo Gateway transcription path. It is batch transcription, not direct provider streaming. + +## Recovery + +| Failure signal | Response | +|---|---| +| Missing SSE events for 15 seconds | SSE adapter aborts attempt and reconnects | +| SSE reconnect | Starts at 250 ms delay and backs off to 5 seconds until stream opens | +| Health poll | Every 10 seconds, checks `/global/health` with 3 second timeout; failure forces SSE reconnect | +| Server exit | Clears connection state, reports error, and lets later retry or connection attempt spawn replacement | +| Extension disposal | Stops polls, disposes SSE, and sends server process group termination with kill fallback | + +## Builds + +| Build | Source | Output | +|---|---|---| +| Extension host | `src/extension.ts` | `dist/extension.js` | +| Sidebar and editor chat webview | `webview-ui/src/index.tsx` | `dist/webview.js` | +| Agent Manager webview | `webview-ui/agent-manager/index.tsx` | `dist/agent-manager.js` | +| KiloClaw webview | `webview-ui/kiloclaw/index.tsx` | `dist/kiloclaw.js` | +| Diff Viewer webview | `webview-ui/diff-viewer/index.tsx` | `dist/diff-viewer.js` | +| Diff Virtual webview | `webview-ui/diff-virtual/index.tsx` | `dist/diff-virtual.js` | +| Shared Shiki worker | synthetic worker entry | `dist/shiki-worker.js` | + +Extension host bundle targets Node/CommonJS. Browser webviews and shared worker use esbuild browser bundles. Run `bun run typecheck`, `bun run lint`, and targeted unit tests from `packages/kilo-vscode/` after changing this area. + +## Source map + +Paths below are relative to [`Kilo-Org/kilocode`](https://github.com/Kilo-Org/kilocode). + +| Concern | Source path | +|---|---| +| Activation | `packages/kilo-vscode/src/extension.ts` | +| Editor-owned server child process | `packages/kilo-vscode/src/services/cli-backend/server-manager.ts` | +| Shared SDK and SSE ownership | `packages/kilo-vscode/src/services/cli-backend/connection-service.ts` | +| SSE reconnect adapter | `packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts` | +| Agent Manager | `packages/kilo-vscode/src/agent-manager/` | +| Build entries | `packages/kilo-vscode/esbuild.js` | + +## Related pages + +- [Architecture Overview](/docs/contributing/architecture) - local and hosted execution map +- [CLI Runtime](/docs/contributing/architecture/cli-runtime) - shared local-server, routing, persistence, and SSE behavior +- [JetBrains Plugin](/docs/contributing/architecture/jetbrains-plugin) - corresponding editor-client architecture for JetBrains +- [Development Patterns](/docs/contributing/architecture/development-patterns) - choose code-ownership seam and validation workflow before editing extension contracts diff --git a/packages/kilo-docs/pages/contributing/development-environment.md b/packages/kilo-docs/pages/contributing/development-environment.md index 477b4a5e4ee..d592e2a8285 100644 --- a/packages/kilo-docs/pages/contributing/development-environment.md +++ b/packages/kilo-docs/pages/contributing/development-environment.md @@ -16,8 +16,9 @@ This document will help you set up your development environment and understand h Before you begin, make sure you have the following installed: 1. **Git** - For version control -2. **Node.js** (version v20.18.1 (See `.nvmrc` for latest) or higher recommended) and npm +2. **Bun 1.3.14+** - Required for installing dependencies and running scripts 3. **Visual Studio Code** - Our recommended IDE for development +4. **Java 21** - Required only when running JetBrains plugin checks or repo-level checks that include `@kilocode/kilo-jetbrains` ## Getting Started @@ -37,10 +38,10 @@ Before you begin, make sure you have the following installed: 1. **Install dependencies**: ```bash - pnpm install + bun install ``` - This command will install dependencies for the main extension, webview UI, and e2e tests. + This command will install dependencies for all workspace packages. 1. **Install VSCode Extensions**: - **Required**: [ESBuild Problem Matchers](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) - Helps display build errors correctly. @@ -52,136 +53,164 @@ While not strictly necessary for running the extension, these extensions are rec The full list of recommended extensions is in `.vscode/extensions.json` +### Using AI and Coding Agents + +AI and coding agents are allowed in this repo. If you use one, start it from the repository root so the root `AGENTS.md` is available, then check package-specific guidance when your change touches a package with its own `AGENTS.md` or contributor docs. + +You remain responsible for the submitted work. Before opening a PR, personally review the diff, test the change, make sure you can explain it, and understand how it interacts with the affected package and the rest of the repo. Do not use agents to submit batches of agent-generated, untested, or weakly reviewed PRs. Keep concurrent PRs limited, generally no more than three at a time, and prioritize high-impact issues first. Do not use automation or agents to mass-create issues without human review and prioritization. + +Kilo has bug bounties. To be eligible, make sure your GitHub account is connected in your Kilo account. + ### Project Structure -The project is organized into several key directories: +The project is organized into several key packages: -- **`src/`** - Core extension code - - **`core/`** - Core functionality and tools - - **`services/`** - Service implementations -- **`webview-ui/`** - Frontend UI code -- **`e2e/`** - End-to-end tests -- **`scripts/`** - Utility scripts -- **`assets/`** - Static assets like images and icons +- **`packages/opencode/`** - Kilo CLI, agent runtime, local HTTP server, session management, and TUI +- **`packages/kilo-vscode/`** - VS Code extension, webview UI, Agent Manager, and extension packaging +- **`packages/sdk/js/`** - Generated TypeScript SDK for the local server API +- **`packages/kilo-docs/`** - Documentation site +- **`packages/kilo-jetbrains/`** - JetBrains plugin ## Development Workflow -### Building the Extension +### Running the CLI -To build the extension: +To run the CLI from the repo root: ```bash -pnpm build +bun dev ``` -This will: +`bun dev` and `bun run dev` are equivalent. Both run the local source in `packages/opencode/`; they do not use a globally installed `kilo` binary. -1. Build the webview UI -2. Compile TypeScript -3. Bundle the extension -4. Create a `.vsix` file in the `bin/` directory +### Backend/API Validation -### Running the Extension +For backend and API validation, use the root [TESTING.md](https://github.com/Kilo-Org/kilocode/blob/main/TESTING.md) guide. It covers starting the local backend with: -To run the extension in development mode: +```bash +bun dev serve +``` -1. Press `F5` (or select **Run** → **Start Debugging**) in VSCode -2. This will open a new VSCode window with Kilo Code loaded +and validating behavior with `curl` requests against the local server. -### Hot Reloading +If you change server endpoints in `packages/opencode/src/server/`, regenerate the SDK from the repo root: -- **Webview UI changes**: Changes to the webview UI will appear immediately without restarting -- **Core extension changes**: Changes to the core extension code will automatically reload the ext host +```bash +./script/generate.ts +``` -In development mode (NODE_ENV="development"), changing the core code will trigger a `workbench.action.reloadWindow` command, so it is no longer necessary to manually start/stop the debugger and tasks. +### Running the Extension -> **Important**: In production builds, when making changes to the core extension, you need to: -> -> 1. Stop the debugging process -> 2. Kill any npm tasks running in the background (see screenshot below) -> 3. Start debugging again +To run the extension in development mode: -{% image src="https://github.com/user-attachments/assets/466fb76e-664d-4066-a3f2-0df4d57dd9a4" alt="Stopping background tasks" width="600" /%} +```bash +bun run extension +``` + +This will build and launch the extension in an isolated VS Code instance. -### Installing the Built Extension +### Building the Extension -To install your built extension: +From `packages/kilo-vscode/`: ```bash -code --install-extension "$(ls -1v bin/kilo-code-*.vsix | tail -n1)" +bun run compile +bun run package ``` -Replace `[version]` with the current version number. +Use `bun run compile` when you need a development build and `bun run package` when you need a production extension bundle. ## Testing Kilo Code uses several types of tests to ensure quality: -### Unit Tests +### Repo-Level Checks -Run unit tests with: +From the repo root: ```bash -npm test +bun install +bun run lint +bun run typecheck ``` -This runs both extension and webview tests. +`bun run typecheck` wraps `bun turbo typecheck`. Use `bun turbo typecheck --force` if you need to bypass the Turbo cache. -To run specific test suites: +Do **not** run `bun test` from the repo root. The root test script intentionally exits with failure to prevent accidentally running tests from the wrong package. -```bash -npm run test:extension # Run only extension tests -npm run test:webview # Run only webview tests -``` +### CLI Checks -### End-to-End Tests +From `packages/opencode/`: -E2E tests verify the extension works correctly within VSCode: +```bash +bun run typecheck +bun test +bun test ./path/to/file.test.ts +``` -1. Create a `.env.local` file in the root with required API keys: +Use the root [TESTING.md](https://github.com/Kilo-Org/kilocode/blob/main/TESTING.md) guide for backend/API checks that require `bun dev serve` and `curl`-based requests. - ``` - OPENROUTER_API_KEY=sk-or-v1-... - ``` +### VS Code Extension Checks -2. Run the integration tests: - ```bash - npm run test:integration - ``` +From `packages/kilo-vscode/`: -For more details on E2E tests, see e2e/VSCODE_INTEGRATION_TESTS +```bash +bun run typecheck +bun run lint +bun run test:unit +bun run test +bun run compile +bun run package +``` -## Linting and Type Checking +### Documentation Checks -Ensure your code meets our quality standards: +From the repo root: ```bash -npm run lint # Run ESLint -npm run check-types # Run TypeScript type checking +bun run --filter @kilocode/kilo-docs test +bun run --filter @kilocode/kilo-docs build +bun run --filter @kilocode/kilo-docs dev ``` -## Git Hooks +For manual documentation validation, run the docs site locally, preview the affected page, and check the changed links and rendered content. + +### Testing Evidence for Pull Requests + +Every PR marked ready for review must include testing evidence. A bare `Not tested` or `N/A` answer is not sufficient. + +Choose checks that match the files touched. Docs-only, config-only, and similar changes may satisfy this rule with concrete manual verification or a relevant command check. -This project uses [Husky](https://typicode.github.io/husky/) to manage Git hooks, which automate certain checks before commits and pushes. The hooks are located in the `.husky/` directory. +For CLI and extension changes, useful evidence can include: -### Pre-commit Hook +- The relevant command checks from the package you changed +- Manual/local verification of the changed behavior in the CLI or extension +- Screenshots or videos for visual changes, such as a settings page update or changed CLI/extension behavior -Before a commit is finalized, the `.husky/pre-commit` hook runs: +For docs changes, useful evidence can include: -1. **Branch Check**: Prevents committing directly to the `main` branch. -2. **Type Generation**: Runs `npm run generate-types`. -3. **Type File Check**: Ensures that any changes made to `src/exports/roo-code.d.ts` by the type generation are staged. -4. **Linting**: Runs `lint-staged` to lint and format staged files. +- `bun run script/check-md-table-padding.ts --fix` +- `bun run --filter @kilocode/kilo-docs test` +- Previewing the changed docs page locally, as described in [Documentation Contributions](/docs/contributing#documentation-contributions) -### Pre-push Hook +If you cannot complete a relevant command, include all of the following in the PR: -Before changes are pushed to the remote repository, the `.husky/pre-push` hook runs: +- The command you attempted or would normally run +- The blocker or failure that prevented completion +- The substitute verification you performed instead -1. **Branch Check**: Prevents pushing directly to the `main` branch. -2. **Compilation**: Runs `npm run compile` to ensure the project builds successfully. -3. **Changeset Check**: Checks if a changeset file exists in `.changeset/` and reminds you to create one using `npm run changeset` if necessary. +Agent limitations, local resource constraints, OOM constraints, or an agent prompt that says to skip tests do not waive this requirement. Draft PRs may be incomplete until they are marked ready for review. Maintainers may still defer or close review at their discretion. + +## Guardrails + +- User-facing changes usually need a changeset. Run `bunx changeset add` or add a file under `.changeset/`. +- After changing server endpoints, run `./script/generate.ts` from the repo root to regenerate `packages/sdk/js/`. +- After adding or changing guarded URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root. +- When editing shared `packages/opencode/` files, keep changes small and mark Kilo-only edits with `// kilocode_change` for a single line or `// kilocode_change start` / `// kilocode_change end` for a block. Do not add these markers inside `kilocode`-named paths. + +## Git Hooks -These hooks help maintain code quality and consistency. If you encounter issues with commits or pushes, check the output from these hooks for error messages. +This project uses [Husky](https://typicode.github.io/husky/) to manage Git hooks. The current pre-push hook checks the Bun version against root `package.json` and runs the repo-level typecheck. ## Troubleshooting @@ -189,7 +218,8 @@ These hooks help maintain code quality and consistency. If you encounter issues 1. **Extension not loading**: Check the VSCode Developer Tools (Help > Toggle Developer Tools) for errors 2. **Webview not updating**: Try reloading the window (Developer: Reload Window) -3. **Build errors**: Make sure all dependencies are installed with `npm run install:all` +3. **Build errors**: Make sure all dependencies are installed with `bun install` +4. **Root tests fail immediately**: This is expected. Run package-level tests instead of root `bun test` ### Debugging Tips diff --git a/packages/kilo-docs/pages/contributing/features/agent-observability.md b/packages/kilo-docs/pages/contributing/features/agent-observability.md new file mode 100644 index 00000000000..4b1a347cedd --- /dev/null +++ b/packages/kilo-docs/pages/contributing/features/agent-observability.md @@ -0,0 +1,94 @@ +--- +title: "Agent Observability" +description: "Current observability capabilities and roadmap for agentic coding systems" +--- + +# Agent Observability + +{% callout type="info" title="Status" %} +Partial - API metrics, session ingestion, storage, and burn-rate alert infrastructure exist. Higher-order agent behavior and outcome analysis remain roadmap work. +{% /callout %} + +## Overview + +Agentic coding systems combine model requests, tool execution, file changes, and external API calls. Traditional request metrics catch hard failures. Agent behavior signals are also needed to investigate loops, degraded sessions, and poor outcomes. + +Current cloud service context is documented in [Cloud Platform observability](/docs/contributing/architecture/cloud-platform#observability). + +## Current implementation + +| Capability | Status | Notes | +|---|---|---| +| API metrics ingestion | Current | Operational request metrics ingestion exists | +| Session metrics ingestion | Current | Session-level ingestion exists | +| Burn-rate alert evaluation | Current | Alert evaluation runs against stored metrics | +| Alert config storage | Current | Alert configuration storage exists | +| Analytics Engine storage | Current | API and session metrics datasets exist | +| Export pipelines | Current infrastructure | Metrics export infrastructure exists for downstream analysis | +| Per-message feedback | Current | Explicit user feedback signal exists | + +## Roadmap + +| Capability | Status | Goal | +|---|---|---| +| Oscillation detection | Planned or partial | Detect repeated or alternating agent actions | +| Unique-file progress metrics | Planned or partial | Track files touched during session | +| Unique-tool progress metrics | Planned or partial | Track tool diversity and repeated operations | +| Session termination classification | Planned | Distinguish completion, abandonment, timeout, and errors | +| Higher-order outcome analysis | Planned | Assess usefulness and task success beyond hard errors | + +## Operational metrics roadmap + +Use existing ingestion and alert infrastructure as base for dashboards and service-level objectives. Metric coverage should be validated before treating any field as available in production analysis. + +### API metrics + +Candidate dimensions for model requests: + +- Provider +- Model +- Tool +- Latency +- Success or failure +- Error type +- Token counts +- Client source + +### Session metrics + +Candidate session aggregates: + +- Session duration +- Time to first model response +- Turns and tool calls +- Errors by type +- Tokens consumed +- Context compaction frequency +- Termination reason + +### Alert policy + +Burn-rate evaluation infrastructure exists. Proposed alert routing should page only for recommended models using Kilo Gateway; other conditions can create tickets or remain disabled. + +| Window | Burn rate | Proposed action | +|---|---|---| +| 5 min | 14.4x | Page for major outage | +| 30 min | 6x | Page for incident | +| 6 hr | 1x | Create ticket for behavior change | + +## Agent behavior roadmap + +Initial behavior analysis should focus on repeated operations and progress signals: + +| Signal | Purpose | +|---|---| +| Identical tool calls | Detect repeated actions with same tool and arguments | +| Identical failing calls | Detect retries that repeat same failure | +| Oscillation patterns | Detect alternating states without progress | +| Unique files touched | Estimate breadth of session changes | +| Unique tools used | Compare progress against repeated operations | +| Repeated-to-unique ratio | Identify sessions that may be stuck | + +## Outcome roadmap + +Hard errors and behavior metrics do not prove user success. Later work can combine explicit per-message feedback with session termination analysis and other outcome signals. Offline model and agent comparison belongs in [Benchmarking](/docs/contributing/features/benchmarking). diff --git a/packages/kilo-docs/pages/contributing/features/benchmarking.md b/packages/kilo-docs/pages/contributing/features/benchmarking.md new file mode 100644 index 00000000000..61f8b7480aa --- /dev/null +++ b/packages/kilo-docs/pages/contributing/features/benchmarking.md @@ -0,0 +1,110 @@ +--- +title: "Benchmarking" +description: "Current evaluation evidence and roadmap for benchmarking Kilo Code" +--- + +# Benchmarking + +{% callout type="info" title="Status" %} +Partial - inspected repositories show a Harbor-facing smoke-eval workflow and cloud `model-eval-ingest` promotion sync. Broader Harbor adapters, ATIF traces, Opik workflows, and commands remain unverified roadmap items. +{% /callout %} + +## Overview + +Benchmarking should answer two questions: + +1. How do models compare when used by same Kilo Code agent? +2. How do agents or Kilo Code versions compare when used with same model? + +This page separates inspected repository evidence from roadmap. It does not guarantee private benchmark tooling, external adapters, or example commands are available to contributors. + +{% callout type="info" %} +Benchmarking is separate from [production observability](/docs/contributing/features/agent-observability). Observability monitors real sessions. Benchmarking runs controlled evaluation tasks. +{% /callout %} + +## Current evidence + +| Capability | Status | Evidence and limits | +|---|---|---| +| Harbor-facing smoke eval | Current workflow | `.github/workflows/smoke-test.yml` checks out private `Kilo-Org/kilo-bench`, installs dependencies, and runs two smoke tasks through repository scripts | +| CLI release smoke coverage | Current workflow | Workflow can test latest npm CLI or requested release asset before validating results | +| Smoke result artifacts | Current workflow | Workflow uploads result, trajectory, and agent setup files for inspection | +| Cloud model eval ingest | Current service | Static source inspection found `services/model-eval-ingest/` promotion sync surface | +| Private `kilo-bench` internals | Not verified here | Private repository scripts, adapter behavior, and supported local commands are outside inspected docs scope | +| Live production enablement | Not verified here | Static source does not prove deployment, rollout, retention, or vendor configuration | + +## Roadmap + +| Capability | Status | Intended use | +|---|---|---| +| Contributor-facing Harbor adapter | Unverified roadmap | Run Kilo CLI autonomously in controlled evaluation environments | +| ATIF trajectory adapter | Unverified roadmap | Emit structured step-level traces for comparison | +| Opik integration | Unverified roadmap | Ingest traces and compare evaluation runs | +| Standard model comparison workflow | Planned | Compare quality, cost, and wall-clock time across models | +| Standard agent comparison workflow | Planned | Compare agents or Kilo Code versions on same tasks | +| Custom task-set template | Planned | Build focused regression or capability suites | +| CI regression suite beyond smoke eval | Planned | Run stable subset before release | + +## Inspected smoke-eval workflow + +Current repository workflow runs small smoke evaluation after checking out private benchmark repository. It uses private repository script `./scripts/run_eval.sh`, validates output with `scripts/validate_smoke_test.py`, and uploads selected artifacts. + +| Task | Dataset selection | Expected scope recorded in workflow | +|---|---|---| +| `hello-world` | `hello-world` | Small smoke task | +| `log-summary-date-ranges` | `terminal-bench-sample` with included task name | Small terminal benchmark sample | + +This evidence shows smoke coverage exists. It does not establish public Harbor adapter contract or contributor-ready local CLI. + +## Cloud model-eval-ingest evidence + +Static source inspection found cloud `model-eval-ingest` service for promotion sync. Treat this as current repository-defined surface only. Validate deployed environment and operational behavior separately before making production claims. + +## Proposed evaluation design + +Broader design can use open-source evaluation components if adapter availability is verified during implementation. + +| Component | Roadmap role | Verification needed | +|---|---|---| +| [Harbor](https://harborframework.com) | Evaluation harness and datasets | Confirm supported Kilo adapter and invocation contract | +| [ATIF](https://harborframework.com/docs/agents/trajectory-format) | Structured trajectories | Confirm emitted fields and reasoning-data policy | +| [Opik](https://www.comet.com/docs/opik) | Trace ingestion and analysis | Confirm Harbor integration setup and Kilo adapter support | +| Terminal-Bench or other datasets | Controlled tasks | Confirm versions, licensing, and task selection | + +Potential architecture: + +```text +Evaluation task set + -> controlled trial environment + -> verified Kilo adapter + -> model request + -> result and optional trajectory artifacts + -> smoke validation, aggregate analysis, or trace analysis +``` + +## Proposed comparison dimensions + +| Comparison | Fixed input | Variable | Measures | +|---|---|---|---| +| Model comparison | Kilo Code agent and task set | Model | Completion, cost, and wall-clock time | +| Agent comparison | Model and task set | Agent or Kilo Code version | Completion, cost, and wall-clock time | +| Trace analysis | Evaluation task | Run trajectory | Tool choices, errors, and repeated steps | + +## Command verification requirement + +Do not document `opik harbor run -a kilo`, `kilo --auto`, or `kilo run --auto` as ready-to-run interfaces until adapter and autonomous CLI invocation are verified in relevant repository. Private `kilo-bench` workflow commands are implementation evidence, not public usage guarantees. + +## Future deliverables + +- Verify and document supported autonomous CLI invocation +- Verify Harbor adapter ownership and availability +- Define ATIF export fields and data-handling policy +- Validate Opik ingestion path before publishing commands +- Publish contributor workflow only after local reproduction succeeds +- Expand smoke coverage into stable regression subset where cost and runtime allow + +## References + +- [Harbor Framework Documentation](https://harborframework.com/docs) +- [ATIF Specification](https://github.com/laude-institute/harbor/blob/main/docs/rfcs/0001-trajectory-format.md) +- [Opik Harbor Integration](https://www.comet.com/docs/opik/integrations/harbor) diff --git a/packages/kilo-docs/pages/contributing/features/enterprise-mcp-controls.md b/packages/kilo-docs/pages/contributing/features/enterprise-mcp-controls.md new file mode 100644 index 00000000000..c645e46377a --- /dev/null +++ b/packages/kilo-docs/pages/contributing/features/enterprise-mcp-controls.md @@ -0,0 +1,108 @@ +--- +title: "Enterprise MCP Controls" +description: "Proposal for organization-managed MCP controls" +--- + +# Enterprise MCP Controls + +{% callout type="info" title="Status" %} +Proposal - no matching organization MCP allowlist implementation exists yet. Schema, endpoints, dashboard flows, and client enforcement described here are tentative. +{% /callout %} + +## Overview + +Developers can configure MCP (Model Context Protocol) servers, including marketplace servers and custom servers. Enterprise customers may need organization policy for which MCP servers their developers can use. + +This proposal adds an organization-managed allowlist of approved marketplace MCP servers and dashboard-managed member configuration. It is a design document, not current architecture. + +## MVP requirements + +### Dashboard app + +- Give organization administrators a dashboard section for MCP policy. +- Show marketplace MCP servers and let administrators select approved entries. +- Default policy to disabled. If policy is enabled, start with marketplace MCP servers selected to avoid unexpected disruption. +- Record allowlist changes in audit logs. +- Let organization members configure approved servers in dashboard. + +### Client behavior + +- Keep existing local MCP behavior when organization policy is disabled. +- When organization policy is enabled, replace local MCP configuration with dashboard-managed configuration scoped to organization and member. +- Do not activate or use disallowed local MCP entries. +- If client still detects disallowed local entries while policy is enabled, it may show non-blocking policy feedback. Those entries do not need to appear as activatable MCP options. +- Replace extension marketplace configuration UI with link to dashboard while organization policy is enabled. + +This resolves two distinct cases: local entries rejected by policy need not be activated, while dashboard-managed configuration replacement is proposed behavior only when policy is enabled. + +## System design + +### Current MCP configuration + +{% image src="/docs/img/enterprise-mcp-controls-today.png" alt="Current MCP configuration flow" /%} + +### Proposed policy-enabled configuration + +{% image src="/docs/img/enterprise-mcp-controls-with-ent-control.png" alt="Proposed enterprise MCP controls flow" /%} + +When organization policy is enabled, client pulls dashboard-managed configuration instead of using end-user filesystem definitions. Policy-disabled organizations keep existing local behavior. + +## Tentative schema + +{% callout type="warning" title="Tentative design" %} +Following schema has not shipped. Names, storage layout, encryption approach, and API shape may change during implementation review. +{% /callout %} + +Organization settings could hold allowlist policy: + +```ts +const OrganizationSettings_MCPControls = z.object({ + mcp_controls_enabled: z.boolean().optional(), + mcp_controls_allowed_marketplace_servers: z.string().optional(), +}) +``` + +Dashboard-managed member configuration may require encrypted storage: + +```sql +create table if not exists organization_member_mcp_configs ( + id uuid not null default uuid_generate_v4(), + organization_id uuid not null references organizations(id), + kilo_user_id text not null references kilocode_users(id), + config bytea not null, + created_at timestamptz not null default now() +) +``` + +Payload shape could start with: + +```ts +const OrganizationMemberMCPConfig = z + .object({ mcp_id: z.string(), parameters: z.record(z.string(), z.string()) }) + .array() +``` + +## Tentative dashboard and API surface + +| Surface | Proposed behavior | +|---|---| +| `/organizations/:id/mcp-control` | Let owners manage allowlist and members configure approved MCP servers | +| `GET /api/marketplace/mcps` | Retrieve marketplace MCP list for policy UI | +| Organization settings API | Read and update enabled state and allowlist | +| Member MCP config API | Store encrypted approved MCP configuration | + +These routes and endpoints are placeholders for implementation design. They are not documented as available APIs. + +## Scope and implementation plan + +| Area | Proposed work | +|---|---| +| Backend | Add policy schema, encrypted member config storage, audit logging, and organization/member APIs | +| Dashboard | Add administrator allowlist UI and member configuration UI | +| Client | Fetch policy-enabled configuration, ignore disallowed local entries, and link to dashboard configuration | + +## Future work + +- Organization-provided custom MCP server configurations outside marketplace +- Project-level MCP configurations +- Tool-call audit reports grouped by user, project, and MCP server diff --git a/packages/kilo-docs/pages/contributing/features/index.md b/packages/kilo-docs/pages/contributing/features/index.md new file mode 100644 index 00000000000..4336da762ea --- /dev/null +++ b/packages/kilo-docs/pages/contributing/features/index.md @@ -0,0 +1,17 @@ +--- +title: "Feature Proposals" +description: "Design proposals and roadmaps for Kilo Code features" +--- + +# Feature Proposals + +These pages contain design proposals and roadmaps for features under consideration or implementation. They are planning documents, not current-state architecture references. Each page records its implementation status near the top. + +| Feature | Status | Description | +|---|---|---| +| [Enterprise MCP Controls](/docs/contributing/features/enterprise-mcp-controls) | Proposal | Organization policy controls for MCP server configuration | +| [Onboarding Improvements](/docs/contributing/features/onboarding-improvements) | Partial | Welcome-screen work and proposed onboarding improvements | +| [Agent Observability](/docs/contributing/features/agent-observability) | Partial | Current operational metrics and planned agent-quality signals | +| [Benchmarking](/docs/contributing/features/benchmarking) | Partial | Current smoke-eval evidence and broader evaluation roadmap | + +Use the [proposal template](/docs/contributing/features/template) for new feature designs. diff --git a/packages/kilo-docs/pages/contributing/features/onboarding-improvements.md b/packages/kilo-docs/pages/contributing/features/onboarding-improvements.md new file mode 100644 index 00000000000..2c8b40f14b9 --- /dev/null +++ b/packages/kilo-docs/pages/contributing/features/onboarding-improvements.md @@ -0,0 +1,72 @@ +--- +title: "Onboarding Improvements" +description: "Partial roadmap for onboarding and engagement improvements" +--- + +# Onboarding Improvements + +{% callout type="info" title="Status" %} +Partial - welcome-screen work exists. Starter cards, interactive tutorial, changelog, provider-settings changes, and funnel events below remain roadmap items unless marked current. +{% /callout %} + +## Overview + +New users need a clearer first-run path and better discovery of product features. This roadmap separates current welcome-screen work from proposed onboarding changes. + +## Current implementation + +| Capability | Status | Notes | +|---|---|---| +| Welcome screen | Current | Existing first-run surface provides starting context for new users | + +## Roadmap requirements + +| Capability | Status | Proposed behavior | +|---|---|---| +| Starter prompt cards | Planned | Replace generic prompt with contextual actions and codicon visuals | +| Interactive tutorial | Planned | Guide users through current UI controls and chat input | +| Tutorial completion state | Planned | Avoid showing completed or skipped tutorial repeatedly | +| In-product changelog | Planned | Surface relevant product changes to returning users | +| Kilo provider settings layout | Planned | Put provider setup action beside relevant field and improve discoverability | +| Onboarding analytics | Planned | Track onboarding progress and later product engagement | + +## Proposed welcome-screen extension + +Add starter prompt cards to welcome screen. Each card should populate chat input when selected and use VS Code codicons rather than emoji. + +| Card | Prompt | +|---|---| +| Debug helper | Help me fix a bug in my code | +| Feature builder | Add a new feature to my project | +| Documentation | Generate documentation for this file | +| Code review | Review my current changes by running `git diff` and analyzing output | + +## Proposed tutorial flow + +Earlier design notes referred to Chat, Edit, and Architect modes. Treat those names as historical examples, not current UI requirements. Implementation should target controls available when tutorial is built. + +| Step | Focus | Content | +|---|---|---| +| Welcome | Interface | Explain purpose of short tour | +| Agent or mode selection | Current selector UI | Explain available task behaviors | +| Side panels and MCP | Sidebar | Point to history and MCP configuration | +| Starting chat | Input area | Explain prompts and file references | +| Starter prompts | Welcome actions | Show common first tasks | + +## Proposed analytics + +Events remain roadmap items. Final names and payloads require telemetry review before implementation. + +| Funnel | Candidate events | +|---|---| +| Onboarding | `onboarding.started`, `onboarding.tutorial.completed`, `onboarding.tutorial.skipped`, `onboarding.prompt.selected`, `onboarding.finished` | +| Engagement | `chat.started`, `mode.changed`, `changelog.viewed`, `changelog.dismissed`, `provider.configured`, `file.referenced`, `mcp.configured` | + +## Future work + +- Funnel analysis for onboarding drop-off +- Project-aware first-action recommendations +- Progressive disclosure of advanced features +- Role-specific onboarding flows +- Prompt suggestions based on project code +- Team and repository-specific onboarding diff --git a/packages/kilo-docs/pages/contributing/features/template.md b/packages/kilo-docs/pages/contributing/features/template.md new file mode 100644 index 00000000000..5e18e78d993 --- /dev/null +++ b/packages/kilo-docs/pages/contributing/features/template.md @@ -0,0 +1,73 @@ +--- +title: "Feature Proposal Template" +description: "Template for proposing new feature designs" +--- + +# Feature proposal template + +{% callout type="info" title="Status" %} +Proposal - replace this sentence with concise status detail. Use Partial only when page clearly separates shipped behavior from roadmap. +{% /callout %} + +## Status guidance + +Every proposal page must include visible Status callout near title. Use one lifecycle label: + +| Status | Use when | +|---|---| +| `Proposal` | Design only; no matching implementation exists | +| `Partial` | Some pieces shipped; page separates current behavior from roadmap | +| `Historical` | Page remains for design history; implementation shipped elsewhere or changed materially | +| `Superseded` | Another proposal or implementation reference replaced page | + +For `Partial` pages, add separate current implementation and roadmap tables. Do not mix shipped behavior with tentative schema, endpoints, commands, or rollout claims. + +## Overview + +Describe problem and proposed solution. State intended outcome and boundaries. Keep scope small enough to ship and evaluate. + +## Requirements + +List minimum requirements needed for proposed solution. + +- + +### Non-requirements + +List work intentionally excluded from this proposal. + +- + +## Current implementation + +For `Partial` proposals, list shipped capabilities with evidence scope. Remove this section for design-only proposals. + +| Capability | Status | Notes | +|---|---|---| +| Example capability | Current | Describe verified current behavior | + +## Roadmap + +List tentative behavior separately from current implementation. + +| Capability | Status | Proposed behavior | +|---|---|---| +| Example capability | Planned | Describe intended change | + +## System design + +Document proposed architecture and implementation decisions. Mark tentative schema, endpoints, commands, and vendor integrations as proposed until verified. + +## Scope and implementation + +List work items that can become GitHub issues. + +- + +## Compliance considerations + +Describe relevant security, privacy, data-handling, and SOC 2 considerations. + +## Future work + +List ideas intentionally deferred beyond current proposal. diff --git a/packages/kilo-docs/pages/contributing/index.md b/packages/kilo-docs/pages/contributing/index.md index a3f17423b64..a7fab46e38e 100644 --- a/packages/kilo-docs/pages/contributing/index.md +++ b/packages/kilo-docs/pages/contributing/index.md @@ -52,6 +52,14 @@ git checkout -b docs/your-change-description - Include appropriate tests for new features - Update documentation for any user-facing changes +### Contribution Ownership and AI Assistance + +AI and coding agents are welcome in Kilo contributions. Contributors still own the work they submit: you must personally understand the change, test it appropriately, be able to explain the diff, and understand how it interacts with the affected package and the rest of the repo. + +When using an agent, start it from the repository root so the root `AGENTS.md` is available. If you work in a package with its own guidance, check and follow the package-specific `AGENTS.md` or contributor docs too. + +Maintainers may close PRs that appear to be submitted without credible contributor ownership or understanding, including AI-assisted work that has not been meaningfully reviewed by the contributor. + ### Commit Guidelines - Write clear, concise commit messages @@ -85,16 +93,32 @@ Guidelines: Skip the changeset only for internal refactors, CI tweaks, test-only changes, or docs that do not affect users. +### Other Guardrails + +- Regenerate `packages/sdk/js/` with `./script/generate.ts` after changing server endpoints. +- Run `bun run script/extract-source-links.ts` after adding or changing guarded URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`. +- When editing shared `packages/opencode/` files, keep Kilo changes small and mark Kilo-only edits with `// kilocode_change` for a single line or `// kilocode_change start` / `// kilocode_change end` for a block. Do not add these markers inside `kilocode`-named paths. + ### Testing Your Changes -- Run the test suite: - ```bash - npm test - ``` -- Manually test your changes in the development extension +Use the current Bun commands in the [Development Environment](/docs/contributing/development-environment) guide for repo-level, CLI, backend/API, VS Code extension, and docs checks. + +Key reminders: + +- Do **not** run `bun test` from the repo root. The root test script intentionally exits with failure so tests run from the package that owns them. +- Use the root [TESTING.md](https://github.com/Kilo-Org/kilocode/blob/main/TESTING.md) guide for backend/API validation with `bun dev serve` and `curl`-based requests. +- Regenerate the SDK with `./script/generate.ts` from the repo root after changing server endpoints. +- Manually verify extension behavior with `bun run extension`. +- For manual documentation validation, preview the affected page and check changed links and rendered content. + +Before marking a PR ready for review, include testing evidence in the PR template. See [Testing Evidence for Pull Requests](/docs/contributing/development-environment#testing-evidence-for-pull-requests) for the full standard, including docs/config-only verification and blocked command fallback requirements. ### Creating a Pull Request +Contributor guidance exists to protect maintainer review time and keep reviews focused on work that is ready to evaluate. + +Follow the issue-first policy by linking the relevant issue when you open a PR. Use `Fixes #123`, `Closes #123`, or equivalent linked issue wording so reviewers can see the problem statement, discussion, and intended scope before reviewing the code change. + 1. Push your changes to your fork: ```bash @@ -108,10 +132,34 @@ Skip the changeset only for internal refactors, CI tweaks, test-only changes, or 4. Select your fork and branch 5. Fill out the PR template with: - - A clear description of the changes - - Any related issues - - Testing steps - - Screenshots (if applicable) + - Related issue link, or an explanation for why there is no existing issue + - What problem is being solved and why the change is needed + - Important implementation choices or tradeoffs reviewers cannot infer from the diff + - Testing evidence, including commands run and results + - Manual/local verification performed + - Any command blocker plus substitute verification + - Screenshots or video for visual UI changes, showing the relevant before/after or resulting state + - Confirmation that you personally reviewed the diff and can explain the changes, including any AI-assisted work + +Keep the description focused on context reviewers cannot infer from the diff. Skip file-by-file summaries, placeholders, and other filler. + +Maintainers may close or decline review of PRs presented as review-ready at their discretion when they lack linked issue context, a clear what/why explanation, credible testing evidence, credible contributor ownership of AI-assisted work, or relevant UI proof for visual UI changes. + +When a PR is close to this bar, addresses important work, or would benefit from further shaping, maintainers may ask for specific fixes instead of closing or declining review. Contributors may reopen or resubmit once the PR meets the documented bar. + +## Tracker Use and Automation + +Please keep the issue and PR trackers useful for maintainers and contributors. Do not submit batches of agent-generated, untested, or weakly reviewed PRs. + +Keep concurrent PRs focused and limited. As a rule, open no more than three PRs at a time, especially if you are a new contributor. Prioritize high-impact or high-priority issues first instead of opening many speculative fixes. If a contributor opens a large batch of low-value or duplicative PRs, maintainers may close the batch and ask the contributor to choose one PR to reopen, focus, and bring up to the documented review bar before submitting more. + +For issues, do not mass-create tickets through automation or agents. Search existing issues first, open issues only when you have enough context for someone to act, and prioritize the most important reports instead of filing every possible finding. Maintainers may close duplicate, low-signal, automated, or weakly reviewed issues without action. + +Maintainers may close issues or PRs that disregard the contribution guide, bypass required context, or lack credible contributor ownership of AI-assisted work. Repeated disregard of this contribution guide, or high-volume automated or agent-generated tracker spam across issues or PRs, may result in maintainers blocking the responsible account. + +## Bug Bounties + +Kilo has bug bounties. To be eligible, make sure your GitHub account is connected in your Kilo account. ## Contributing to the Kilo Marketplace @@ -131,7 +179,7 @@ To contribute: ## Engineering Specs -For larger features, we write engineering specs to align on requirements before implementation. Check out the [Architecture](/docs/contributing/architecture) section to see planned features and learn how to contribute specs. +For larger features, we write engineering specs to align on requirements before implementation. Check the [Feature Proposals](/docs/contributing/features) section to see planned features and learn how to contribute specs. ## Documentation Contributions @@ -143,14 +191,16 @@ Documentation improvements are highly valued contributions: - Use absolute paths starting from `/docs/` for internal links (except within the same directory) - Don't include `.md` extensions in links -2. Test your documentation changes by running the docs site locally: +2. Test your documentation changes and run the docs site locally from the repo root: ```bash - cd packages/kilo-docs - pnpm install - pnpm dev + bun run --filter @kilocode/kilo-docs test + bun run --filter @kilocode/kilo-docs build + bun run --filter @kilocode/kilo-docs dev ``` + For manual validation, preview the affected page and check changed links and rendered content. + 3. Submit a PR with your documentation changes ## Community Guidelines diff --git a/packages/kilo-docs/pages/customize/agent-permissions.md b/packages/kilo-docs/pages/customize/agent-permissions.md new file mode 100644 index 00000000000..c0764c27829 --- /dev/null +++ b/packages/kilo-docs/pages/customize/agent-permissions.md @@ -0,0 +1,189 @@ +--- +title: "Agent Permissions" +description: "Configure Kilo Code agent permission rules for tools, shell commands, files, and subagents" +platform: new +--- + +# Agent Permissions + +Agent permissions decide whether a tool call is allowed, asks for approval, or is denied. + +This page focuses on Markdown agent files, where permission rules are written as YAML frontmatter under the `permission` key. For global defaults in `kilo.jsonc`, use the JSON examples in [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions#glob-pattern-rules). + +## Actions + +Each permission rule uses one of these actions: + +| Action | Behavior | +|---|---| +| `allow` | Run the matching tool call without asking. | +| `ask` | Prompt before running the matching tool call. | +| `deny` | Block the matching tool call. | + +You can write each permission as one action for the whole tool or as a pattern map: + +```yaml +permission: + read: allow + edit: + "*": deny + "*.md": allow + bash: + "*": ask + "git status *": allow +``` + +## Rule Precedence + +Permission rules are evaluated in config order. When more than one rule matches the requested permission and target pattern, the last matching rule wins. + +Put broad fallbacks first and exceptions after them: + +```yaml +permission: + bash: + "*": ask + "uv *": allow +``` + +With that config, `uv pip install ...` is allowed because `uv *` appears after the catch-all `*`. + +If you put the catch-all last, it overrides the earlier specific rule: + +```yaml +permission: + bash: + "uv *": allow + "*": ask +``` + +With that config, `uv pip install ...` asks because the later `*` rule also matches. + +Top-level permission keys follow the same rule. For example, this lets `bash` override the global fallback: + +```yaml +permission: + "*": ask + bash: allow +``` + +This does the opposite because the top-level `*` is later: + +```yaml +permission: + bash: allow + "*": ask +``` + +## Patterns + +Permission patterns use glob matching: + +| Pattern | Matches | +|---|---| +| `*` | Any target for that permission. | +| `git *` | `git`, `git status`, `git log --oneline`, and other `git` commands. | +| `git status *` | `git status` with or without extra arguments. | +| `src/*` | Paths under `src/`. | +| `*.env` | Files ending in `.env`, including nested paths such as `apps/web/.env`. | + +The matcher normalizes Windows backslashes to forward slashes before matching. On Windows, matching is case-insensitive; on Unix-like systems, matching is case-sensitive. Prefer forward slashes in config because they work across platforms. + +`~`, `~/...`, `$HOME`, and `$HOME/...` at the start of a pattern are expanded to your home directory when the config is loaded. + +## File Paths + +File tools such as `read`, `edit`, and `write` resolve the input path first, then check permissions against the path relative to the current worktree. + +For project files, use workspace-relative patterns: + +```yaml +permission: + read: + "*": ask + "docs/*": allow + "src/generated/*": deny + edit: + "*": deny + "*.md": allow +``` + +Absolute paths are mainly relevant for `external_directory` permissions and shell commands that touch paths outside the worktree. + +## Shell Commands + +The `bash` permission is checked against parsed shell command patterns. If a shell block contains multiple parsed commands, each relevant command must be permitted. A single denied command rejects the request. + +For example: + +```yaml +permission: + bash: + "*": ask + "cd *": allow + "git *": deny +``` + +For this command: + +```bash +cd "/project"; git status +``` + +Kilo checks the parsed command patterns. The `git status` command matches `git *`, so the request is denied. Directory changes and commands that access paths outside the worktree can also trigger `external_directory` checks. + +Built-in read-only agents include additional shell restrictions for write-like patterns such as output redirection, command substitution, pipes, and command chains. If you create your own read-only agent, prefer an explicit deny fallback and allow only the commands you trust: + +```yaml +permission: + bash: + "*": deny + "cat *": allow + "grep *": allow + "git status *": allow + "git diff *": allow +``` + +## Sensitive Files + +Kilo treats `.env` and `.env.*` reads as sensitive. Broad read approvals, such as `read: allow`, `read: { "*": allow }`, saved wildcard approvals, or allow-everything mode do not bypass the built-in prompt for these files. `.env.example` is treated as safe documentation and can be allowed by default. + +Use explicit sensitive-file rules only when you intentionally want that behavior for a specific agent: + +```yaml +permission: + read: + "*": allow + "*.env": ask + "*.env.*": ask + "*.env.example": allow +``` + +## Subagent Delegation + +Use `task` permission rules to control which subagents another agent may invoke: + +```yaml +permission: + task: + "*": deny + "code-reviewer": allow + "docs-writer": allow +``` + +This allows delegation only to `code-reviewer` and `docs-writer`. + +## Troubleshooting + +- If a specific rule appears to be ignored, check whether a later catch-all also matches. +- If a broad allow does not apply to `.env`, this is expected sensitive-file protection. +- If a shell command asks unexpectedly, check whether it was parsed into more than one command pattern or triggered `external_directory`. +- If path rules behave differently across operating systems, write patterns with forward slashes and workspace-relative paths where possible. + +## Related + +- [Custom Modes](/docs/customize/custom-modes) +- [Custom Subagents](/docs/customize/custom-subagents) +- [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) +- [.kilocodeignore](/docs/customize/context/kilocodeignore) +- [Tool Use Overview](/docs/automate/tools) diff --git a/packages/kilo-docs/pages/customize/agents-md.md b/packages/kilo-docs/pages/customize/agents-md.md index 7eccef968da..cbe0e6743fb 100644 --- a/packages/kilo-docs/pages/customize/agents-md.md +++ b/packages/kilo-docs/pages/customize/agents-md.md @@ -57,21 +57,25 @@ my-project/ The filename must be uppercase (`AGENTS.md`), not lowercase (`agents.md`). This ensures consistency across different operating systems and tools. {% /callout %} -### Subdirectory AGENTS.md Files +### Per-Directory AGENTS.md Files -You can also place AGENTS.md files in subdirectories to provide context-specific instructions: +You can place AGENTS.md files in subdirectories to provide context-specific instructions when the agent accesses files in those locations: ``` my-project/ ├── AGENTS.md # Root-level instructions ├── src/ │ └── backend/ -│ └── AGENTS.md # Backend-specific instructions +│ └── AGENTS.md # Backend-specific instructions (loaded when reading backend files) └── docs/ - └── AGENTS.md # Documentation-specific instructions + └── AGENTS.md # Documentation-specific instructions (loaded when reading docs files) ``` -When working in a subdirectory, Kilo Code will load both the root AGENTS.md and any subdirectory AGENTS.md files, with subdirectory files taking precedence for conflicting instructions. +{% callout type="info" %} +Per-directory AGENTS.md files are **dynamically loaded** when the agent reads files in that directory - they are not pre-loaded at session start. When the agent reads a file in `src/backend/`, the corresponding `AGENTS.md` is discovered and its contents are injected into the conversation as `` tags. + +This is useful for providing context-specific guidance for different parts of a monorepo or project. +{% /callout %} ## File Protection diff --git a/packages/kilo-docs/pages/customize/context/codebase-indexing.md b/packages/kilo-docs/pages/customize/context/codebase-indexing.md index f577678670b..3c0a9bdb228 100644 --- a/packages/kilo-docs/pages/customize/context/codebase-indexing.md +++ b/packages/kilo-docs/pages/customize/context/codebase-indexing.md @@ -7,8 +7,8 @@ description: "Index your codebase for improved AI understanding" Codebase Indexing enables semantic code search across your entire project using AI embeddings. Instead of searching for exact text matches, it understands the _meaning_ of your queries, helping Kilo Code find relevant code even when you don't know specific function names or file locations. -{% callout type="warning" title="Experimental" %} -Codebase Indexing is currently **experimental** in the CLI and the new VS Code extension. You must explicitly opt in before the feature becomes available — see the **Setup** section below. Behavior, configuration, and defaults may change in future releases. +{% callout type="info" title="Opt-in indexing" %} +Codebase Indexing is disabled by default. It starts only after you enable indexing globally or for an individual project. Configuring an embedding provider without enabling one of those toggles does not start indexing. {% /callout %} ## What It Does @@ -34,30 +34,12 @@ This enables natural language queries like "user authentication logic" or "datab {% tabs %} {% tab label="VSCode" %} -### 1. Enable the experimental flag - -Codebase Indexing is gated behind an experimental flag. Until the flag is on, the Indexing UI is hidden and `semantic_search` is unavailable. - -1. Open Kilo Code **Settings** → **Experimental**. -2. Toggle **Semantic Indexing** on. -3. The **Indexing** tab will appear in Settings and the indexing status indicator will appear at the bottom of the prompt input panel. - -Alternatively, set `experimental.semantic_indexing` to `true` in your `kilo.jsonc`: - -```json -{ - "experimental": { - "semantic_indexing": true - } -} -``` - -### 2. Configure indexing +### Configure indexing 1. Open Kilo Code **Settings** → **Indexing**, or click the indexing indicator at the bottom of the prompt input panel. -2. Toggle **Enable Indexing** on. +2. Turn on **Global Enable** to index every workspace, or turn on **Enable for This Project** to index only the current workspace. Both toggles are off until explicitly enabled. 3. Pick an **Embedding Provider** and fill in its required fields. -4. Pick a **Vector Store** (`Qdrant` or `LanceDB`) and configure it. +4. Pick a **Vector Store** (`LanceDB` or `Qdrant`) and configure it. 5. Optionally adjust **Tuning Parameters** (search score, batch size, retries, max results). 6. Save to start the initial scan. @@ -82,7 +64,7 @@ You can also edit the `indexing` section in `kilo.jsonc` directly: |---|---|---| | **OpenAI** | API key | Default model: `text-embedding-3-small`. `text-embedding-3-large` for higher accuracy. | | **Ollama** | Local base URL | No API costs. Runs fully offline. | -| **OpenAI-Compatible** | Base URL + API key | For self-hosted or third-party OpenAI-compatible endpoints. | +| **OpenAI-Compatible** | Base URL + optional API key | For self-hosted or third-party OpenAI-compatible endpoints, including unauthenticated local servers. | | **Gemini** | Google AI API key | Supports `gemini-embedding-001` and other Gemini embedding models. | | **Mistral** | API key from [La Plateforme](https://console.mistral.ai/api-keys/) | Use a standard Mistral API key. The Codestral-specific keys from the [Mistral autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) are **not** interchangeable — those only work for completion. | | **Vercel AI Gateway** | API key | Routes requests through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). | @@ -92,8 +74,12 @@ You can also edit the `indexing` section in `kilo.jsonc` directly: ### Vector stores -- **Qdrant** (default) — external server. Recommended for team deployments and larger codebases. See [Setting Up Qdrant](#setting-up-qdrant). -- **LanceDB** — embedded, file-based. No server to run. Stores data under your Kilo data directory by default. +- **LanceDB** (default). Embedded and file-based, with no server to run. Stores data under your Kilo data directory by default. +- **Qdrant**. External server recommended for team deployments and larger codebases. See [Setting Up Qdrant](#setting-up-qdrant). + +{% callout type="warning" title="Intel Macs" %} +LanceDB does not support Intel Macs. Select **Qdrant** and configure a Qdrant server instead. +{% /callout %} {% callout type="tip" %} For a fully local, zero-cost setup, combine **Ollama** (embeddings) with **LanceDB** (vector store — no separate server needed). @@ -106,23 +92,9 @@ The prompt input panel shows a compact indexing status indicator that reflects t {% /tab %} {% tab label="CLI" %} -### 1. Enable the experimental flag +### Configure indexing -Codebase Indexing is gated behind an experimental flag. Until the flag is on, the `/indexing` command is hidden and `semantic_search` is unavailable. - -Set the flag in your `kilo.jsonc`: - -```json -{ - "experimental": { - "semantic_indexing": true - } -} -``` - -Restart the CLI for the change to take effect. The `/indexing` command (and aliases `/index`, `/embedding`) will appear in the command palette once the flag is active. - -### 2. Configure indexing +The `/indexing` command (and aliases `/index`, `/embedding`) is available when the indexing plugin is installed. Indexing remains disabled until it is enabled globally or for the current project. Open a Kilo TUI session and run: @@ -138,7 +110,7 @@ This opens an interactive configuration dialog where you can: - Choose an **Embedding Provider** and fill in provider settings (API key, base URL, AWS region, etc.) - Set the **Embedding Model** (blank = provider default) - Set the **Vector Dimension** (blank = auto-detect from the model) -- Choose a **Vector Store** (`Qdrant` or `LanceDB`) and configure its connection +- Choose a **Vector Store** (`LanceDB` or `Qdrant`) and configure its connection - Adjust **Tuning Parameters** (search threshold, batch size, retries, max results) All changes are written to your `kilo.jsonc` config and take effect immediately. @@ -152,14 +124,11 @@ You can also edit the `indexing` section directly. This is the full shape of the "provider": "voyage", "model": "voyage-code-3", "dimension": 1024, - "vectorStore": "qdrant", + "vectorStore": "lancedb", "voyage": { "apiKey": "pa-..." }, - "qdrant": { - "url": "http://localhost:6333", - "apiKey": "" - }, + "lancedb": {}, "searchMinScore": 0.4, "searchMaxResults": 50, "embeddingBatchSize": 60, @@ -174,7 +143,7 @@ You can also edit the `indexing` section directly. This is the full shape of the |---|---|---|---| | **OpenAI** | `openai` | `{ apiKey }` | Default: `text-embedding-3-small`. | | **Ollama** | `ollama` | `{ baseUrl }` | No API costs. Runs fully offline. | -| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey }` | For self-hosted or third-party endpoints. | +| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey? }` | For self-hosted or third-party endpoints, including unauthenticated local servers. | | **Gemini** | `gemini` | `{ apiKey }` | Supports `gemini-embedding-001`. | | **Mistral** | `mistral` | `{ apiKey }` | Use a [La Plateforme](https://console.mistral.ai/api-keys/) key — the Codestral-specific keys from the [autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) don't work for embeddings. | | **Vercel AI Gateway** | `vercel-ai-gateway` | `{ apiKey }` | Routes through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). | @@ -184,8 +153,8 @@ You can also edit the `indexing` section directly. This is the full shape of the ### Vector stores -- `qdrant` — `{ url?, apiKey? }` (default). See [Setting Up Qdrant](#setting-up-qdrant). -- `lancedb` — `{ directory? }` — embedded, file-based. No server to run. Uses a default Kilo data directory when omitted. +- `lancedb` uses `{ directory? }` and is the default. It is embedded and file-based, with no server to run. Kilo uses its data directory when `directory` is omitted. +- `qdrant` uses `{ url?, apiKey? }`. See [Setting Up Qdrant](#setting-up-qdrant). {% callout type="tip" %} For a fully local, zero-cost setup, combine **Ollama** (embeddings) with **LanceDB** (vector store — no separate server needed). @@ -198,7 +167,7 @@ When indexing is enabled, the CLI shows an indexing status badge at the bottom o {% /tab %} {% tab label="VSCode (Legacy)" %} -The legacy extension does not require an experimental flag. +The legacy extension uses its own Codebase Indexing settings panel. ### Open Codebase Indexing Settings diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index c5b173a3e09..69cfdc4ac6a 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -36,7 +36,7 @@ This summary replaces older conversation history while Kilo keeps the most recen ### Automatic trigger -Kilo tracks the total token count for the session: input, output, and cached reads and writes. Compaction runs when token usage reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. +Kilo checks provider-reported usage after each response and estimates the outgoing text, system instructions, and tool definitions before contacting the provider. Compaction runs when either count reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. How the buffer is chosen depends on what the model declares. When the model advertises a separate input limit, the buffer defaults to 20,000 tokens (or the model's maximum output size, whichever is smaller). When the model only declares a single context window, Kilo instead reserves the model's full output cap — up to 32,000 tokens. @@ -136,7 +136,7 @@ This summary replaces older conversation history while Kilo keeps the most recen ### Automatic trigger -Kilo tracks the total token count for the session: input, output, and cached reads and writes. Compaction runs when token usage reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. +Kilo checks provider-reported usage after each response and estimates the outgoing text, system instructions, and tool definitions before contacting the provider. Compaction runs when either count reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. How the buffer is chosen depends on what the model declares. When the model advertises a separate input limit, the buffer defaults to 20,000 tokens (or the model's maximum output size, whichever is smaller). When the model only declares a single context window, Kilo instead reserves the model's full output cap — up to 32,000 tokens. diff --git a/packages/kilo-docs/pages/customize/custom-instructions.md b/packages/kilo-docs/pages/customize/custom-instructions.md index c46dda3120e..4caf933f583 100644 --- a/packages/kilo-docs/pages/customize/custom-instructions.md +++ b/packages/kilo-docs/pages/customize/custom-instructions.md @@ -50,7 +50,7 @@ Project-level instructions are loaded before global instructions and apply to ev You can place `AGENTS.md` files in any subdirectory of your project. These are loaded dynamically — when the agent's Read tool accesses a file in that directory, the corresponding `AGENTS.md` is discovered and its contents are injected into the conversation as `` tags. -This is useful for providing context-specific guidance for different parts of a monorepo or project. +This is useful for providing context-specific guidance for different parts of a monorepo or project. The subdirectory file does not need to duplicate root-level instructions; it supplements them for tasks within that directory. ## Additional Instruction Sources @@ -127,7 +127,7 @@ Project-level instructions are loaded before global instructions and apply to ev You can place `AGENTS.md` files in any subdirectory of your project. These are loaded dynamically — when the agent's Read tool accesses a file in that directory, the corresponding `AGENTS.md` is discovered and its contents are injected into the conversation as `` tags. -This is useful for providing context-specific guidance for different parts of a monorepo or project. +This is useful for providing context-specific guidance for different parts of a monorepo or project. The subdirectory file does not need to duplicate root-level instructions; it supplements them for tasks within that directory. ## Additional Instruction Sources diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 928a4feda93..95f82ce3dab 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -5,7 +5,7 @@ description: "Create and configure custom modes in Kilo Code" # Custom Modes -Kilo Code allows you to create **custom modes** (also called **agents**) to tailor Kilo's behavior to specific tasks or workflows. Custom modes can be either **global** (available across all projects) or **project-specific** (defined within a single project). +Kilo Code allows you to create **custom modes** (also called **agents**) to tailor Kilo's behavior to specific tasks or workflows. Custom modes can be **global** (available across all projects), **project-specific** (defined within a single project), or **organization-managed** (provided by your Kilo organization). {% callout type="info" %} The current VS Code extension (built on the Kilo CLI) uses **agent Markdown files** to define custom modes. The legacy extension used `custom_modes.yaml` / `.kilocodemodes`. See the tabs below for the relevant approach. @@ -17,6 +17,19 @@ The current VS Code extension (built on the Kilo CLI) uses **agent Markdown file - **Safety:** Restrict a mode's access to sensitive files or commands. For example, a "Review Mode" could be limited to read-only operations - **Experimentation:** Safely experiment with different prompts and configurations without affecting other modes - **Team Collaboration:** Share custom modes with your team to standardize workflows +- **Organization Consistency:** Use organization-managed agents/custom modes so members share the same behavior for common workflows + +## Organization-Managed Custom Modes + +If your Kilo organization provides custom modes, Kilo adds them to your local experience as organization-sourced agents/custom modes. They appear alongside built-in and personal agents so members can select them directly where Kilo shows user-selectable agents or modes. + +Organization-managed modes are controlled at the organization level: + +- An organization-managed mode can use the same name as a built-in agent. When it does, the organization-provided definition takes precedence for members of that organization. +- Individual members cannot remove organization-managed modes from their local agent list. Changes need to be made in the organization-managed definition. +- Organization-managed modes are useful for shared prompts, instructions, and tool access expectations that should stay consistent across a team. + +For organization members, contact the person or team that manages Kilo for your organization if an organization mode appears unexpectedly, needs different instructions, or needs different tool access. For admins and support teams, keep the purpose and owner of each organization custom mode clear so members know when to use it and where to request changes. {% tabs %} {% tab label="VSCode" %} @@ -151,7 +164,7 @@ permission: read: allow ``` -Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. +Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `todowrite`, `todoread`, and more. ### `model` @@ -387,7 +400,7 @@ permission: read: allow ``` -Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. +Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `todowrite`, `todoread`, and more. ### `model` diff --git a/packages/kilo-docs/pages/customize/custom-subagents.md b/packages/kilo-docs/pages/customize/custom-subagents.md index c1164c6701b..ad2b341b43e 100644 --- a/packages/kilo-docs/pages/customize/custom-subagents.md +++ b/packages/kilo-docs/pages/customize/custom-subagents.md @@ -191,7 +191,7 @@ The `permission` field controls what tools the subagent can use. Each tool permi } ``` -For bash commands, you can use glob patterns to set permissions per command. Rules are evaluated in order, with the **last matching rule winning**. +For bash commands, you can use glob patterns to set permissions per command. Rules are evaluated in order, with the **last matching rule winning**. See [Agent Permissions](/docs/customize/agent-permissions) for rule precedence, shell command patterns, path matching, and sensitive-file behavior. You can also control which subagents an agent can invoke via `permission.task`: diff --git a/packages/kilo-docs/pages/customize/index.md b/packages/kilo-docs/pages/customize/index.md index 2a4e54aeb3d..9413cb762ba 100644 --- a/packages/kilo-docs/pages/customize/index.md +++ b/packages/kilo-docs/pages/customize/index.md @@ -17,6 +17,7 @@ Configure how Kilo Code behaves and responds: - [**Custom Rules**](/docs/customize/custom-rules) - Define rules that apply to specific file types or situations - [**Custom Instructions**](/docs/customize/custom-instructions) - Add project-specific guidelines and context - [**Custom Subagents**](/docs/customize/custom-subagents) - Create specialized subagents with custom prompts, models, and permissions +- [**Agent Permissions**](/docs/customize/agent-permissions) - Configure rule precedence, shell command patterns, file paths, and subagent delegation - [**agents.md**](/docs/customize/agents-md) - Configure agent behavior at the project level - [**Workflows**](/docs/customize/workflows) - Automate multi-step processes - [**Skills**](/docs/customize/skills) - Extend Kilo's capabilities with reusable skill definitions diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index 99dbe4dabe1..dca559d1690 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -80,10 +80,10 @@ your-project/ ### Compatibility Directories -For interoperability with other tools, the CLI also loads skills from: +For interoperability with other tools, Kilo Code also loads skills from: -- `.claude/skills/` — Claude Code compatibility -- `.agents/skills/` — Open agent standard +- `.agents/skills/` — Open agent standard, loaded by default +- `.claude/skills/` — Claude Code compatibility, loaded when Claude Code Compatibility is enabled ### Additional Skill Paths and Remote URLs @@ -93,12 +93,28 @@ You can configure extra skill locations and remote skill URLs in your `kilo.json { "skills": { "paths": ["/path/to/shared/skills", "~/my-skills", "relative/skills"], - "urls": ["https://example.com/skills/my-skill/SKILL.md"], + "urls": ["https://example.com/.well-known/skills/"], }, } ``` -The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs pointing to remote `SKILL.md` files that are fetched on demand. +The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs to remote skill directories that serve an `index.json` manifest. + +The remote server must serve an `index.json` file at the URL path with the following structure: + +```json +{ + "skills": [ + { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + ] +} +``` + +Each skill object contains: +- `name`: The skill name (must match the directory name) +- `files`: Array of files to fetch for this skill (must include `SKILL.md`) + +Files are downloaded from `{url}/{skill-name}/{file}` paths. {% /tab %} {% tab label="CLI" %} @@ -146,12 +162,28 @@ You can configure extra skill locations and remote skill URLs in your `kilo.json { "skills": { "paths": ["/path/to/shared/skills", "~/my-skills", "relative/skills"], - "urls": ["https://example.com/skills/my-skill/SKILL.md"], + "urls": ["https://example.com/.well-known/skills/"], }, } ``` -The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs pointing to remote `SKILL.md` files that are fetched on demand. +The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs to remote skill directories that serve an `index.json` manifest. + +The remote server must serve an `index.json` file at the URL path with the following structure: + +```json +{ + "skills": [ + { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + ] +} +``` + +Each skill object contains: +- `name`: The skill name (must match the directory name) +- `files`: Array of files to fetch for this skill (must include `SKILL.md`) + +Files are downloaded from `{url}/{skill-name}/{file}` paths. {% /tab %} {% tab label="VSCode (Legacy)" %} diff --git a/packages/kilo-docs/pages/deploy-secure/index.md b/packages/kilo-docs/pages/deploy-secure/index.md index d929c64349e..91a52cfdac1 100644 --- a/packages/kilo-docs/pages/deploy-secure/index.md +++ b/packages/kilo-docs/pages/deploy-secure/index.md @@ -32,15 +32,6 @@ Ship your applications with one-click deployment: - Real-time log streaming - Deployment history with one-click rollbacks -## Managed Indexing - -Fast, scalable code indexing for better AI context: - -- [**Managed Indexing**](/docs/deploy-secure/managed-indexing) — Cloud-based code indexing -- Improved context for large codebases -- Faster initial indexing times -- Reduced local resource usage - ## Security Reviews AI-powered dependency vulnerability triage for your codebase: @@ -61,8 +52,7 @@ AI-powered dependency vulnerability triage for your codebase: 1. Enable [GitHub Integration](/docs/deploy-secure/deploy#prerequisites) for deployments 2. Set up your first [deployment](/docs/deploy-secure/deploy) in the dashboard -3. Configure [managed indexing](/docs/deploy-secure/managed-indexing) for large projects -4. Enable the [Security Agent](/docs/deploy-secure/security-reviews) to triage your Dependabot alerts +3. Enable the [Security Agent](/docs/deploy-secure/security-reviews) to triage your Dependabot alerts ## Best Practices diff --git a/packages/kilo-docs/pages/deploy-secure/managed-indexing.md b/packages/kilo-docs/pages/deploy-secure/managed-indexing.md deleted file mode 100644 index 1b799c40af2..00000000000 --- a/packages/kilo-docs/pages/deploy-secure/managed-indexing.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "Managed Indexing" -description: "Cloud-managed codebase indexing" ---- - -# Managed Indexing - -Kilo's **Managed Indexing** feature provides semantic search across your repositories using cloud-hosted embeddings. When enabled, Kilo indexes your codebase to deliver more relevant, context-aware responses during development. - ---- - -## What Managed Indexing Enables - -- Semantic search across your entire codebase -- More accurate and context-aware AI responses -- Git-aware indexing that tracks your base branch and feature branch changes -- Shared indexes for teams and enterprise accounts -- Cost-effective cloud storage with automatic cleanup of stale indexes - ---- - -## Prerequisites - -Before enabling Managed Indexing: - -- **Your workspace must be a Git repository** - Indexing requires a Git repository root directory. Non-Git folders will not be indexed. - -- **Available credit balance** - If your balance reaches zero, managed indexing will be disabled and the extension will revert to local indexing (if configured). - ---- - -## Cost - -- **Currently free during beta** -- **Pricing coming soon** — A daily usage fee for index storage will be deducted from your AI credit balance. You will be charged per GB per day. -- **Embedding model** — Uses `mistralai/codestral-embed-2505` which currently charges $0.15/M input tokens. - ---- - -## How to Enable - -Codebase Indexing is rolling out across our users. It will automatically engage unless your repository root is configured to opt out. - -1. Create a `.kilocode/config.json` file in the root of your repository (if it doesn't already exist). -2. Add the following configuration: - -```json -{ - "project": { - "managedIndexingEnabled": false - } -} -``` - -### Configuration Options - -| Field | Type | Required | Description | -|---|---|---|---| -| `project.id` | string | No | Custom name for your project. Defaults to the name from your Git origin remote. | -| `project.baseBranch` | string | No | Specifies your base branch if it isn't `main`, `master`, `dev`, or `develop`. | -| `project.managedIndexingEnabled` | boolean | No | Set to `false` to disable indexing for individual project repositories. Defaults to `true`. | - -Organization-wide indexing is enabled for any organization that has a credit balance. If you want to disable indexing for a specific repository, set `managedIndexingEnabled` to `false` in the config file. - ---- - -## How Managed Indexing Works - -- **Base branch** — Indexed in its entirety -- **Feature branches** — Only changes from the base branch are indexed -- **Detached HEAD states** — Not indexed -- **Storage** — Embeddings are stored in Kilo Cloud. Your actual code is never stored, only the vector embeddings. -- **Team sharing** — For teams and enterprise accounts, indexes are shared among all team members. - -### Index Retention - -Indexes are stored for **7 days**. If a branch or repository index hasn't been updated within that window, it will be garbage collected. The next time you open the project in VS Code with Kilo running, it will be re-indexed automatically. - -This retention policy keeps costs minimal by only maintaining indexes for actively used code. - ---- - -## Managing Your Indexes - -A minimal UI is available at [app.kilo.ai](https://app.kilo.ai) to: - -- View the size and status of your indexed projects -- Delete old branches & projects. - ---- - -## Migration from Local Indexing - -Enabling managed indexing will **replace local self-hosted indexing entirely**. If you have already configured local indexing for a workspace it will take precedence until you disable it. - -### Automatic Reversion - -If your credit balance reaches zero, the extension will automatically revert to local indexing (if previously configured). - ---- - -## Perfect For - -Managed Indexing is ideal for: - -- **Developers wanting smarter, context-aware AI assistance** -- **Teams needing shared semantic search across repositories** -- **Large codebases where finding relevant code is difficult** -- **Organizations wanting centralized index management** - ---- - -## Limitations and Guidance - -- **Git repository required** — Only Git repository root directories can be indexed. We plan to extend this in the future. -- **Detached HEAD not supported** — Commits in detached HEAD state will not be indexed. -- **7-day retention** — Unused indexes are automatically removed after 7 days. -- **Beta capacity** — During beta, indexing capacity may be limited for very large repositories. -- **Organization indexing** — Shared organization indexes currently require contacting support. diff --git a/packages/kilo-docs/pages/deploy-secure/security-reviews.md b/packages/kilo-docs/pages/deploy-secure/security-reviews.md index c3a77571325..bb7f84ba5f0 100644 --- a/packages/kilo-docs/pages/deploy-secure/security-reviews.md +++ b/packages/kilo-docs/pages/deploy-secure/security-reviews.md @@ -9,7 +9,7 @@ Most teams are drowning in Dependabot alerts. The majority of reported CVEs aren Kilo's Security Agent fixes this. It syncs your Dependabot alerts, triages them with AI, and performs deep codebase analysis to determine whether each vulnerability is actually reachable in your code. Non-exploitable findings can be auto-dismissed and synced back to GitHub. -Available on **Teams** and **Enterprise** plans. +Available for all users. --- diff --git a/packages/kilo-docs/pages/gateway/authentication.md b/packages/kilo-docs/pages/gateway/authentication.md index 91315a6016d..30129525ed0 100644 --- a/packages/kilo-docs/pages/gateway/authentication.md +++ b/packages/kilo-docs/pages/gateway/authentication.md @@ -90,10 +90,20 @@ BYOK lets you use your own provider API keys with the Kilo AI Gateway. When a BY | xAI | `xai` | | Z.AI | `zai` | | BytePlus Coding Plan | `byteplus-coding` | +| Chutes BYOK | `chutes-byok` | | Codestral (FIM) | `codestral` | +| CrofAI | `crofai` | +| Inceptron BYOK | `inceptron-byok` | | Kimi Code | `kimi-coding` | +| Martian | `martian` | | Neuralwatt | `neuralwatt` | -| Z.AI Coding Plan | `zai-coding` | +| Ollama Cloud | `ollama-cloud` | +| OpenCode Go | `opencode-go` | +| OrcaRouter | `orcarouter` | +| Synthetic | `synthetic` | +| Xiaomi Token Plan (Europe) | `xiaomi-token-plan-ams` | +| Xiaomi Token Plan (Singapore) | `xiaomi-token-plan-sgp` | +| Z.ai Coding Plan | `zai-coding` | ### How BYOK works diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index fc692771c85..922706b984b 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -60,20 +60,20 @@ Several models are available at no cost, subject to rate limits: | Model ID | Description | |---|---| -| `x-ai/grok-code-fast-1:optimized:free` | xAI Grok Code Fast 1 Optimized | -| `nvidia/nemotron-3-super-120b-a12b:free` | NVIDIA Nemotron 3 Super 120B | -| `arcee-ai/trinity-large-thinking:free` | Arcee Trinity Large | +| `stepfun/step-3.7-flash:free` | StepFun Step 3.7 Flash | +| `poolside/laguna-m.1:free` | Poolside Laguna M.1 | +| `nvidia/nemotron-3-ultra-550b-a55b:free` | NVIDIA Nemotron 3 Ultra | | `openrouter/free` | Best available free model | Free models are available to both authenticated and anonymous users. Anonymous users are rate-limited to 200 requests per hour per IP address. -{% callout type="warning" title="Nemotron 3 Super Free (NVIDIA free endpoints)" %} -Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. +{% callout type="warning" title="NVIDIA free endpoints" %} +For NVIDIA free endpoints (Super/Ultra/etc): Trial use only - do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://www.nvidia.com/en-us/about-nvidia/privacy-policy/). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). {% /callout %} ## Auto models -Auto virtual models automatically select the best underlying model based on the task type. The selection is controlled by the `x-kilocode-mode` request header. +Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Balanced uses the API interface, Free uses deterministic affinity across available candidates, and Small uses account balance. {% callout type="info" title="Underlying models can change" %} The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. @@ -104,7 +104,7 @@ Great balance of price and capability. The resolved model depends on the API int Free with limited capability. No credits required. The resolved model is selected dynamically per session from a curated set of available free models; the mapping updates server-side as free model availability shifts. {% callout type="warning" title="Data handling for Auto Free" %} -Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services — including NVIDIA's free endpoints, which are provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) (trial use only, not for production or sensitive data). Do not submit personal or confidential data when using Auto Free. +Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services. Do not submit personal or confidential data when using Auto Free. In particular, it may route to NVIDIA's free endpoints (see NVIDIA Trial Terms of Service above). {% /callout %} ### `kilo-auto/small` diff --git a/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md b/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md index 485c9d2c26b..f2ec735f7f4 100644 --- a/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md +++ b/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md @@ -292,6 +292,17 @@ The Kilo AI Gateway works with any framework that supports OpenAI-compatible API | [LlamaIndex](https://www.llamaindex.ai) | Use OpenAI-compatible configuration | | [Haystack](https://haystack.deepset.ai) | Use OpenAI generator with custom URL | | [Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/) | Use OpenAI connector with custom endpoint | +| [Pi](https://pi.dev) | Install the [Kilo provider extension](https://github.com/Kilo-Org/kilo-pi-provider) | + +### Pi coding agent + +Use the Kilo-maintained [Pi provider extension](https://github.com/Kilo-Org/kilo-pi-provider) to access Kilo Gateway models from the [Pi coding agent](https://pi.dev). + +```bash +pi install git:github.com/Kilo-Org/kilo-pi-provider +``` + +Run `/login kilo` in Pi to connect your account, or use supported free models without signing in. See the provider repository for organization configuration and model-specific behavior. ### LangChain example diff --git a/packages/kilo-docs/pages/getting-started/adding-credits.md b/packages/kilo-docs/pages/getting-started/adding-credits.md index a8b021abcea..b9d7d3cf145 100644 --- a/packages/kilo-docs/pages/getting-started/adding-credits.md +++ b/packages/kilo-docs/pages/getting-started/adding-credits.md @@ -7,7 +7,7 @@ description: "How to add credits to your Kilo Code account" Once you've used any initial free Kilo Credits, you can easily add more: -- Subscribe to the [Kilo Pass](https://kilo.ai/features/kilo-pass), the most cost effective way to add credits. +- Subscribe to the [Kilo Pass](https://kilo.ai/pricing/kilo-pass), the most cost effective way to add credits. - Purchase additional credits as a one-time transaction. - Enable [automatic top-up](https://kilo.ai/features/auto-top-ups), which purchases additional credits when your balance runs low. Auto top-up is available for both individual and organization accounts. diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index 52c70ae77c7..adb426172dc 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -43,9 +43,18 @@ These providers offer coding-focused subscriptions or dedicated endpoints. Bring - BytePlus Coding Plan - Chutes BYOK +- CrofAI +- Inceptron BYOK - Kimi Code +- Martian - Mistral Codestral - Neuralwatt +- Ollama Cloud +- OpenCode Go +- OrcaRouter +- Synthetic +- Xiaomi Token Plan (Europe) +- Xiaomi Token Plan (Singapore) - Z.ai Coding Plan ## Add a BYOK key @@ -88,5 +97,6 @@ Your IAM user or role must have the following permissions: ## Using BYOK in the Extensions and CLI - BYOK works with the Kilo Gateway provider. Users should ensure that is set as the active [provider](/docs/ai-providers). -- Select a model from a provider configured for BYOK, for example Claude Sonnet 4.5 if you configured BYOK for Anthropic, or GLM-4.7 if you configured the Z.ai Coding Plan. +- Kilo Gateway models that can use one of your enabled personal or organization BYOK providers display a `BYOK` badge in the model picker. The badge does not apply to models selected through other providers. +- Select a model with the `BYOK` badge, for example Claude Sonnet 4.5 if you configured BYOK for Anthropic, or GLM-4.7 if you configured the Z.ai Coding Plan. - (Optional) Validate with the provider that traffic is being served by that key. diff --git a/packages/kilo-docs/pages/getting-started/installing.md b/packages/kilo-docs/pages/getting-started/installing.md index e63c864adb5..095d1687af7 100644 --- a/packages/kilo-docs/pages/getting-started/installing.md +++ b/packages/kilo-docs/pages/getting-started/installing.md @@ -25,49 +25,6 @@ The current Kilo Code extension is built on the [Kilo CLI](https://github.com/Ki The "pre-release" label is a VS Code Marketplace distribution channel — the extension is stable and recommended for all users. {% /callout %} -{% /tab %} -{% tab label="CLI" %} - -## Command Line Interface - -{% partial file="install-cli.md" /%} - -{% /tab %} -{% tab label="VS Code (Legacy)" %} - -## VS Code Legacy Extension - -The legacy extension is the previous version of Kilo Code for VS Code. It is still available but is no longer actively developed. We recommend installing the current extension (see the **VS Code** tab). - -To install or switch back to the legacy version: - -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 **Switch to Release Version** - -{% /tab %} -{% tab label="JetBrains" %} - -## JetBrains IDEs - -{% partial file="install-jetbrains.md" /%} - -{% /tab %} -{% tab label="Slack" %} - -## Slack Integration - -{% partial file="install-slack.md" /%} - -{% /tab %} -{% tab label="Other IDEs" %} - -{% partial file="install-other-ides.md" /%} - -{% /tab %} -{% /tabs %} - ## Manual Installations ### Open VSX Registry @@ -136,6 +93,49 @@ If you plan to remain on that version for a while, you may also want to temporar 3. Add: `C:\Windows\System32\WindowsPowerShell\v1.0\` 4. Click **OK** and restart VS Code +{% /tab %} +{% tab label="CLI" %} + +## Command Line Interface + +{% partial file="install-cli.md" /%} + +{% /tab %} +{% tab label="VS Code (Legacy)" %} + +## VS Code Legacy Extension + +The legacy extension is the previous version of Kilo Code for VS Code. It is still available but is no longer actively developed. We recommend installing the current extension (see the **VS Code** tab). + +To install or switch back to the legacy version: + +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 **Switch to Release Version** + +{% /tab %} +{% tab label="JetBrains" %} + +## JetBrains IDEs + +{% partial file="install-jetbrains.md" /%} + +{% /tab %} +{% tab label="Slack" %} + +## Slack Integration + +{% partial file="install-slack.md" /%} + +{% /tab %} +{% tab label="Other IDEs" %} + +{% partial file="install-other-ides.md" /%} + +{% /tab %} +{% /tabs %} + ## Next Steps After installation, check out these resources to get started: diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index 693ad0871ed..f556be70ca6 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -49,7 +49,7 @@ The Auto Approve tab lists the following tool-specific permissions. Some tools a | `skill` | Loading specialized skills | | `lsp` | Language server protocol operations | | `todoread` / `todowrite` | Reading and updating the todo list | -| `websearch` / `codesearch` | Performing web or code searches | +| `websearch` | Performing web searches | | `webfetch` | Fetching content from URLs | | `doom_loop` | Allowing the agent to continue after repeated failures | @@ -66,7 +66,7 @@ Use the shield button in the prompt controls to toggle runtime auto-approve for Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed or denied lists. These rules are then appended to the bottom of the approval rules in settings and the config file. -For the experimental `agent_manager` tool, runtime approvals use the requested mode as the pattern: `worktree` or `local`. +For the `agent_manager` tool, runtime approvals use the requested mode as the pattern: `worktree` or `local`. ## MCP Tool Permissions @@ -117,7 +117,7 @@ Permissions are configured under the `permission` key in `kilo.jsonc`. The follo | `skill` | Loading specialized skills | | `lsp` | Language server protocol operations | | `todoread` / `todowrite` | Reading and updating the todo list | -| `websearch` / `codesearch` | Performing web or code searches | +| `websearch` | Performing web searches | | `webfetch` | Fetching content from URLs | | `doom_loop` | Allowing the agent to continue after repeated failures | @@ -125,6 +125,40 @@ Permissions are configured under the `permission` key in `kilo.jsonc`. The follo Instead of a simple `"allow"` or `"deny"`, each tool can use glob-pattern rules for granular control. Patterns are matched against the tool's arguments (command strings, file paths, etc.), and the last matching rule wins. +### Rule Precedence + +Permission rules are evaluated in config order. When more than one rule matches the requested permission and target pattern, the last matching rule wins. + +Put broad fallbacks first and exceptions after them: + +```json +{ + "permission": { + "bash": { + "*": "ask", + "uv *": "allow" + } + } +} +``` + +With that config, `uv pip install ...` is allowed because `uv *` appears after the catch-all `*`. + +If you put the catch-all last, it overrides the earlier specific rule: + +```json +{ + "permission": { + "bash": { + "uv *": "allow", + "*": "ask" + } + } +} +``` + +With that config, `uv pip install ...` asks because the later `*` rule also matches. + ### Example: Shell Commands Allow git commands automatically, but prompt for everything else: @@ -133,8 +167,8 @@ Allow git commands automatically, but prompt for everything else: { "permission": { "bash": { - "git *": "allow", - "*": "ask" + "*": "ask", + "git *": "allow" } } } @@ -148,8 +182,8 @@ Prompt before reading `.env` files, but allow all other reads: { "permission": { "read": { - "*.env": "ask", - "*": "allow" + "*": "allow", + "*.env": "ask" } } } @@ -163,11 +197,11 @@ Deny `rm -rf` commands, allow common dev commands, and ask for anything else: { "permission": { "bash": { + "*": "ask", "rm -rf *": "deny", "npm *": "allow", "bun *": "allow", - "git *": "allow", - "*": "ask" + "git *": "allow" } } } @@ -185,7 +219,7 @@ Different agents can have different permission levels. Override the default perm "agent": { "code": { "permission": { - "bash": { "git *": "allow", "*": "ask" } + "bash": { "*": "ask", "git *": "allow" } } }, "plan": { @@ -199,6 +233,26 @@ Different agents can have different permission levels. Override the default perm In this example, the `code` agent can run `git` commands automatically and asks for other shell commands, while the `plan` agent cannot run shell commands at all. +## Markdown Agent Files + +If you define agents in Markdown files, the `permission` frontmatter uses the same `allow` / `ask` / `deny` values and glob patterns as `kilo.jsonc`: + +```markdown +--- +description: Reviews code for quality and best practices +mode: subagent +permission: + bash: + "*": ask + "git *": allow + read: + "*": allow + "*.env": ask +--- +``` + +This is the same permission shape described in [Agent Permissions](/docs/customize/agent-permissions), just shown in the agent-file format. + ## Runtime Permission Requests When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt. You have three options: @@ -234,12 +288,12 @@ This is a custom example showing the available configuration options — it does ```json { "permission": { - "read": { "*.env": "ask", "*": "allow" }, - "edit": { "*.env": "ask", "*": "allow" }, + "read": { "*": "allow", "*.env": "ask" }, + "edit": { "*": "allow", "*.env": "ask" }, "glob": { "*": "allow" }, "grep": { "*": "allow" }, "list": { "*": "allow" }, - "bash": { "git *": "allow", "npm *": "allow", "*": "ask" }, + "bash": { "*": "ask", "git *": "allow", "npm *": "allow" }, "task": { "*": "allow" }, "skill": { "*": "allow" }, "lsp": { "*": "allow" }, @@ -247,14 +301,13 @@ This is a custom example showing the available configuration options — it does "todowrite": { "*": "allow" }, "webfetch": { "*": "allow" }, "websearch": { "*": "allow" }, - "codesearch": { "*": "allow" }, "external_directory": { "*": "ask" }, "doom_loop": { "*": "ask" } }, "agent": { "code": { "permission": { - "bash": { "git *": "allow", "npm *": "allow", "*": "ask" } + "bash": { "*": "ask", "git *": "allow", "npm *": "allow" } } } } diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index f9645eca76c..9c2a9be2be7 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -47,6 +47,30 @@ Use **Local Config** or **Global Config** in the Settings header to open the mat If you check config files into version control, make sure they do not contain API keys or other secrets (e.g., `provider.*.options.apiKey`). Use environment variables for credentials instead. {% /callout %} +### Voice Transcription Model + +When the Kilo provider is enabled and you are signed in, choose the transcription model under **Models** > **Speech to Text Model**. This stores `experimental.speech_to_text_model` in your global Kilo CLI config: + +```json +{ + "experimental": { + "speech_to_text_model": "openai/whisper-large-v3-turbo" + } +} +``` + +### Prompt-Training Model Visibility + +Enable **Hide Prompt-Training Models** under **Models** to remove Kilo Gateway models whose providers may use your prompts for training from model lists. Models from other providers and models without explicit prompt-training metadata remain visible. The setting is disabled by default. + +You can also enable it in `kilo.jsonc`: + +```json +{ + "hide_prompt_training_models": true +} +``` + ### Reasoning Blocks Reasoning blocks stay expanded by default in the VS Code chat UI. Enable **Auto-Collapse Reasoning** in the Display tab, or set `auto_collapse_reasoning` in `kilo.jsonc`, to collapse them after the agent finishes writing them: @@ -193,25 +217,13 @@ Use this option only if you are certain you want to remove all Kilo Code data or The new extension exposes experimental features via the **Experimental** tab in Settings (click the gear icon {% codicon name="gear" /%} → Experimental). -Available experimental toggles include: - -- **Share mode** — `manual`, `auto`, or `disabled` session sharing -- **LSP integration** — expose language server diagnostics to the agent -- **Paste summary** — summarize large clipboard pastes before including them -- **Speech to Text**: enable voice transcription in chat -- **Batch tool** — allow the agent to batch multiple tool calls in one step -- **Agent Manager Tool** - allow agents to start Agent Manager local and worktree sessions from chat -- **OpenTelemetry** — enable Kilo telemetry and optional OTLP export when configured - -Speech to Text is enabled from this Experimental tab. Kilo stores that toggle in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`), not VS Code user settings: +Available experimental settings include: -```json -{ - "experimental": { - "speech_to_text": true - } -} -``` +- **Share mode** - `manual`, `auto`, or `disabled` session sharing +- **LSP integration** - expose language server diagnostics to the agent +- **Paste summary** - summarize large clipboard pastes before including them +- **Batch tool** - allow the agent to batch multiple tool calls in one step +- **OpenTelemetry** - enable Kilo telemetry and optional OTLP export when configured Advanced options not exposed in the UI can be configured via the `experimental` key in `kilo.jsonc`: @@ -220,7 +232,6 @@ Advanced options not exposed in the UI can be configured via the `experimental` "experimental": { "codebase_search": true, "batch_tool": false, - "agent_manager_tool": false, "openTelemetry": true, "disable_paste_summary": false, "mcp_timeout": 30000 diff --git a/packages/kilo-docs/pages/getting-started/setup-authentication.md b/packages/kilo-docs/pages/getting-started/setup-authentication.md index 4b479998432..c068475ed1b 100644 --- a/packages/kilo-docs/pages/getting-started/setup-authentication.md +++ b/packages/kilo-docs/pages/getting-started/setup-authentication.md @@ -46,7 +46,7 @@ That's it! You're ready to [start your first task](/docs/getting-started/quickst {% /tabs %} {% callout type="tip" title="Add Credits" %} -[Add credits to your account](https://app.kilo.ai/profile), or sign up for [Kilo Pass](https://kilo.ai/features/kilo-pass). +[Add credits to your account](https://app.kilo.ai/profile), or sign up for [Kilo Pass](https://kilo.ai/pricing/kilo-pass). {% /callout %} ## Kilo Gateway API Key diff --git a/packages/kilo-docs/pages/getting-started/using-kilo-for-free.md b/packages/kilo-docs/pages/getting-started/using-kilo-for-free.md index 60f0d97aa48..64f6b7585ec 100644 --- a/packages/kilo-docs/pages/getting-started/using-kilo-for-free.md +++ b/packages/kilo-docs/pages/getting-started/using-kilo-for-free.md @@ -24,7 +24,9 @@ Kilo provides free models for coding tasks through the Kilo Gateway and partner The easiest way to get started is [**Auto Free**](/docs/code-with-ai/agents/auto-model) (`kilo-auto/free`). This is a Kilo-provided model tier that automatically routes your requests to the best available free models — no configuration needed. {% callout type="warning" title="Data handling for Auto Free" %} -Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services — including NVIDIA's free endpoints, which are provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) (trial use only, not for production or sensitive data). Do not submit personal or confidential data when using Auto Free. +Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services. Do not submit personal or confidential data when using Auto Free. In particular, it may route to NVIDIA's free endpoints. + +For NVIDIA free endpoints (Super/Ultra/etc): Trial use only - do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://www.nvidia.com/en-us/about-nvidia/privacy-policy/). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). {% /callout %} ### Finding Other Free Models @@ -63,7 +65,7 @@ By default, autocomplete routes through the Kilo provider and uses credits. If y ### How to Get It Free -Add your own Mistral AI (Codestral) API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance. +Add your own Mistral AI API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance. See the [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) for step-by-step instructions. diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index f95b39d715f..62d29dafc20 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -187,7 +187,6 @@ const categories = [ ), links: [ { title: "Deploy", href: "/deploy-secure" }, - { title: "Managed Indexing", href: "/deploy-secure" }, { title: "Security Reviews", href: "/deploy-secure" }, ], }, diff --git a/packages/kilo-docs/pages/kiloclaw/chat-platforms/index.md b/packages/kilo-docs/pages/kiloclaw/chat-platforms/index.md index 69482f76a5c..2e0ee99ea71 100644 --- a/packages/kilo-docs/pages/kiloclaw/chat-platforms/index.md +++ b/packages/kilo-docs/pages/kiloclaw/chat-platforms/index.md @@ -1,13 +1,21 @@ --- title: "Chat Platforms" -description: "Connect your KiloClaw agent to Telegram, Discord, and Slack" +description: "Use Kilo Chat or connect your KiloClaw agent to Telegram, Discord, and Slack" --- # Chat Platforms -KiloClaw supports connecting your AI agent to messaging platforms so it can receive instructions and send responses directly in your chat apps. You can configure channels from the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard#channels), or from the OpenClaw Control UI after accessing your instance. +KiloClaw includes Kilo Chat as its first-party channel and also supports connecting your AI agent to messaging platforms so it can receive instructions and send responses directly in your chat apps. You can configure third-party channels from the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard#channels), or from the OpenClaw Control UI after accessing your instance. -The general steps to connect any chat platform are: +## Kilo Chat + +Kilo Chat is the zero-setup, first-party channel for KiloClaw. It is enabled by default, does not require a per-sandbox channel token, and is available from the Kilo web and mobile apps as well as supported Kilo Code editor and TUI surfaces. + +Use Kilo Chat when you want to talk to your Claw without configuring a separate bot or app in another messaging platform. For external team chat tools, use one of the third-party channels below. + +## Third-Party Platforms + +The general steps to connect a third-party chat platform are: 1. Configure the channel token in Settings 2. Redeploy the KiloClaw instance @@ -16,6 +24,7 @@ The general steps to connect any chat platform are: ## Supported Platforms +- [**Kilo Chat**](https://app.kilo.ai) — Use the built-in first-party channel with no token setup. - [**Telegram**](/docs/kiloclaw/chat-platforms/telegram) — Connect via a BotFather bot token. - [**Discord**](/docs/kiloclaw/chat-platforms/discord) — Connect via a Discord Developer Portal bot token. - [**Slack**](/docs/kiloclaw/chat-platforms/slack) — Connect via a Slack app manifest with app-level and bot tokens. diff --git a/packages/kilo-docs/pages/kiloclaw/dashboard.md b/packages/kilo-docs/pages/kiloclaw/dashboard.md index 6661aa069ba..8534ec22278 100644 --- a/packages/kilo-docs/pages/kiloclaw/dashboard.md +++ b/packages/kilo-docs/pages/kiloclaw/dashboard.md @@ -9,6 +9,10 @@ This page covers everything you can do from the KiloClaw dashboard. For getting {% image src="/docs/img/kiloclaw/dashboard.png" alt="Connect account screen" width="800" caption="The KiloClaw Dashboard" /%} +## Personal and Organization Instances + +The dashboard controls are the same for personal and organization-scoped KiloClaw instances. Organization instances are selected from the organization context and are listed separately from your **Personal** instance. Availability depends on your organization membership and KiloClaw entitlement. + ## Instance Status Your instance is always in one of these states as indicated by the status label at the top of your dashboard: @@ -88,7 +92,7 @@ For access to the full catalog of 335+ models, use the `/model` and `/models` co ### Channels -You can connect Telegram, Discord, and Slack by entering bot tokens in the Settings tab. See [Connecting Chat Platforms](/docs/kiloclaw/chat-platforms) for setup instructions. +Kilo Chat is always available as KiloClaw's first-party channel and does not need a token. You can also connect Telegram, Discord, and Slack by entering bot tokens in the Settings tab. See [Connecting Chat Platforms](/docs/kiloclaw/chat-platforms) for setup instructions. {% callout type="info" %} After saving channel tokens, you need to **Redeploy** or **Restart OpenClaw** for the changes to take effect. @@ -154,7 +158,7 @@ Do not use the **Update** feature in the OpenClaw Control UI to update KiloClaw. When your instance is running, the dashboard shows any pending pairing requests. These appear when: -- Someone messages your bot on Telegram, Discord, or Slack for the first time +- Someone messages your bot on a third-party chat channel for the first time - A new browser or device connects to the Control UI You need to **approve** each request before the user or device can interact with your agent. See [Pairing Requests](/docs/kiloclaw/chat-platforms#pairing-requests) for details. diff --git a/packages/kilo-docs/pages/kiloclaw/development-tools/composio.md b/packages/kilo-docs/pages/kiloclaw/development-tools/composio.md new file mode 100644 index 00000000000..08ac935bc19 --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/development-tools/composio.md @@ -0,0 +1,66 @@ +--- +title: "Composio Integration" +description: "Connect Composio to your KiloClaw agent to access hundreds of tool integrations" +--- + +# Composio Integration + +Connect Composio to your KiloClaw agent to instantly unlock access to 250+ tool integrations — from Salesforce and HubSpot to Notion, Jira, and beyond. Composio is a platform that handles the authentication and connection details for each service, so your agent can use them without you having to set up each one individually. + +{% callout type="info" title="Tip" %} +Browse the full list of toolkits Composio supports at [composio.dev/toolkits](https://composio.dev/toolkits). If a toolkit you need is listed there, you can connect it to KiloClaw through Composio in minutes. +{% /callout %} + +## Prerequisites + +Before you begin, make sure you have: + +- A **Composio account** — sign up for free at [composio.dev](https://composio.dev) +- A **KiloClaw agent** already set up — see the [Dashboard Reference](/docs/kiloclaw/dashboard) if you haven't done this yet + +## Setup + +### Step 1: Create a Composio account and get your API key + +1. Go to [composio.dev](https://composio.dev) and sign up for a free account +2. Once logged in, open the **Settings** or **API Keys** section of your Composio dashboard +3. Click **Create API Key**, give it a name (for example, `kiloclaw`), and copy the key + +### Step 2: Add the API key to KiloClaw + +1. Go to the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard) +2. Scroll to the **Integrations** section and find **Composio** +3. Paste the API key into the **Composio API Key** field +4. Click **Save** +5. **Redeploy** your instance to apply the changes + +### Step 3: Authenticate tools in Composio + +Composio acts as a bridge between KiloClaw and each third-party service. To allow your agent to use a specific tool, you need to authorise it once inside Composio: + +1. In your Composio dashboard, go to **Integrations** or **Connected Accounts** +2. Find the tool you want (for example, Slack, Notion, or Jira) +3. Click **Connect** and follow the authentication steps for that service +4. Once connected, the tool is immediately available to your KiloClaw agent + +Repeat this for each service your agent needs to access. + +## What Your Agent Can Do + +Once connected, your KiloClaw agent can use any tool you have authenticated in Composio. With 250+ integrations available, this includes: + +| Category | Example tools | +|---|---| +| **Project management** | Jira, Notion, Asana, Trello, ClickUp | +| **Communication** | Slack, Discord, Microsoft Teams | +| **Development** | GitHub, GitLab, Bitbucket | +| **CRM & sales** | Salesforce, HubSpot, Pipedrive | +| **Documents & storage** | Google Drive, Dropbox, Confluence | +| **Databases** | Airtable, Supabase, PostgreSQL | + +Your agent can perform actions like reading data, creating records, sending messages, and triggering workflows — all through natural language prompts. + +## Related + +- [Integrations Overview](/docs/kiloclaw/development-tools) +- [GitHub Integration](/docs/kiloclaw/development-tools/github) diff --git a/packages/kilo-docs/pages/kiloclaw/development-tools/index.md b/packages/kilo-docs/pages/kiloclaw/development-tools/index.md index a99a3d705de..97e513d8048 100644 --- a/packages/kilo-docs/pages/kiloclaw/development-tools/index.md +++ b/packages/kilo-docs/pages/kiloclaw/development-tools/index.md @@ -1,13 +1,19 @@ --- -title: "Development Tools" -description: "Connect your KiloClaw agent to development platforms like GitHub and Google Workspace" +title: "Integrations" +description: "Manage integrations and settings for your KiloClaw agent" --- -# Development Tools +# Integrations -KiloClaw supports integrations with popular development platforms, allowing your agent to interact with repositories, code reviews, calendars, documents, and more — all autonomously. +Configure integrations and settings for your KiloClaw agent. Connect third-party services to give your agent access to repositories, issue trackers, calendars, documents, and hundreds of other tools — all without manual intervention. ## Available Integrations - [**GitHub**](/docs/kiloclaw/development-tools/github) — Clone repositories, push commits, open pull requests, and leave code reviews. - [**Google Workspace**](/docs/kiloclaw/development-tools/google) — Access Gmail, Calendar, Drive, Docs, Sheets, Slides, Tasks, and more. +- [**Linear**](/docs/kiloclaw/development-tools/linear) — Create and update issues, read projects, and track work in Linear. +- [**Composio**](/docs/kiloclaw/development-tools/composio) — Access 250+ tool integrations through a single connection. +- [**1Password**](/docs/kiloclaw/tools/1password) — Securely manage credentials and let your agent fetch API keys or passwords without ever seeing them in plain text. +- [**Brave Search**](/docs/kiloclaw/tools/brave-search) — Equip your agent with real-time web browsing via the Brave Search API. +- [**AgentCard**](/docs/kiloclaw/tools/agentcard) — Enable your agent to perform financial transactions using virtual debit cards. +- [**Setting Up Other Tools**](/docs/kiloclaw/tools/other-tools) — Configure your agent to use any third-party tool with a CLI or API. diff --git a/packages/kilo-docs/pages/kiloclaw/development-tools/linear.md b/packages/kilo-docs/pages/kiloclaw/development-tools/linear.md new file mode 100644 index 00000000000..3d12a39fb36 --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/development-tools/linear.md @@ -0,0 +1,64 @@ +--- +title: "Linear Integration" +description: "Connect Linear to your KiloClaw agent to create and manage issues automatically" +--- + +# Linear Integration + +Connect Linear to your KiloClaw agent so it can create issues, update their status, read project backlogs, and track work — all automatically. Linear is a project management tool popular with software teams for planning and tracking features, bugs, and tasks. + +{% callout type="warning" title="Keep your API key private" %} +Your Linear API key grants access to your workspace. Never share it publicly or commit it to a repository. If a key is ever exposed, revoke it immediately from your Linear account settings and generate a new one. +{% /callout %} + +## Prerequisites + +Before you begin, make sure you have: + +- A **Linear account** with access to the workspace you want KiloClaw to use +- A **KiloClaw agent** already set up — see the [Dashboard Reference](/docs/kiloclaw/dashboard) if you haven't done this yet + +## Setup + +### Step 1: Generate a Linear API key + +1. Log in to your Linear account at [linear.app](https://linear.app) +2. Click your workspace name in the top-left corner and select **Settings** +3. In the left sidebar, go to **Account** → **API** +4. Click **Create key** +5. Give the key a descriptive label (for example, `kiloclaw-bot`) and click **Create** +6. Copy the key — you will only see it once + +### Step 2: Add the API key to KiloClaw + +1. Go to the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard) +2. Scroll to the **Integrations** section and find **Linear** +3. Paste the API key into the **Linear API Key** field +4. Click **Save** +5. **Redeploy** your instance to apply the changes + +### Step 3: Verify the connection + +Once your instance has redeployed, send your agent a prompt to check that Linear is working. For example: + +- "List the open issues assigned to me in Linear" +- "What projects are in my Linear workspace?" + +If the agent returns results from your workspace, the connection is working correctly. + +## What Your Agent Can Do + +Once connected, your KiloClaw agent can interact with Linear on your behalf: + +| Action | Example prompt | +|---|---| +| Create an issue | "Create a Linear issue titled 'Fix login bug' in the Engineering project" | +| Update an issue | "Mark the Linear issue LIN-42 as In Progress" | +| Read issues | "Show me all open bugs assigned to the team this week" | +| Search projects | "What issues are in the Backend project?" | +| Add comments | "Add a comment to LIN-100 saying the fix has been deployed" | + +## Related + +- [Integrations Overview](/docs/kiloclaw/development-tools) +- [GitHub Integration](/docs/kiloclaw/development-tools/github) diff --git a/packages/kilo-docs/pages/kiloclaw/end-to-end.md b/packages/kilo-docs/pages/kiloclaw/end-to-end.md index b095bce88ab..2ff8e011726 100644 --- a/packages/kilo-docs/pages/kiloclaw/end-to-end.md +++ b/packages/kilo-docs/pages/kiloclaw/end-to-end.md @@ -21,7 +21,7 @@ We recommend creating **separate accounts** for your KiloClaw rather than connec ### Chat platform options -- **[Kilo Chat](https://app.kilo.ai)** — available in the web app and coming soon to iOS and Android; requires zero configuration +- **[Kilo Chat](https://app.kilo.ai)** — available in the Kilo web and mobile apps, plus supported Kilo Code editor and TUI surfaces; requires zero configuration - **[Telegram](/docs/kiloclaw/chat-platforms/telegram)** — easy to set up, private by default - **[Discord](/docs/kiloclaw/chat-platforms/discord)** — moderate setup - **[Slack](/docs/kiloclaw/chat-platforms/slack)** — most involved setup @@ -66,7 +66,7 @@ A Workspace-managed account benefits from your organization's admin policies, ma ## Set up a messaging platform -Your Claw needs a way to communicate with you. **[Kilo Chat](https://app.kilo.ai)** requires no setup — just open the web app. For other platforms, follow the relevant guide: +Your Claw needs a way to communicate with you. **[Kilo Chat](https://app.kilo.ai)** requires no setup — open the Kilo web or mobile app, or use a supported Kilo Code editor or TUI surface. For other platforms, follow the relevant guide: - [Telegram](/docs/kiloclaw/chat-platforms/telegram) — about 2 minutes - [Discord](/docs/kiloclaw/chat-platforms/discord) — about 10 minutes @@ -160,4 +160,4 @@ Or ask your Claw to build a custom skill from scratch — it has a built-in skil **Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive. -You can also use your KiloPass credits — find this under **Profile** in the dashboard. +You can also use your [Kilo Pass](https://kilo.ai/pricing/kilo-pass) credits — find this under **Profile** in the dashboard. diff --git a/packages/kilo-docs/pages/kiloclaw/overview.md b/packages/kilo-docs/pages/kiloclaw/overview.md index 7a1c518d10d..a55e43395fa 100644 --- a/packages/kilo-docs/pages/kiloclaw/overview.md +++ b/packages/kilo-docs/pages/kiloclaw/overview.md @@ -1,19 +1,20 @@ --- title: "KiloClaw" -description: "One-click deployment of your personal AI agent with OpenClaw" +description: "One-click deployment of your Kilo-hosted AI agent with OpenClaw" --- # KiloClaw 🦀 -KiloClaw is Kilo's hosted [OpenClaw](https://openclaw.ai) service — a one-click deployment that gives you a personal AI agent without the complexity of self-hosting. OpenClaw is a 24/7, open source AI agent that connects to chat platforms like Telegram, Discord, and Slack so it can take real actions automatically, not just chat. +KiloClaw is Kilo's hosted [OpenClaw](https://openclaw.ai) service — a one-click deployment that gives you a personal or organization-scoped AI agent without the complexity of self-hosting. OpenClaw is a 24/7, open source AI agent that connects to Kilo Chat and optional chat platforms like Telegram, Discord, and Slack so it can take real actions automatically, not just chat. -KiloClaw is powered by KiloCode. The API key is platform-managed, so you never need to bring your own. +KiloClaw is powered by Kilo Code. The API key is platform-managed, so you never need to bring your own. ## Why KiloClaw? - **No infrastructure setup** — Skip Docker, servers, and configuration files - **Instant provisioning** — Your agent is ready in seconds -- **Powered by KiloCode** — API key is automatically generated and refreshed +- **Kilo Chat included** — Use the first-party Kilo Chat channel without token setup +- **Powered by Kilo Code** — API key is automatically generated and refreshed - **Uses existing credits** — Runs on your Kilo Gateway balance - **Multiple free models** — Choose from several models at no additional cost - **Web UI included** — Access your agent's web interface directly from the dashboard @@ -23,9 +24,10 @@ KiloClaw is powered by KiloCode. The API key is platform-managed, so you never n - **Kilo account** — Sign up at [kilo.ai](https://kilo.ai) if you haven't already - **Model access** — KiloClaw uses **Kilo Gateway by default**, which provides access to **500+ AI models** through a single integration. -You can also run KiloClaw using: +Depending on your setup, you can also use: - **Your own provider API keys (BYOK)** such as Anthropic, OpenAI, Google, or other supported providers. +- **Organization access** if your organization has KiloClaw enabled and you want the instance scoped to that organization. ## Creating an Instance @@ -39,11 +41,19 @@ You can also run KiloClaw using: {% image src="/docs/img/kiloclaw/create-instance.png" alt="Create instance modal with model selection" width="600" caption="Model selection during instance creation" /%} -5. Optionally configure chat channels (Telegram, Discord, Slack) — you can also do this later from [Settings](/docs/kiloclaw/dashboard#settings) +5. Optionally configure third-party chat channels (Telegram, Discord, Slack) — Kilo Chat is already available, and you can add other channels later from [Settings](/docs/kiloclaw/dashboard#settings) 6. Click **Create & Provision** Your instance will be provisioned in seconds. Each instance runs on a dedicated machine with 2 shared vCPUs, 3 GB RAM, and a 10 GB persistent SSD. Once created in a region, your instance always runs there. +## Organization KiloClaw + +If your organization has KiloClaw enabled, you can use an organization-scoped instance for work that belongs to that organization. The core KiloClaw experience is the same as a personal instance, with these differences: + +- Organization instances are separated from your **Personal** instance in KiloClaw lists. +- Provisioning depends on your organization membership and the organization's KiloClaw entitlement. +- Instance ownership and routing are scoped to the organization, so use organization-approved accounts and credentials for connected services. + ## Managing Your Instance The KiloClaw dashboard gives you full control over your instance. @@ -75,7 +85,7 @@ When you initialize a new channel for the first time, or a new device connects t ## Using your OpenClaw Agent -OpenClaw lets you customize your own AI assistant that can actually take action — check your email, manage your calendar, control smart devices, browse the web, and message you on Telegram or Discord when something needs attention. It's like having a personal assistant that runs 24/7, with the skills and access you choose to give it. +OpenClaw lets you customize your own AI assistant that can actually take action — check your email, manage your calendar, control smart devices, browse the web, and message you through Kilo Chat or connected third-party channels when something needs attention. It's like having a personal assistant that runs 24/7, with the skills and access you choose to give it. ### Browser Tool diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/index.md b/packages/kilo-docs/pages/kiloclaw/triggers/index.md index 9b32395a5a0..e46d9fe53ee 100644 --- a/packages/kilo-docs/pages/kiloclaw/triggers/index.md +++ b/packages/kilo-docs/pages/kiloclaw/triggers/index.md @@ -9,6 +9,12 @@ Triggers let external events and schedules drive your KiloClaw agent automatical All triggers are managed from the **Settings** page in the KiloClaw section of the sidebar. +Webhook triggers and scheduled triggers use the same trigger concepts across +KiloClaw and Cloud Agent, but target different agents. Use KiloClaw triggers when +an HTTP event or schedule should deliver a chat message to a KiloClaw instance. +Use Cloud Agent triggers when the automation should start a Cloud Agent session +against a repository. + ## Trigger Types | Type | Description | @@ -29,6 +35,14 @@ Each trigger type has its own set of template variables. See the [Webhooks](/doc When a trigger fires, the rendered message is sent directly to your KiloClaw agent as a prompt. If your instance is configured with a permission model that allows all actions, the agent will execute commands automatically without your explicit approval. This means triggers can cause your agent to take actions without you being aware. Review your instance's [permission settings](/docs/kiloclaw/control-ui/exec-approvals) and prompt templates carefully before enabling triggers. {% /callout %} +## Request History + +Trigger activity appears in request history so you can inspect recent webhook and +scheduled invocations. History entries show the source (webhook or scheduled), +status such as captured, in progress, success, or failed, request metadata, +payload details when available, and links or sharing actions for the resulting +session. + ## Related - [Webhooks](/docs/kiloclaw/triggers/webhooks) diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md b/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md index bb0551cb125..b725593e5a1 100644 --- a/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md +++ b/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md @@ -7,6 +7,10 @@ description: "Run tasks on a schedule using cron expressions" Scheduled triggers let your KiloClaw agent run tasks automatically on a recurring schedule. Instead of waiting for an external event, a scheduled trigger fires at the times you define using cron expressions. When it fires, the prompt template is rendered and delivered as a chat message to your KiloClaw instance, just like a webhook. +Scheduled triggers are one trigger mode shared by KiloClaw and Cloud Agent. In +KiloClaw, the rendered prompt is delivered to the KiloClaw instance on this page; +in Cloud Agent, the same trigger concept starts a Cloud Agent repository session. + ## Setup 1. Go to **Settings** under the KiloClaw section in the sidebar diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md b/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md index 93a135d33bd..1e24c91a876 100644 --- a/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md +++ b/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md @@ -7,6 +7,10 @@ description: "Trigger your KiloClaw agent from external events using webhooks" KiloClaw supports inbound webhooks so external events can trigger your agent automatically. Form submissions, alerts, calendar updates, ecommerce orders, IoT sensor data; anything that can send an HTTP request can kick off a conversation with your agent. When a webhook fires, the payload is rendered through a prompt template and delivered as a chat message to your KiloClaw instance. The agent processes and responds as if you typed it yourself. +Webhook triggers are one trigger mode shared by KiloClaw and Cloud Agent. In +KiloClaw, the rendered prompt is delivered to the KiloClaw instance on this page; +in Cloud Agent, the same trigger concept starts a Cloud Agent repository session. + ## Setup 1. Go to **Settings** under the KiloClaw section in the sidebar diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index 270e4874ccf..cd3f4d6b326 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -1,4 +1,10 @@ module.exports = [ + { + source: "/docs/kiloclaw/tools", + destination: "/docs/kiloclaw/development-tools", + basePath: false, + permanent: true, + }, { source: "/docs/contributing/cline-to-kilo-migration", destination: "/docs/contributing", @@ -607,7 +613,13 @@ module.exports = [ }, { source: "/docs/advanced-usage/managed-indexing", - destination: "/docs/deploy-secure/managed-indexing", + destination: "/docs/customize/context/codebase-indexing", + basePath: false, + permanent: true, + }, + { + source: "/docs/deploy-secure/managed-indexing", + destination: "/docs/customize/context/codebase-indexing", basePath: false, permanent: true, }, @@ -637,9 +649,87 @@ module.exports = [ basePath: false, permanent: true, }, + { + source: "/docs/contributing/architecture/auto-model-tiers", + destination: "/docs/contributing/architecture/cloud-platform#kilo-gateway", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/mcp-oauth-authorization", + destination: "/docs/contributing/architecture/cli-runtime#remote-mcp-oauth", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/organization-modes-library", + destination: "/docs/contributing/architecture/cli-runtime#config-precedence", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/track-repo-url", + destination: "/docs/contributing/architecture/cloud-platform#kilo-gateway", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/voice-transcription", + destination: "/docs/contributing/architecture/vscode-extension#bundled-resources", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/per-message-feedback", + destination: "/docs/contributing/architecture/cloud-security#privacy-logging-and-retention", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/security-agent", + destination: "/docs/contributing/architecture/cloud-platform#security-agent", + basePath: false, + permanent: true, + }, { source: "/docs/contributing/architecture/onboarding-engagement-improvements", - destination: "/docs/contributing/architecture/onboarding-improvements", + destination: "/docs/contributing/features/onboarding-improvements", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/features", + destination: "/docs/contributing/features", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/enterprise-mcp-controls", + destination: "/docs/contributing/features/enterprise-mcp-controls", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/onboarding-improvements", + destination: "/docs/contributing/features/onboarding-improvements", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/agent-observability", + destination: "/docs/contributing/features/agent-observability", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/benchmarking", + destination: "/docs/contributing/features/benchmarking", + basePath: false, + permanent: true, + }, + { + source: "/docs/contributing/architecture/feature-template", + destination: "/docs/contributing/features/template", basePath: false, permanent: true, }, @@ -821,7 +911,7 @@ module.exports = [ }, { source: "/docs/contributing/architecture/vercel-ai-gateway", - destination: "/docs/contributing/architecture/features", + destination: "/docs/contributing/features", basePath: false, permanent: true, }, diff --git a/packages/kilo-docs/public/img/kiloclaw/kiloclaw-architecture.png b/packages/kilo-docs/public/img/kiloclaw/kiloclaw-architecture.png deleted file mode 100644 index 30e085f9cb0..00000000000 Binary files a/packages/kilo-docs/public/img/kiloclaw/kiloclaw-architecture.png and /dev/null differ diff --git a/packages/kilo-docs/public/img/npm-package-readme/kilo-cli.png b/packages/kilo-docs/public/img/npm-package-readme/kilo-cli.png new file mode 100644 index 00000000000..c045b7940be Binary files /dev/null and b/packages/kilo-docs/public/img/npm-package-readme/kilo-cli.png differ diff --git a/packages/kilo-docs/public/img/organization-modes-library-1.png b/packages/kilo-docs/public/img/organization-modes-library-1.png deleted file mode 100644 index 083e35a9133..00000000000 Binary files a/packages/kilo-docs/public/img/organization-modes-library-1.png and /dev/null differ diff --git a/packages/kilo-docs/public/img/organization-modes-library-2.png b/packages/kilo-docs/public/img/organization-modes-library-2.png deleted file mode 100644 index ac7dc6b64a9..00000000000 Binary files a/packages/kilo-docs/public/img/organization-modes-library-2.png and /dev/null differ diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png index 8555653044c..0438ce9395f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc50e7ce14bc197134d5fdf38ac92087940906f7b627e2507c082a5be0527d31 -size 17378 +oid sha256:e8318f427b6c0ff71d4cce5c8ac03ba4778f99fda5723d24d23c38471523b622 +size 52203 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index fab66b07794..37a0a3a3a86 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3838b7760984c6c822078de1bdb74067839775d29ab8a0435626d0afff8cbb30 -size 24826 +oid sha256:1bd31cd4e01c2e2a5ed579bce1b542368e7a11fc878d375a93fb238ba4f82469 +size 53288 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png new file mode 100644 index 00000000000..17e37e9de1f --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47b7b7bfb4a6b5631d8fdb808c1b3a9eb631b6f96911ddf15880f0c2fee4f7e0 +size 38558 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png new file mode 100644 index 00000000000..a4234ebfc91 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-1280-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:270d3ecab98c4462a0e6af3e8fe43b3313da03759c507914d0b1048045474f1e +size 50886 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png new file mode 100644 index 00000000000..a9c0ac26a82 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b0cb661ae199182341c0034495298cb939ed5487ed8d1dcd41bb558eda8d0e3 +size 45709 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/sidebar-search-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/sidebar-search-open-chromium-linux.png new file mode 100644 index 00000000000..3a21dccf87c --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/sidebar-search-open-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29bb5361de5ac4b7acf247b8f9976c715c4e300448f6e504ec791566eca91219 +size 25750 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png index 6ab79b4689a..23c66f21c9f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7ec87f1e046cf1a44688b952c0d994899119c6663bf627026688bf337b56127 -size 2411 +oid sha256:1370f2dcf5c50258971e0665ea151f2c522f566619745d58d6a10f3914949e6c +size 2521 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png index 905e28d6b85..8e3f4a2492e 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ee0856c65e445a230cf3e94bfe592c98306b7502e44a2602b5fac2f6d69937a -size 4601 +oid sha256:b8af37e0c169811092437b71110c63571ae43c4d35e12f6890be8295a3cb67da +size 4569 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png index 83fda13fbb4..3eb073991f9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1ee2d880a055b03664d594598a6e8764ab27d443a578572fd01b37451cb0193 -size 3193 +oid sha256:16fdb6361199c58a8e99ad2661dc47791b0f4737ada16760151a26e7ebdd8bd1 +size 3044 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png new file mode 100644 index 00000000000..caff5a18c03 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-agent-manager-completed-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:181609c7545d67f4a4b9c948293984ecf18c940a41d67c488fa760965d109ce5 +size 13437 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png index 2cd1d92c8c1..96ea7b460ed 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7eede94f30f617eab9435e21861f2d56b85bdf347c94f1acfdedef0be65ee77 -size 16452 +oid sha256:cd6334875ae3c633396b90ffbcbd53e5567668e430c8ca97a4c1cc8bc30a0585 +size 17933 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png new file mode 100644 index 00000000000..7040705a68e --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-1280-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f104b9ceef97c0dd5f5522745fb573189ba562a60b1f73196565e1b5a4b7dc6 +size 37225 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png new file mode 100644 index 00000000000..22456a13149 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4cf97da43aa741ac4227026dbb4f16a3a2e44a8394a4f0ab321a0938a5d5fb6e +size 32313 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png index 4c8042cccf2..7f31e6a1d50 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e929c1f6dd90495d14edea552acfd2f1a3a609d1c3713651f5e11f40aac19309 -size 8120 +oid sha256:7294cf96505d165a74609912f17be2da7318ea43517dc329370e31d60aa835fe +size 9900 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-pending-question-empty-input-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-pending-question-empty-input-chromium-linux.png index 2efeec8e48b..5c07196de4b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-pending-question-empty-input-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-pending-question-empty-input-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8476ecb49183dbfed9be2924a8aa4622e8b95eb098c50609b986f9b8eb6cfe2d -size 14390 +oid sha256:c0a184f1d7c71f9877c4918e93b7e18f2ebe699f802c46846bd60f29459dbc21 +size 15864 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/error-display-data-policy-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/error-display-data-policy-chromium-linux.png new file mode 100644 index 00000000000..010968eb6a0 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/error-display-data-policy-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3050c782d797be7dad951b76199df2c87195415e699d3e8481bbf40ad0dfccf +size 10073 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png index 1353b857b46..2aed188bd65 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54c85aa9565b91d2a1f848e02d2b21883dc05cb57df92fd359df83c207995455 -size 10247 +oid sha256:16c736bff5fba46d9e7d88e852ad70f96e37ad000280b03cb393da9c970e2ae9 +size 1415 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index edc40570888..e4b892ac849 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dbaab3a1ff10eac8f710b0ed8ddc99d96188c06a09c227e634ca712bcee12130 -size 11543 +oid sha256:288e4d74c5e74773cea45e3b7cb428903ace337f9d27a3506c9ba193fe291b86 +size 3041 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-many-options-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-many-options-chromium-linux.png index ab7338e4f98..dd7ce407292 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-many-options-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-many-options-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ac3951c6ce87f25da24a146dd5420528392720e081688646b67f33632bb6152 -size 28951 +oid sha256:bb4ff7b2909397b635c29603e93956ef16f12ffb1c3ac77f9f4c5c3fd7a07389 +size 28876 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-multi-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-multi-chromium-linux.png index cffb0b8544a..01bb2d85e90 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-multi-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-multi-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:381b72a63cc256f520e671460431f9dc9950d27fd727d0779d1a6b558c7593cd -size 18195 +oid sha256:e74a00c414bf627c41e0841380dcd359cc7398111caa0545aafb7681cf76ea7e +size 18128 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-single-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-single-chromium-linux.png index 5b2984973a6..52aa2e46b82 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-single-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-single-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a0460abb79716b44ec7e9eb42c8d364c2e6737fc79042ca6b715dffe33b1a74 -size 24548 +oid sha256:6e4e9484d65b9dfab86a970db0242366566c2f345f13cab6fb523cc1a625873b +size 24479 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/suggest-bar-review-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/suggest-bar-review-chromium-linux.png index 27e9b8983a1..b26984c98f1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/suggest-bar-review-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/suggest-bar-review-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eee5f5d61cf2ac9ad0b987a8b220d76c503be0d11447766e6669da4684698c82 -size 6130 +oid sha256:bbdf3df267cb9c32febbaa486b7a63e8561e1d968bda24bb5278634307eac873 +size 5962 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-failed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-failed-chromium-linux.png new file mode 100644 index 00000000000..757e3d3a669 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-failed-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b8208c98b0ab3551094af455bf2c7a947c2973f0adb6fe6fa99f16da970454a +size 1184 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png new file mode 100644 index 00000000000..00e71e588fb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4544323cd000c4f43b88c420bbf2b7cb72317b6d40b0b0f3c690fb9deabaa27d +size 3737 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-review-comments-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-review-comments-chromium-linux.png new file mode 100644 index 00000000000..606fab3eb74 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-review-comments-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ae8197b9d63cf977fa5112a1b02eb48bc22a1577b4dee15abd475805f0db5b0 +size 11769 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png index 96ce67cb9ae..1aeb2b3ea8d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d2cdb6dfc9a907b8ff98bb1982f324efa6d19e4ee98f9f690b8adbc2a0a163de -size 28994 +oid sha256:f0c612b61586c3f71b468e49c0ceab2ac2b8ef630d9e8aee1db63b2e6d7a74c7 +size 32345 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png index ddcba78b314..895e500e4bb 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cabfff215cef485ec913b9a082aaac844275e506e88ba0fab112d95ff6de329 -size 18200 +oid sha256:fc84c90016236e0c08dea8989c2124092d167d1d94136c33484ece3a54525250 +size 18881 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png index b557ae59935..95185819c19 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/background-process-tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18d394d726ebee312e54db5d45f55cca3a1728d2b41f9f73bb9b610096bf53e0 -size 41004 +oid sha256:209002988d276858f6e6adae0083ff84c9a1b853ef396bbe4c8121e678121065 +size 44104 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png index be5844e9f96..ba354fdf8a3 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1984de7e73137893e1556a90ff059e666723322cc10c9fb54e17e3eb1f1ce32a -size 12423 +oid sha256:234454319a6d8ce9e513411c7eb94865053bd24b8ac6624b08151af6658ac7ac +size 14909 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png index dad76b65cf7..8e57e390eb6 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5584b7b6d9cc3b80082e1d6fb24eda43d084fb437cf10c15fa3117e99ad9e89 -size 4300 +oid sha256:222790e6c32346cf6b6cc52a0859ee01d364b52a1ef8b9f385a6e9798adfccfb +size 4256 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-idle-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-idle-chromium-linux.png index bb29a832f34..8853ba58e30 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-idle-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-idle-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b6d383088e69f94aeed3e9291092d1278f8346b2d50889bd83ecc72db5d13b7 -size 2846 +oid sha256:7bddc0f45b57b5ed7222f0d0c325224a9569a87cd6d893cd1565130a2a524bea +size 2830 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png index d341a7a2c72..89de007ed93 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:067a5c757dfa32d27955dd634b8dea52edef2141111a5eecee0161df36c0ed8b -size 7061 +oid sha256:acccbf1fa9a76076f324031ed4d7e9ac317059a127c238cf9eaa9e4569dd38b2 +size 7023 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png index 21b51fd2228..ffc6b05851c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52722d7eeca414000bac9be9e25a842acf162bbf939bbe603221017bbe37a0ee -size 13477 +oid sha256:0a80f1cfd15e41bff690dda11e083b45dd0dba6936833bbb1292fd20e4e8e3a4 +size 15091 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-cards-chromium-linux.png index 4ec7183fdad..0dc50184f6f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-cards-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c583144b11ac9608ec755e9a683dced8ae6ffae9a6f3833e1c0eec6b664f358e -size 7720 +oid sha256:516db3d182497211aa32a32d8b33de309967be93e56a348f0397ad8eaef57eec +size 7014 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png index e4954fe821a..4ac4916fcf1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0af006973871a81b4c0ae2fc6c98c88151655afbd9e63d77e970c18db861355 -size 26371 +oid sha256:c3ebab71919a7bafe609933ba8909f22e68171d3546473cca7ad5e3d859787ef +size 30655 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png index 3937f335fc9..844596d44d6 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c05d543611aff7bf10ef406de42844fa1aa825275e380c6ded05d03170aa9b0 -size 8057 +oid sha256:76b359a64ad4dede420eb4669f973f80f87be63b5d0a1dfe36829f0c19698381 +size 7486 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png index db19d08e861..f38949019a1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97728b91ed1c92556abfde08824b91536e87cc7afa4d121bb5b1b488946c1d05 -size 26009 +oid sha256:e872bc8ad05ad2a7ad1dd6de8a2ff427957fd278cee46940db0b12d84fe42ecf +size 25534 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png index 9ac645dc422..c19ee28042d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40b9b706914367e13d829e711f2215bfe6f75a2d6b024858cf56a0e55a080103 -size 15976 +oid sha256:6f7e685eee7798085c2f68e3909b97c5cdef4610a5172ac29ec990e02d164e43 +size 17881 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png index 0aadc161dc2..0eff7b16fa8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7b8e9f6e0b9a9a62efc9b1e807936049c7627674ba241239dfd777698f951acc -size 21820 +oid sha256:ff33e800f4d2379bcc997228a1e4dd83433521b931ae85edb7c8f0c4c93405e7 +size 20540 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png index 878d15c4706..ddc01144048 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0268de1b89b0af37f0eca8eb4c6244b9d49c1665b92c4402432d8efa0615e0a9 -size 15590 +oid sha256:61b1d0bc725e3b33a40d1d777d71b70afcec1b6cdcfae15e2023667bc4574f23 +size 17201 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png index 889daa6e724..6a4cdb2424b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1ca383c2a5fa9d6f477430d7c63c7330e13e2667bf717906d3c29c215f163c5 -size 21564 +oid sha256:31a447c3602b2959fde3da003baa05901d4d68e935f245c63d7080a574ef7b53 +size 22366 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png index d367c92ca94..be6a5d5bff0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed1f050e35ebefc2b9ca1b73bbd37d23bbf882d0e0de4c6e88ca03d67899788f -size 13608 +oid sha256:836d6b819a7108510a2abd5db6c9307945a7489c5a2b240035bfc1acb3405385 +size 13009 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png index 588f5fdd2cf..b4ad5e3c158 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34965c1e34d6186515fd826abc43d68d74e53484e40b4b1ff04b7b9d82a6f94f -size 13291 +oid sha256:19395088343e6546ae2f8d3232b8e1e17e5db482841dcf516a80c40fcbc8604a +size 14912 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png index 6336b90f9cd..e01e50db549 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62fbc073856f73a4b25deceef1e0af3e711ec413ef4b9ec2e300fef8d98dc538 -size 13113 +oid sha256:45f622206510533051173fbab26f30c1359d25b9baff7d515c05b664b16d7aa4 +size 14734 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png index 415c576791d..3c20cdaab65 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2113bfbfbfa54af9cb7d94724edbb361a48bf3501270d7e692df9e4b6752d4ed -size 14222 +oid sha256:d76fe59b3a6b07d89243d1f9bc434eb3e813d361e0e59db97bd4916097279e2a +size 15842 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png index 42ea4dd8ab4..e32553cc476 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5223aa923783e200838f744eba2b5adc4aae18575412bb05f32606f2a5c823e -size 16857 +oid sha256:e5b64674aaceb051ba48bf7db489845f50c8bd7f65a108685dda5243103c735a +size 18477 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png index 1260df31e9d..52aaff1f7d3 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1ebe718b7747f0b0db029396c728ae859f388f11cf206bb020fcd7027f3b0a0 -size 5046 +oid sha256:cba6f84ec6138fc59be088cdf84d2ebe57a55f8514df58eed693a84969ff7a90 +size 4794 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png index bb7d8b71d2d..71be8a86f7b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b47f6adfc72399cb1535be3a69661b027eec3068880aba35821d5240e0c3492 -size 16739 +oid sha256:1adee4d2de92696f86183a38fabd14dc38be1b818100092f2014017e4de12b8c +size 16710 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-completed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-completed-chromium-linux.png index c7661c4e1f3..2c3bf2f3427 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-completed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-completed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a308ad1c07d7af681670853d5e0f40b01ee88ad4e863dd5b84c0ab511757cdf -size 6134 +oid sha256:56c7610136e6a015393f5e19b239ff446ec78ad34c75d3dda1e6d6870964f501 +size 6340 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png index c8ecdbcbb55..2b8bef21b2f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4d7ac9d6322d2cf673a6353cb07cf9979ad55351769ba9d90eed386170f6c42 -size 40854 +oid sha256:146055492419facf8997d352f47ee80e9f205b60cc5345cf266683bc10518a8f +size 37718 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png index a244deeaa87..5b9a04e30d0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a35c54af1c6d5f89fefcb651ef45deb127e1862f848560183abe206f99223ae -size 14179 +oid sha256:99f328f5169276d97c51d182185a920e8b45ecd0f3c699a64ce8e1fc7ad6196d +size 15361 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-cards-chromium-linux.png index 7959059a155..f0969249c8d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-cards-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d21dc25072b1c52cacaf0a91369f45e89f43ccc181d643e6b65030b829e5b1e -size 8591 +oid sha256:0539bd8e7296f0a57af343b48b311f35292dd3b8eef257fb713cef457ea63a74 +size 7150 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-errors-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-errors-200-chromium-linux.png new file mode 100644 index 00000000000..688cc7d77e8 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-errors-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9233fec056782bc365cfbbeca8533f63a287e8363455826821d6c4c2fb99d841 +size 7619 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-errors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-errors-chromium-linux.png new file mode 100644 index 00000000000..d1b06a67b9e --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-errors-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8296de6d4c8196db8566f622037cd2f1559bcc4523294fec2e1d84602edf23e8 +size 8469 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png new file mode 100644 index 00000000000..d64935ee798 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/sources-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4caad41e47c02636156c91066ef88dc4cb0df298ef1a0f2048cad12aafd985ee +size 21865 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/with-items-chromium-linux.png index 74a826d3453..8f2b4c1f3fa 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65686e2d0978adcf09ae504d9414e2d81f304eb18ff2275be7a33a9c3579a5e5 -size 18209 +oid sha256:32fa296aeee4f45f1cefe49d8eaca08099a8b3b8c23d4e9cdec803ce7fd1f0a1 +size 18207 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png new file mode 100644 index 00000000000..17c5f71238b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c87ff987a67d2e1221ca1dffd0c2bbc5b0037853624da6820d2589b238ec98c8 +size 181061 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png new file mode 100644 index 00000000000..fd503e50e61 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7338febdf802b5ce501773be164e9da152fb7903e9b16807bd8751fa38f208ed +size 10310 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png new file mode 100644 index 00000000000..071927ae44d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7e075b4961d3a62c8deda6b481f64cb8777d05183a9c10d0dd2373b7972d3ab +size 53678 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png new file mode 100644 index 00000000000..158e7e0bdcc --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0dd1dc26b28d354ecd14c53dd1b9e6158a161f47152f30299ba4c0827ce1674 +size 50904 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-mode-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-agent-card-chromium-linux.png similarity index 100% rename from packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-mode-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-agent-card-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-empty-chromium-linux.png deleted file mode 100644 index f190c5fac6e..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-empty-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2c925148c349c6ab1f14190a2a379aa24869de35e5b77455fa15d8868eb08343 -size 5943 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-installed-chromium-linux.png deleted file mode 100644 index 180572497e7..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-installed-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5094e214a52bac4627f8fe280f4818c986753c49f160bf5295f21122054ea72f -size 53684 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-items-chromium-linux.png deleted file mode 100644 index b8b99ed8d51..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-items-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:83a70ef1260f77c3017db90a918ee77c332c065bfc047e0f206948bb5dde83b9 -size 50888 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-mode-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-agent-card-chromium-linux.png similarity index 100% rename from packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-mode-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-agent-card-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/migration/roo-wizard-selecting-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/migration/roo-wizard-selecting-chromium-linux.png new file mode 100644 index 00000000000..5a1446777b1 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/migration/roo-wizard-selecting-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06c6cd2c2c63745d3cbaec756ae852db3ef0749c005926b75fe3eb03f06b0495 +size 22169 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png index 6beec18acc5..14bac93660a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88d2598f448e68b32b3daef5420a3e31ae5fdafe77ced20aadbc4fdf4c1ab84a -size 4682 +oid sha256:4f395ec688aa5adc800b2145615691ee9bfcc1b5a365600105dd1a17f28e343c +size 6118 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png index 19e7ee32a14..bba8ab7e265 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b74f0ca7296bb81df65fe6628bc8709d743bf46f99b4c7cda67d5d1ae7c55d22 -size 47647 +oid sha256:72b04f729ebf5b25e54dc9f66aa04fbc2ec888e3396df3e34a9b8f9457117763 +size 47249 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-empty-chromium-linux.png index d9796b01032..e588cecbdd7 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b70543ee69296913543f8de0f1b1d4787fb6be8a9e410423073b0a84a6da6305 -size 21290 +oid sha256:fe0ed476823eb03bc50fec4e9cdb656552aab3b07ee6ed3c30265ed41f0bfda0 +size 21344 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png new file mode 100644 index 00000000000..0230012032a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d5db71fbed0542dd8ef3101d7b56b9538b9e6411921150d0957996acd620fde +size 51161 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png new file mode 100644 index 00000000000..21f11b47d28 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c501793cb8d0ad50b2d8d283a6314ce4b21dabdc0e95f63174c4a7e475523e32 +size 54055 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png index 18c542093c0..041e7f16fee 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856 -size 57421 +oid sha256:8d280dc15c3e64ddf365e1bdaa572d07da0199d0cb9d37acb174ae70db5ae746 +size 57329 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-scope-switch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-scope-switch-chromium-linux.png new file mode 100644 index 00000000000..cda24ddb934 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-scope-switch-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16b0e2bed3c576328a5565ae957c3af4abbc884a0db894472f587b29e1c355b4 +size 56194 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png new file mode 100644 index 00000000000..34dae072479 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e041b27abbb19de884e1b928d415d49182872b06018411ed589197424a5ac22 +size 48674 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png index e395b8b062d..34dae072479 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdcac95e7969d021ebce19001f37f86b8ab670bd79e39086385c9419617294df -size 27177 +oid sha256:9e041b27abbb19de884e1b928d415d49182872b06018411ed589197424a5ac22 +size 48674 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-speech-to-text-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-speech-to-text-chromium-linux.png new file mode 100644 index 00000000000..866111072a1 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-speech-to-text-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ced53d4974eeacc629eede823c7e37191f40ea2816f80f18c1de1a833fbcc614 +size 45341 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png index cbcae5fd2ad..37cc654dcef 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25f9a2c9499801c7526c6e0dd60a17ecacb873e69e02e7073f33e07b4c96841d -size 28094 +oid sha256:4872106b43a1e7e505d0cb1807fa83455a739ec14e86b65a5369c56afc3e7df6 +size 26645 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 new file mode 100644 index 00000000000..8cde4b3acf0 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/sandboxing-panel-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a817c2b87e1d7b28a7c28deaa8fd8d1e9c51aa2daabda1a2de4a0db0a33fcc17 +size 27768 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 77079da8613..545366bac04 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:73a34a22b24e42731510f9ecd3ea8827a0e39fa9b335294c45c4e8780df9a1f4 -size 35853 +oid sha256:f4dbd3be2cdf72a789c17076afa73ddeb2eacbe201ace8d8a2657eb95b23a413 +size 53851 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-200-chromium-linux.png new file mode 100644 index 00000000000..ade4c0398da --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:001db2cec148c97a321076e45ec4478beced1bd18f0f4347927419d7194d6a43 +size 31986 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png new file mode 100644 index 00000000000..2597a60c84d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3f86dabc39700b3a86508c1e6499a546eeabf720832b62c9ce12f2d97378ebd +size 34649 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png new file mode 100644 index 00000000000..f6894817821 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-accessible-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838 +size 1085 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png new file mode 100644 index 00000000000..f6894817821 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-selected-favorite-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838 +size 1085 diff --git a/packages/kilo-docs/public/img/track-repo-url-system-design.png b/packages/kilo-docs/public/img/track-repo-url-system-design.png deleted file mode 100644 index 9db8a4c27c7..00000000000 Binary files a/packages/kilo-docs/public/img/track-repo-url-system-design.png and /dev/null differ diff --git a/packages/kilo-docs/public/img/voice-transcription-architecture.png b/packages/kilo-docs/public/img/voice-transcription-architecture.png deleted file mode 100644 index 51d32d16dc2..00000000000 Binary files a/packages/kilo-docs/public/img/voice-transcription-architecture.png and /dev/null differ diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index f1c023caaa5..ce7301c2770 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,13 +1,14 @@ # Source Code Links - - - +- + - - @@ -26,6 +27,8 @@ - +- + - - @@ -36,19 +39,23 @@ - +- + - - - +- + - -- - - -- +- + +- - @@ -56,6 +63,8 @@ - +- + - - @@ -79,7 +88,6 @@ - - - - @@ -87,10 +95,14 @@ - +- + - - +- + - - @@ -99,6 +111,7 @@ - + - @@ -106,57 +119,43 @@ - +- + - - +- + +- + - - - + - +- + - - -- - -- - - -- - -- - -- - -- - -- - - -- - + - -- +- -- - - -- - -- - -- - - +- + - - @@ -191,6 +190,8 @@ - +- + - - diff --git a/packages/kilo-gateway/.gitignore b/packages/kilo-gateway/.gitignore new file mode 100644 index 00000000000..b6f2962c39e --- /dev/null +++ b/packages/kilo-gateway/.gitignore @@ -0,0 +1 @@ +.artifacts diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index dc0b0236abd..b755d4912fd 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.3.8", + "version": "7.3.54", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", @@ -17,6 +17,10 @@ ], "exports": { ".": "./src/index.ts", + "./autocomplete": "./src/autocomplete.ts", + "./fim": "./src/fim.ts", + "./edit": "./src/edit.ts", + "./edit-prompt": "./src/edit-prompt.ts", "./tui": "./src/tui.ts" }, "files": [ @@ -24,16 +28,17 @@ ], "scripts": { "typecheck": "tsgo --noEmit", - "build": "tsc" + "build": "tsc", + "test:ci": "mkdir -p .artifacts/unit && bun test test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "dependencies": { "@kilocode/plugin": "workspace:*", - "@kilocode/sdk": "workspace:*", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", - "@ai-sdk/openai": "3.0.48", - "@ai-sdk/openai-compatible": "2.0.37", - "@openrouter/ai-sdk-provider": "2.8.1", + "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai-compatible": "2.0.48", + "@ai-sdk/mistral": "3.0.27", + "@openrouter/ai-sdk-provider": "2.9.0", "@clack/prompts": "1.0.0-alpha.1", "ai": "catalog:", "open": "10.1.2", @@ -45,8 +50,8 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:", "solid-js": "catalog:", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75" + "@opentui/core": "catalog:", + "@opentui/solid": "catalog:" }, "peerDependencies": { "solid-js": "*", diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 8c556af15f0..c669b52008f 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -100,4 +100,11 @@ export const PROMPTS = [ "gpt55", ] as const -export const AI_SDK_PROVIDERS = ["alibaba", "anthropic", "openai", "openai-compatible", "openrouter"] as const +export const AI_SDK_PROVIDERS = [ + "alibaba", + "anthropic", + "mistral", + "openai", + "openai-compatible", + "openrouter", +] as const diff --git a/packages/kilo-gateway/src/api/embedding-models.ts b/packages/kilo-gateway/src/api/embedding-models.ts index 475d8d25249..9809ddf2a07 100644 --- a/packages/kilo-gateway/src/api/embedding-models.ts +++ b/packages/kilo-gateway/src/api/embedding-models.ts @@ -15,6 +15,12 @@ export type KiloEmbeddingModelCatalog = { aliases: Record } +export type KiloEmbeddingModelCatalogIssue = { + code: "http" | "invalid-response" | "network" + message: string + status?: number +} + export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = { defaultModel: "", models: [], @@ -39,25 +45,66 @@ type Options = { baseURL?: string token?: string signal?: AbortSignal + attempts?: number + onError?: (issue: KiloEmbeddingModelCatalogIssue) => void +} + +const retryable = (status: number) => status === 408 || status === 425 || status === 429 || status >= 500 + +function wait(ms: number, signal?: AbortSignal) { + if (signal?.aborted) return Promise.reject(signal.reason) + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer) + reject(signal?.reason) + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort) + resolve() + }, ms) + signal?.addEventListener("abort", abort, { once: true }) + }) } export async function fetchKiloEmbeddingModelCatalog(options: Options = {}): Promise { const url = new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: options.baseURL, token: options.token })) + const requested = options.attempts ?? 3 + const attempts = Number.isFinite(requested) ? Math.min(3, Math.max(1, Math.floor(requested))) : 3 + const issue = { current: undefined as KiloEmbeddingModelCatalogIssue | undefined } - try { - const response = await fetch(url, { signal: options.signal }) - if (!response.ok) { - console.warn(`[Kilo Gateway] Failed to fetch embedding model catalog: ${response.status}`) - return EMPTY_KILO_EMBEDDING_MODEL_CATALOG + for (const attempt of Array.from({ length: attempts }, (_, index) => index)) { + if (options.signal?.aborted) throw options.signal.reason + try { + const response = await fetch(url, { signal: options.signal, redirect: "error" }) + if (!response.ok) { + issue.current = { + code: "http", + message: `Unable to load Kilo embedding models (HTTP ${response.status}).`, + status: response.status, + } + if (!retryable(response.status) || attempt === attempts - 1) break + await wait(200 * 2 ** attempt, options.signal) + continue + } + const body = await response.json().catch(() => undefined) + const parsed = catalog.safeParse(body) + if (parsed.success) return parsed.data + issue.current = { + code: "invalid-response", + message: "Kilo returned an invalid embedding model catalog.", + } + break + } catch (err) { + if (options.signal?.aborted) throw options.signal.reason + issue.current = { + code: "network", + message: "Unable to connect to Kilo to load embedding models. Check your network connection and try again.", + } + if (attempt === attempts - 1) break + await wait(200 * 2 ** attempt, options.signal) } - const parsed = catalog.safeParse(await response.json()) - if (!parsed.success) { - console.warn("[Kilo Gateway] Embedding model catalog response validation failed:", parsed.error.format()) - return EMPTY_KILO_EMBEDDING_MODEL_CATALOG - } - return parsed.data - } catch (err) { - console.warn("[Kilo Gateway] Error fetching embedding model catalog:", err) - return EMPTY_KILO_EMBEDDING_MODEL_CATALOG } + + if (issue.current) options.onError?.(issue.current) + return EMPTY_KILO_EMBEDDING_MODEL_CATALOG } diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index a251eb47d5c..598255e66a0 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -36,6 +36,15 @@ const openRouterModelSchema = z.object({ supported_parameters: z.array(z.string()).optional(), preferredIndex: z.number().optional(), isFree: z.boolean().optional(), + mayTrainOnYourPrompts: z.boolean().optional(), + hasUserByokAvailable: z.boolean().optional(), + terminalBench: z + .object({ + overallScore: z.number(), + avgAttemptCostUsd: z.number(), + }) + .optional() + .catch(undefined), opencode: z .object({ family: z.string().optional(), @@ -183,6 +192,9 @@ function transformToModelDevFormat(model: OpenRouterModel): any { ai_sdk_provider: model.opencode?.ai_sdk_provider, tool_call: supportsTools, isFree: model.isFree, + mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, + ...(model.terminalBench && { terminalBench: model.terminalBench }), ...(inputPrice !== undefined && outputPrice !== undefined && { cost: { diff --git a/packages/kilo-gateway/src/autocomplete.ts b/packages/kilo-gateway/src/autocomplete.ts new file mode 100644 index 00000000000..51d24b7c2f7 --- /dev/null +++ b/packages/kilo-gateway/src/autocomplete.ts @@ -0,0 +1,158 @@ +export type AutocompleteProviderID = "kilo" | "mistral" | "inception" +export type DirectAutocompleteProviderID = Exclude + +interface AutocompleteModelBase { + /** Stable combined value for internal comparisons. */ + readonly id: string + /** Model value stored in settings and sent to the autocomplete API. */ + readonly modelID: string + /** Human-readable label shown in settings. */ + readonly label: string + /** Provider value stored in settings and used by the selector group. */ + readonly providerID: AutocompleteProviderID + /** Provider display name for status bar / telemetry. */ + readonly provider: string + /** Full model ID sent upstream by the autocomplete route. */ + readonly requestModel: string + /** Provider key to use for direct BYOK. Empty means Kilo Gateway. */ + readonly directProvider?: DirectAutocompleteProviderID + /** Request temperature. */ + readonly temperature: number +} + +export type AutocompleteModelDef = AutocompleteModelBase & + ( + | { + /** Route through `/kilo/edit` using the Next Edit pipeline. */ + readonly kind: "edit" + /** Stable combined ID of the FIM model used where Next Edit is unsupported. */ + readonly fimModelID: string + } + | { + /** Route through the FIM endpoint. */ + readonly kind?: "fim" + readonly fimModelID?: never + } + ) + +const models: AutocompleteModelDef[] = [ + { + id: "kilo/mistralai/codestral-2508", + modelID: "mistralai/codestral-2508", + label: "Codestral", + providerID: "kilo", + provider: "Kilo Gateway", + requestModel: "mistralai/codestral-2508", + temperature: 0.2, + }, + { + id: "kilo/inception/mercury-edit-2", + modelID: "inception/mercury-edit-2", + label: "Mercury Edit 2 (FIM)", + providerID: "kilo", + provider: "Kilo Gateway", + requestModel: "inception/mercury-edit-2", + temperature: 0, + }, + { + // Same wire-level model as `kilo/inception/mercury-edit-2`, but routed + // through the Kilo Gateway's Next Edit endpoint instead of FIM. Picked by + // users who want multi-line next-edit predictions with the jump-to-edit UX. + id: "kilo/inception/mercury-next-edit", + modelID: "inception/mercury-next-edit", + label: "Mercury Edit 2 (Next Edit)", + providerID: "kilo", + provider: "Kilo Gateway", + requestModel: "inception/mercury-edit-2", + temperature: 0, + kind: "edit", + fimModelID: "kilo/inception/mercury-edit-2", + }, + { + id: "mistral/codestral-2508", + modelID: "codestral-2508", + label: "Codestral", + providerID: "mistral", + provider: "Mistral", + requestModel: "codestral-2508", + directProvider: "mistral", + temperature: 0.2, + }, + { + id: "inception/mercury-edit-2", + modelID: "mercury-edit-2", + label: "Mercury Edit 2 (FIM)", + providerID: "inception", + provider: "Inception", + requestModel: "mercury-edit-2", + directProvider: "inception", + temperature: 0, + }, + { + // Same wire-level model as `mercury-edit-2`, but routed through the + // Mercury Edit 2 (Next Edit) endpoint instead of FIM. Picked by users who want + // multi-line next-edit predictions with the jump-to-edit UX. + id: "inception/mercury-next-edit", + modelID: "mercury-next-edit", + label: "Mercury Edit 2 (Next Edit)", + providerID: "inception", + provider: "Inception", + requestModel: "mercury-edit-2", + directProvider: "inception", + temperature: 0, + kind: "edit", + fimModelID: "inception/mercury-edit-2", + }, +] + +export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models + +export const DEFAULT_AUTOCOMPLETE_PROVIDER_ID: AutocompleteProviderID = "kilo" +export const DEFAULT_AUTOCOMPLETE_MODEL_ID = "inception/mercury-next-edit" + +export const DEFAULT_AUTOCOMPLETE_MODEL: AutocompleteModelDef = (() => { + const found = models.find( + (m) => m.providerID === DEFAULT_AUTOCOMPLETE_PROVIDER_ID && m.modelID === DEFAULT_AUTOCOMPLETE_MODEL_ID, + ) + if (!found) { + throw new Error( + `DEFAULT_AUTOCOMPLETE_MODEL not found: provider=${DEFAULT_AUTOCOMPLETE_PROVIDER_ID} model=${DEFAULT_AUTOCOMPLETE_MODEL_ID}`, + ) + } + return found +})() + +const aliases: Record = { + "inception/mercury-edit": "inception/mercury-edit-2", +} + +export function getAutocompleteModel(provider?: string, model?: string): AutocompleteModelDef { + // When provider is unset, always default to Kilo Gateway. Direct-provider + // use must be opted into explicitly via the provider setting — never inferred + // from a model name, since the same plain model id can exist on multiple + // providers and we don't want to silently route legacy settings to BYOK. + const pid = provider ?? "kilo" + const mid = aliases[model ?? ""] ?? model + for (const m of models) { + if (m.providerID === pid && m.modelID === mid) return m + } + return DEFAULT_AUTOCOMPLETE_MODEL +} + +export function getAutocompleteModelById(id: string): AutocompleteModelDef { + for (const m of models) { + if (m.id === id) return m + } + return DEFAULT_AUTOCOMPLETE_MODEL +} + +export function validAutocompleteProvider(value: unknown) { + if (typeof value !== "string") return false + return models.some((m) => m.providerID === value) +} + +export function validAutocompleteModel(value: unknown) { + if (typeof value !== "string") return false + const resolved = aliases[value] ?? value + return models.some((m) => m.modelID === resolved) +} diff --git a/packages/kilo-gateway/src/cloud-sessions.ts b/packages/kilo-gateway/src/cloud-sessions.ts index fd5e9d4c991..2752073db19 100644 --- a/packages/kilo-gateway/src/cloud-sessions.ts +++ b/packages/kilo-gateway/src/cloud-sessions.ts @@ -7,6 +7,7 @@ export interface DrizzleDb { } const INGEST_BASE = process.env.KILO_SESSION_INGEST_URL ?? "https://ingest.kilosessions.ai" +const TIMEOUT = 30_000 function exportUrl(sessionId: string) { return UUID_RE.test(sessionId) @@ -18,6 +19,7 @@ export type FetchResult = { ok: true; data: any } | { ok: false; status: number; export async function fetchCloudSession(token: string, sessionId: string): Promise { const response = await fetch(exportUrl(sessionId), { + signal: AbortSignal.timeout(TIMEOUT), headers: { Authorization: `Bearer ${token}`, ...buildKiloHeaders(), @@ -33,6 +35,7 @@ export async function fetchCloudSession(token: string, sessionId: string): Promi export async function fetchCloudSessionForImport(token: string, sessionId: string): Promise { const response = await fetch(exportUrl(sessionId), { + signal: AbortSignal.timeout(TIMEOUT), headers: { Authorization: `Bearer ${token}`, ...buildKiloHeaders(), diff --git a/packages/kilo-gateway/src/edit-prompt.ts b/packages/kilo-gateway/src/edit-prompt.ts new file mode 100644 index 00000000000..02419702fb2 --- /dev/null +++ b/packages/kilo-gateway/src/edit-prompt.ts @@ -0,0 +1,117 @@ +/** + * Mercury Next Edit prompt assembly. Lives in the gateway so every client + * (VS Code, JetBrains, TUI) sends the same structured editor context and the + * Mercury-specific sentinel format is defined in exactly one place. + * + * Tag set is defined by the model and must be reproduced verbatim — see + * https://docs.inceptionlabs.ai/capabilities/next-edit + */ + +const RECENTLY_VIEWED_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>" +const RECENTLY_VIEWED_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>" +const RECENTLY_VIEWED_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>" +const RECENTLY_VIEWED_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>" +const CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>" +const CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>" +const CODE_TO_EDIT_OPEN = "<|code_to_edit|>" +const CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>" +const EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>" +const EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>" +const CURSOR = "<|cursor|>" +/** Trailing token that tells the model this is a next-edit (not chat) request. */ +const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>" + +export interface MercuryRecentSnippet { + filepath: string + content: string +} + +/** Editor-derived context a client sends; the gateway turns it into a prompt. */ +export interface MercuryEditContext { + currentFilePath: string + currentFileContent: string + cursorLine: number + cursorCharacter: number + editableRegionStartLine: number + editableRegionEndLine: number + recentlyViewedSnippets: MercuryRecentSnippet[] + editDiffHistory: string[] +} + +function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] { + if (cursorLine < 0 || cursorLine >= lines.length) return lines + const line = lines[cursorLine] + const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length) + const next = line.slice(0, safeChar) + CURSOR + line.slice(safeChar) + return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)] +} + +export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string { + const inner = snippets + .map((s) => + [ + RECENTLY_VIEWED_SNIPPET_OPEN, + `code_snippet_file_path: ${s.filepath}`, + s.content, + RECENTLY_VIEWED_SNIPPET_CLOSE, + ].join("\n"), + ) + .join("\n") + return [RECENTLY_VIEWED_SNIPPETS_OPEN, inner, RECENTLY_VIEWED_SNIPPETS_CLOSE].join("\n") +} + +export function currentFileContentBlock( + currentFilePath: string, + currentFileContent: string, + editableRegionStartLine: number, + editableRegionEndLine: number, + cursorLine: number, + cursorCharacter: number, +): string { + const rawLines = currentFileContent.split("\n") + const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter) + const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length)) + const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1)) + const instrumented = [ + ...withCursor.slice(0, start), + CODE_TO_EDIT_OPEN, + ...withCursor.slice(start, end + 1), + CODE_TO_EDIT_CLOSE, + ...withCursor.slice(end + 1), + ] + return [ + CURRENT_FILE_CONTENT_OPEN, + `current_file_path: ${currentFilePath}`, + instrumented.join("\n"), + CURRENT_FILE_CONTENT_CLOSE, + ].join("\n") +} + +export function editDiffHistoryBlock(diffs: string[]): string { + // Each unidiff from `diff.createPatch` opens with an Index line + separator we + // strip. Diffs are blank-line separated so the model reads them as distinct hunks. + const trimmed = diffs.map((d) => { + const lines = d.split("\n") + return lines.length > 2 ? lines.slice(2).join("\n") : d + }) + return [EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), EDIT_DIFF_HISTORY_CLOSE].join("\n") +} + +export function buildMercuryEditPrompt(ctx: MercuryEditContext): string { + return [ + recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets), + "", + currentFileContentBlock( + ctx.currentFilePath, + ctx.currentFileContent, + ctx.editableRegionStartLine, + ctx.editableRegionEndLine, + ctx.cursorLine, + ctx.cursorCharacter, + ), + "", + editDiffHistoryBlock(ctx.editDiffHistory), + "", + UNIQUE_TOKEN, + ].join("\n") +} diff --git a/packages/kilo-gateway/src/edit.ts b/packages/kilo-gateway/src/edit.ts new file mode 100644 index 00000000000..7740ced3f91 --- /dev/null +++ b/packages/kilo-gateway/src/edit.ts @@ -0,0 +1,72 @@ +import { KILO_API_BASE } from "./api/constants.js" +import { getAutocompleteModel, type DirectAutocompleteProviderID } from "./autocomplete.js" + +/** + * Env var(s) consulted as a fallback for BYOK keys when the provider hasn't + * been authenticated via the gateway's Auth store. Mirrors `DIRECT_FIM_ENV`. + */ +export const DIRECT_EDIT_ENV: Record = { + mistral: ["MISTRAL_API_KEY"], + inception: ["INCEPTION_API_KEY"], +} + +export type EditTarget = + | { provider: "inception"; model: string; url: string } + | { provider: "kilo"; model: string; url: string } + +/** Shape of the upstream (Mercury) chat/edit completion response we read from. */ +export interface EditUpstreamResponse { + choices?: Array<{ message?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number } +} + +const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions" +const KILO_NEXTEDIT_URL = KILO_API_BASE + "/api/edit/completions" + +/** + * Pick the upstream edit endpoint for a (provider, model) pair. Today this is + * either Inception's `/v1/edit/completions` (direct BYOK) or the Kilo Gateway's + * `/api/edit/completions` proxy, which forwards to Inception server-side. + * Mistral does not expose a comparable surface. + */ +export function resolveEditTarget(provider?: string, model?: string): EditTarget { + const info = getAutocompleteModel(provider, model) + if (info.kind === "edit") { + if (info.providerID === "kilo") { + // The gateway expects the upstream model id with the `inception/` prefix + // (it strips it before forwarding to Inception). The kilo entry's + // `requestModel` already carries the prefix. + const m = info.requestModel.includes("/") ? info.requestModel : `inception/${info.requestModel}` + return { provider: "kilo", model: m, url: KILO_NEXTEDIT_URL } + } + if (info.directProvider === "inception") { + return { provider: "inception", model: info.requestModel, url: INCEPTION_EDIT_URL } + } + } + // Non-edit models fall through to a kilo placeholder with no URL so the + // handler can surface a 400 rather than silently routing somewhere unexpected. + return { provider: "kilo", model: info.requestModel, url: "" } +} + +/** + * Mercury wraps the rewritten editable region in a triple-backtick fence, + * sometimes with a language tag and sometimes with `<|code_to_edit|>` sentinels + * inside. Strip all of that down to the bare code. Shared by both the hono and + * the Effect HttpApi edit handlers so the parsing can't drift between them. + */ +export function extractFencedBody(message: string): string { + if (!message) return "" + const fenceOpen = message.indexOf("```") + if (fenceOpen === -1) return message + const afterFenceOpen = message.indexOf("\n", fenceOpen + 3) + if (afterFenceOpen === -1) return "" + // A missing closing fence means the replacement was truncated. Applying a + // partial editable region can delete valid trailing code, so suppress it. + const fenceClose = message.indexOf("```", afterFenceOpen + 1) + if (fenceClose === -1) return "" + let body = message.slice(afterFenceOpen + 1, fenceClose) + if (body.endsWith("\n")) body = body.slice(0, -1) + body = body.replace(/^<\|code_to_edit\|>\n?/, "") + body = body.replace(/\n?<\|\/code_to_edit\|>$/, "") + return body +} diff --git a/packages/kilo-gateway/src/fim.ts b/packages/kilo-gateway/src/fim.ts new file mode 100644 index 00000000000..2c7783080ae --- /dev/null +++ b/packages/kilo-gateway/src/fim.ts @@ -0,0 +1,34 @@ +import { KILO_API_BASE } from "./api/constants.js" +import { getAutocompleteModel, type DirectAutocompleteProviderID } from "./autocomplete.js" + +export { requestMistralFim } from "./mistral-fim-endpoint.js" + +export const DIRECT_FIM_ENV: Record = { + mistral: ["MISTRAL_API_KEY"], + inception: ["INCEPTION_API_KEY"], +} + +export type FimTarget = + | { provider: "kilo"; model: string; url: string } + | { provider: "inception"; model: string; url: string } + | { provider: "mistral"; model: string } + +const KILO_FIM_URL = KILO_API_BASE + "/api/fim/completions" +const INCEPTION_FIM_URL = "https://api.inceptionlabs.ai/v1/fim/completions" + +function kiloTarget(model?: string): FimTarget { + return { provider: "kilo", model: model ?? "mistralai/codestral-2501", url: KILO_FIM_URL } +} + +export function resolveFimTarget(provider?: string, model?: string): FimTarget { + if (!provider || provider === "kilo") return kiloTarget(model) + + const info = getAutocompleteModel(provider, model) + if (info.directProvider === "mistral") { + return { provider: "mistral", model: info.requestModel } + } + if (info.directProvider === "inception") { + return { provider: "inception", model: info.requestModel, url: INCEPTION_FIM_URL } + } + return kiloTarget(model) +} diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 429575d093d..bc8d21df72e 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -39,8 +39,19 @@ export { fetchKiloEmbeddingModelCatalog, type KiloEmbeddingModel, type KiloEmbeddingModelCatalog, + type KiloEmbeddingModelCatalogIssue, } from "./api/embedding-models.js" export { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "./api/url.js" +export { + AUTOCOMPLETE_MODELS, + DEFAULT_AUTOCOMPLETE_MODEL, + getAutocompleteModel, + getAutocompleteModelById, + validAutocompleteModel, + validAutocompleteProvider, + type AutocompleteModelDef, + type AutocompleteProviderID, +} from "./autocomplete.js" export { fetchOrganizationModes, clearModesCache, diff --git a/packages/kilo-gateway/src/mistral-fim-endpoint.ts b/packages/kilo-gateway/src/mistral-fim-endpoint.ts new file mode 100644 index 00000000000..ebd5b70fb41 --- /dev/null +++ b/packages/kilo-gateway/src/mistral-fim-endpoint.ts @@ -0,0 +1,30 @@ +export const MISTRAL_FIM_URL = "https://api.mistral.ai/v1/fim/completions" +export const CODESTRAL_FIM_URL = "https://codestral.mistral.ai/v1/fim/completions" + +let preferred: string | undefined + +export function isMistralEndpointMismatch(response: Response) { + return response.status === 401 || response.status === 403 +} + +export function clearMistralFimEndpointCache() { + preferred = undefined +} + +export function getCachedMistralFimEndpoint() { + return preferred +} + +export async function requestMistralFim(request: (url: string) => Promise) { + const firstUrl = preferred ?? MISTRAL_FIM_URL + const secondUrl = firstUrl === MISTRAL_FIM_URL ? CODESTRAL_FIM_URL : MISTRAL_FIM_URL + const first = await request(firstUrl) + + if (first.ok) return first + if (!isMistralEndpointMismatch(first)) return first + + preferred = undefined + const second = await request(secondUrl) + if (second.ok) preferred = secondUrl + return second +} diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index c36551469e6..1f52b09c2d6 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -3,12 +3,13 @@ import { createAlibaba } from "@ai-sdk/alibaba" import { createAnthropic } from "@ai-sdk/anthropic" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { createMistral } from "@ai-sdk/mistral" import type { KiloProvider, KiloProviderOptions } from "./types.js" import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" import { ANONYMOUS_API_KEY } from "./api/constants.js" import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" -import { sanitizeResponsesBody } from "./responses.js" +import { transformRequestBody } from "./responses.js" export function buildRequestHeaders(defaultHeaders: Record, requestHeaders?: HeadersInit): Headers { const headers = new Headers(defaultHeaders) @@ -54,7 +55,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { const originalFetch = options.fetch ?? fetch const wrappedFetch = async (input: string | URL | Request, init?: RequestInit) => { const headers = buildRequestHeaders(customHeaders, init?.headers) - const body = sanitizeResponsesBody(input, init?.body) + const body = transformRequestBody(input, init?.body, options.dataCollection) // Add authorization if API key exists if (apiKey) { @@ -80,6 +81,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { const anthropic = createAnthropic(sdkOptions) const openai = createOpenAI(sdkOptions) const openaiCompatible = createOpenAICompatible({ ...sdkOptions, name: "openaiCompatible" }) + const mistral = createMistral(sdkOptions) return { languageModel(modelId) { @@ -100,6 +102,9 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { anthropic(modelId) { return anthropic(modelId) }, + mistral(modelId) { + return mistral(modelId) + }, openai(modelId) { return openai(modelId) }, diff --git a/packages/kilo-gateway/src/responses.ts b/packages/kilo-gateway/src/responses.ts index 3697c5cf775..5ba68266edc 100644 --- a/packages/kilo-gateway/src/responses.ts +++ b/packages/kilo-gateway/src/responses.ts @@ -28,8 +28,13 @@ function strip(input: unknown[]) { return { kept, changed } } -export function sanitizeResponsesBody(input: string | URL | Request, body: BodyInit | null | undefined) { - if (!endpoint(input)) return body +export function transformRequestBody( + input: string | URL | Request, + body: BodyInit | null | undefined, + value?: "allow" | "deny", +) { + const responses = endpoint(input) + if (!responses && !value) return body if (typeof body !== "string") return body const data = (() => { @@ -40,10 +45,14 @@ export function sanitizeResponsesBody(input: string | URL | Request, body: BodyI } })() if (!record(data)) return body - if (data.store === true) return body - if (!Array.isArray(data.input)) return body - const result = strip(data.input) - if (!result.changed) return body - return JSON.stringify({ ...data, input: result.kept }) + const result = responses && data.store !== true && Array.isArray(data.input) ? strip(data.input) : undefined + if (!result?.changed && !value) return body + + const provider = record(data.provider) ? data.provider : {} + return JSON.stringify({ + ...data, + ...(result?.changed ? { input: result.kept } : {}), + ...(value ? { provider: { ...provider, data_collection: value } } : {}), + }) } diff --git a/packages/kilo-gateway/src/server/edit.ts b/packages/kilo-gateway/src/server/edit.ts new file mode 100644 index 00000000000..707d1b6b52e --- /dev/null +++ b/packages/kilo-gateway/src/server/edit.ts @@ -0,0 +1,121 @@ +import { HEADER_FEATURE } from "../api/constants.js" +import { + DIRECT_EDIT_ENV, + extractFencedBody, + resolveEditTarget, + type EditTarget, + type EditUpstreamResponse, +} from "../edit.js" +import { buildMercuryEditPrompt, type MercuryEditContext } from "../edit-prompt.js" +import type { DirectAutocompleteProviderID } from "../autocomplete.js" +import { buildKiloHeaders } from "../headers.js" +import type { AuthStore } from "./handlers.js" + +type Auth = Pick + +const EDIT_TIMEOUT_MS = 30_000 +const MAX_TOKENS_DEFAULT = 512 + +async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID): Promise { + const auth = await Auth.get(provider) + if (auth?.type === "api") return auth.key + return DIRECT_EDIT_ENV[provider].map((key) => process.env[key]).find(Boolean) +} + +async function getProxyAuth(Auth: Auth) { + const auth = await Auth.get("kilo") + const token = auth?.type === "api" ? auth.key : auth?.type === "oauth" ? auth.access : undefined + return { + auth, + token, + organizationId: auth?.type === "oauth" ? auth.accountId : undefined, + } +} + +export function createEditHandler(Auth: Auth) { + return async (c: any) => { + const { provider, model, maxTokens, ...context } = c.req.valid("json") + const target = resolveEditTarget(provider, model) + + if (target.provider === "kilo" && !target.url) { + return c.json({ error: "Next Edit currently requires the Inception provider (mercury-edit-2)." }, 400 as any) + } + + const proxy = target.provider === "kilo" ? await getProxyAuth(Auth) : undefined + const token = + target.provider === "kilo" + ? proxy?.token + : await getProviderKey(Auth, target.provider as DirectAutocompleteProviderID) + + if (target.provider === "kilo" && !proxy?.auth) { + return c.json({ error: "Not authenticated with Kilo Gateway" }, 401 as any) + } + + if (!token) { + return c.json({ error: `Missing ${target.provider} provider API key` }, 401 as any) + } + + // Build the Mercury sentinel prompt here so every client only sends + // structured editor context. + const content = buildMercuryEditPrompt(context as MercuryEditContext) + const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)]) + + let response: Response + try { + response = await fetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(target.provider === "kilo" + ? buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy?.organizationId }) + : {}), + ...(target.provider === "kilo" ? { [HEADER_FEATURE]: "autocomplete" } : {}), + }, + signal, + body: JSON.stringify({ + model: target.model, + max_tokens: maxTokens ?? MAX_TOKENS_DEFAULT, + // Mercury rejects role:"system" on this endpoint — must be a single + // user message. See the integration's constants.ts for context. + messages: [{ role: "user", content }], + }), + }) + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + return c.json({ error: "Edit request timed out" }, 504 as any) + } + if (signal.aborted) return c.json({ error: "Edit request canceled" }, 499 as any) + throw err + } + + if (!response.ok) { + const text = await safeText(response) + return c.json({ error: `Edit request failed: ${response.status} ${text}` }, response.status as any) + } + + const json = (await response.json()) as EditUpstreamResponse + const replyContent = json.choices?.[0]?.message?.content ?? "" + const body = extractFencedBody(replyContent) + return c.json({ + content: body, + usage: json.usage + ? { + prompt_tokens: json.usage.prompt_tokens, + completion_tokens: json.usage.completion_tokens, + } + : undefined, + }) + } +} + +async function safeText(res: Response): Promise { + try { + return await res.text() + } catch { + return "" + } +} + +// Re-export the target type for tests + the opencode handler +export type { EditTarget } diff --git a/packages/kilo-gateway/src/server/fim.ts b/packages/kilo-gateway/src/server/fim.ts new file mode 100644 index 00000000000..08790784ea0 --- /dev/null +++ b/packages/kilo-gateway/src/server/fim.ts @@ -0,0 +1,120 @@ +import { HEADER_FEATURE } from "../api/constants.js" +import type { DirectAutocompleteProviderID } from "../autocomplete.js" +import { DIRECT_FIM_ENV, requestMistralFim, resolveFimTarget, type FimTarget } from "../fim.js" +import { buildKiloHeaders } from "../headers.js" +import type { AuthStore } from "./handlers.js" + +type Auth = Pick + +const FIM_TIMEOUT_MS = 30_000 + +async function getProxyAuth(Auth: Auth) { + const auth = await Auth.get("kilo") + const token = auth?.type === "api" ? auth.key : auth?.type === "oauth" ? auth.access : undefined + return { + auth, + token, + organizationId: auth?.type === "oauth" ? auth.accountId : undefined, + } +} + +async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID) { + const auth = await Auth.get(provider) + if (auth?.type === "api") return auth.key + return DIRECT_FIM_ENV[provider].map((key) => process.env[key]).find(Boolean) +} + +async function fetchFim( + target: FimTarget, + key: string, + input: { + prefix: string + suffix: string + maxTokens: number + temperature: number + signal: AbortSignal + organizationId?: string + }, +): Promise { + const run = async (url: string) => { + console.info(`[FIM] request provider=${target.provider} model=${target.model} url=${url}`) + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${key}`, + ...(target.provider === "kilo" + ? buildKiloHeaders(undefined, { kilocodeOrganizationId: input.organizationId }) + : {}), + ...(target.provider === "kilo" ? { [HEADER_FEATURE]: "autocomplete" } : {}), + }, + signal: input.signal, + body: JSON.stringify({ + model: target.model, + prompt: input.prefix, + suffix: input.suffix, + max_tokens: input.maxTokens, + temperature: input.temperature, + stream: true, + }), + }) + } + + if (target.provider === "mistral") return requestMistralFim(run) + return run(target.url) +} + +export function createFimHandler(Auth: Auth) { + return async (c: any) => { + const { prefix, suffix, provider, model, maxTokens, temperature } = c.req.valid("json") + const target = resolveFimTarget(provider, model) + const fimMaxTokens = maxTokens ?? 256 + const fimTemperature = temperature ?? 0.2 + const proxy = target.provider === "kilo" ? await getProxyAuth(Auth) : undefined + const token = target.provider === "kilo" ? proxy?.token : await getProviderKey(Auth, target.provider) + + if (target.provider === "kilo" && !proxy?.auth) { + return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + } + + if (target.provider === "kilo" && !token) { + return c.json({ error: "No valid token found" }, 401) + } + + if (!token) { + return c.json({ error: `Missing ${target.provider} provider API key` }, 401) + } + + const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)]) + + try { + const response = await fetchFim(target, token, { + prefix, + suffix, + maxTokens: fimMaxTokens, + temperature: fimTemperature, + signal, + organizationId: proxy?.organizationId, + }) + + if (!response.ok) { + const text = await response.text() + return c.json({ error: `FIM request failed: ${response.status} ${text}` }, response.status as any) + } + + return new Response(response.body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }) + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + return c.json({ error: "FIM request timed out" }, 504 as any) + } + if (signal.aborted) return c.json({ error: "FIM request canceled" }, 499 as any) + throw err + } + } +} diff --git a/packages/kilo-gateway/src/server/handlers.ts b/packages/kilo-gateway/src/server/handlers.ts index 4e3fca63762..ff8e7f2114e 100644 --- a/packages/kilo-gateway/src/server/handlers.ts +++ b/packages/kilo-gateway/src/server/handlers.ts @@ -30,7 +30,7 @@ export interface AuthStore { export interface OrganizationDeps { auth: AuthStore - clear(): void + clear(): void | Promise dispose(): Promise } @@ -97,7 +97,7 @@ export async function setOrganization(deps: OrganizationDeps, organizationId: st ...(organizationId && { accountId: organizationId }), }) - deps.clear() + await deps.clear() clearModesCache() await deps.dispose() return true diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 78c2bfda6b5..c90c4f1253b 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -11,6 +11,8 @@ import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/con import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js" +import { createEditHandler } from "./edit.js" +import { createFimHandler } from "./fim.js" import { GatewayError, UnauthorizedError, @@ -29,7 +31,7 @@ type Validator = any type Resolver = any type Errors = any type Auth = any -type ModelCache = { clear: (providerID: string) => void } +type ModelCache = { clear: (providerID: string) => void | Promise } type Z = any interface KiloRoutesDeps extends ImportDeps { @@ -44,8 +46,6 @@ interface KiloRoutesDeps extends ImportDeps { Instances: { disposeAllInstances(): Promise } } -const FIM_TIMEOUT_MS = 30_000 - /** * Create Kilo Gateway routes with OpenCode dependencies injected * @@ -113,6 +113,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { currentOrgId: z.string().nullable(), }) + const EditCompletionResponse = z.object({ + content: z.string(), + usage: z + .object({ + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + }) + .optional(), + }) + const FimStreamChunk = z.object({ choices: z .array( @@ -318,74 +328,51 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { z.object({ prefix: z.string(), suffix: z.string(), + provider: z.string().optional(), model: z.string().optional(), maxTokens: z.number().optional(), temperature: z.number().optional(), }), ), - async (c: any) => { - const proxy = await getProxyAuth() - - if (!proxy.auth) { - return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - } - - if (!proxy.token) { - return c.json({ error: "No valid token found" }, 401) - } - - const { prefix, suffix, model, maxTokens, temperature } = c.req.valid("json") - const fimModel = model ?? "mistralai/codestral-2501" - const fimMaxTokens = maxTokens ?? 256 - const fimTemperature = temperature ?? 0.2 - - const baseApiUrl = KILO_API_BASE + "/api/" - const endpoint = new URL("fim/completions", baseApiUrl) - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${proxy.token}`, - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), - [HEADER_FEATURE]: "autocomplete", - } - - const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)]) - - let response: Response - try { - response = await fetch(endpoint, { - method: "POST", - headers, - signal, - body: JSON.stringify({ - model: fimModel, - prompt: prefix, - suffix, - max_tokens: fimMaxTokens, - temperature: fimTemperature, - stream: true, - }), - }) - } catch (err) { - if (err instanceof DOMException && err.name === "TimeoutError") - return c.json({ error: "FIM request timed out" }, 504 as any) - if (signal.aborted) return c.json({ error: "FIM request canceled" }, 499 as any) - throw err - } - - if (!response.ok) { - const text = await response.text() - return c.json({ error: `FIM request failed: ${response.status} ${text}` }, response.status as any) - } - - return new Response(response.body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", + createFimHandler(Auth), + ) + .post( + "/edit", + describeRoute({ + summary: "Next Edit completion", + description: + "Proxy a Mercury-style Next Edit request. The client supplies structured editor " + + "context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.", + operationId: "kilo.edit", + responses: { + 200: { + description: "Next Edit completion", + content: { + "application/json": { + schema: resolver(EditCompletionResponse), + }, + }, }, - }) - }, + ...errors(400, 401), + }, + }), + validator( + "json", + z.object({ + provider: z.string().optional(), + model: z.string().optional(), + maxTokens: z.number().optional(), + currentFilePath: z.string(), + currentFileContent: z.string(), + cursorLine: z.number(), + cursorCharacter: z.number(), + editableRegionStartLine: z.number(), + editableRegionEndLine: z.number(), + recentlyViewedSnippets: z.array(z.object({ filepath: z.string(), content: z.string() })), + editDiffHistory: z.array(z.string()), + }), + ), + createEditHandler(Auth), ) .post( "/audio/transcriptions", diff --git a/packages/kilo-gateway/src/tui/types.ts b/packages/kilo-gateway/src/tui/types.ts index d4a60d31965..95509bbfe2b 100644 --- a/packages/kilo-gateway/src/tui/types.ts +++ b/packages/kilo-gateway/src/tui/types.ts @@ -5,7 +5,6 @@ export interface TUIDependencies { // UI Hooks - useCommandDialog: () => any useSync: () => any useDialog: () => any useToast: () => any diff --git a/packages/kilo-gateway/src/types.ts b/packages/kilo-gateway/src/types.ts index 34b5c27f0a4..ab214d3274c 100644 --- a/packages/kilo-gateway/src/types.ts +++ b/packages/kilo-gateway/src/types.ts @@ -95,6 +95,11 @@ export interface KiloProviderOptions { */ name?: string + /** + * Data collection preference for upstream provider routing + */ + dataCollection?: "allow" | "deny" + /** * Custom fetch function */ @@ -162,6 +167,7 @@ export interface ProviderInfo { export type KiloProvider = Provider & { alibaba(modelId: string): LanguageModel anthropic(modelId: string): LanguageModel + mistral(modelId: string): LanguageModel openai(modelId: string): LanguageModel openaiCompatible(modelId: string): LanguageModel } diff --git a/packages/kilo-gateway/src/types/tui.d.ts b/packages/kilo-gateway/src/types/tui.d.ts index 83a03f4a3e3..8bbe5d1f948 100644 --- a/packages/kilo-gateway/src/types/tui.d.ts +++ b/packages/kilo-gateway/src/types/tui.d.ts @@ -3,10 +3,6 @@ * These modules are provided at runtime by the OpenCode TUI system */ -declare module "@tui/component/dialog-command" { - export function useCommandDialog(): any -} - declare module "@tui/context/sync" { export function useSync(): any } diff --git a/packages/kilo-gateway/test/api/embedding-models.test.ts b/packages/kilo-gateway/test/api/embedding-models.test.ts index f2e15a9e250..408f048786c 100644 --- a/packages/kilo-gateway/test/api/embedding-models.test.ts +++ b/packages/kilo-gateway/test/api/embedding-models.test.ts @@ -1,43 +1,81 @@ -import { describe, expect, mock, test } from "bun:test" +import { describe, expect, mock, spyOn, test } from "bun:test" import { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog } from "../../src/api/embedding-models" +const response = () => + new Response( + JSON.stringify({ + defaultModel: "provider/model", + models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }], + aliases: { model: "provider/model" }, + }), + ) + describe("fetchKiloEmbeddingModelCatalog", () => { test("fetches catalog from Kilo Gateway", async () => { const prev = global.fetch - const fn = mock(() => - Promise.resolve( - new Response( - JSON.stringify({ - defaultModel: "provider/model", - models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }], - aliases: { model: "provider/model" }, - }), - ), - ), - ) as unknown as typeof fetch + const fn = mock(() => Promise.resolve(response())) as unknown as typeof fetch global.fetch = fn try { const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" }) expect(catalog.defaultModel).toBe("provider/model") - expect((fn as unknown as { mock: { calls: Array<[URL]> } }).mock.calls[0]?.[0].toString()).toBe( - "https://example.test/api/gateway/embedding-models", - ) + const call = (fn as unknown as { mock: { calls: Array<[URL, RequestInit]> } }).mock.calls[0] + expect(call?.[0].toString()).toBe("https://example.test/api/gateway/embedding-models") + expect(call?.[1].redirect).toBe("error") + } finally { + global.fetch = prev + } + }) + + test("retries transient transport failures", async () => { + const prev = global.fetch + const fn = mock(() => Promise.reject(new TypeError("fetch failed"))) + fn.mockImplementationOnce(() => Promise.reject(new TypeError("fetch failed"))) + fn.mockImplementationOnce(() => Promise.resolve(response())) + global.fetch = fn as unknown as typeof fetch + + try { + const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" }) + + expect(catalog.models).toHaveLength(1) + expect(fn).toHaveBeenCalledTimes(2) + } finally { + global.fetch = prev + } + }) + + test("bounds caller-controlled retry attempts", async () => { + const prev = global.fetch + const fn = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) + global.fetch = fn as unknown as typeof fetch + + try { + await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: Number.POSITIVE_INFINITY }) + expect(fn).toHaveBeenCalledTimes(3) } finally { global.fetch = prev } }) - test("falls back when the request fails", async () => { + test("reports a final failure without writing to the console", async () => { const prev = global.fetch + const warn = spyOn(console, "warn").mockImplementation(() => undefined) + const issue = mock(() => undefined) global.fetch = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) as unknown as typeof fetch try { - await expect(fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })).resolves.toEqual( - EMPTY_KILO_EMBEDDING_MODEL_CATALOG, - ) + await expect( + fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: 1, onError: issue }), + ).resolves.toEqual(EMPTY_KILO_EMBEDDING_MODEL_CATALOG) + expect(issue).toHaveBeenCalledWith({ + code: "http", + message: "Unable to load Kilo embedding models (HTTP 500).", + status: 500, + }) + expect(warn).not.toHaveBeenCalled() } finally { + warn.mockRestore() global.fetch = prev } }) diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 579f0621bc0..56a589af0e5 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -15,6 +15,48 @@ const VALID_RESPONSE = JSON.stringify({ output_modalities: ["text"], }, supported_parameters: ["tools", "temperature"], + isFree: false, + mayTrainOnYourPrompts: true, + hasUserByokAvailable: true, + }, + ], +}) + +const VALID_BENCH_RESPONSE = JSON.stringify({ + data: [ + { + id: "test/model-a", + name: "Test Model A", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "temperature"], + terminalBench: { + overallScore: 0.551, + avgAttemptCostUsd: 53.37, + }, + }, + ], +}) + +const INVALID_BENCH_RESPONSE = JSON.stringify({ + data: [ + { + id: "test/model-a", + name: "Test Model A", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "temperature"], + terminalBench: { + overallScore: 0.551, + }, }, ], }) @@ -104,7 +146,50 @@ test("returns models without error on success", async () => { ;(globalThis as any).fetch = orig expect(result.error).toBeUndefined() - expect(Object.keys(result.models).length).toBeGreaterThan(0) + expect(result.models["test/model-a"]).toMatchObject({ + isFree: false, + mayTrainOnYourPrompts: true, + hasUserByokAvailable: true, + }) +}) + +test("preserves Terminal Bench metadata as a dedicated model field", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response(VALID_BENCH_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["test/model-a"].terminalBench).toEqual({ + overallScore: 0.551, + avgAttemptCostUsd: 53.37, + }) +}) + +test("omits malformed Terminal Bench metadata without rejecting the catalog", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response(INVALID_BENCH_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["test/model-a"].terminalBench).toBeUndefined() }) test("returns error with kind=schema when response body is invalid JSON", async () => { diff --git a/packages/kilo-gateway/test/autocomplete.test.ts b/packages/kilo-gateway/test/autocomplete.test.ts new file mode 100644 index 00000000000..4910979a85b --- /dev/null +++ b/packages/kilo-gateway/test/autocomplete.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { + AUTOCOMPLETE_MODELS, + DEFAULT_AUTOCOMPLETE_MODEL, + DEFAULT_AUTOCOMPLETE_MODEL_ID, + DEFAULT_AUTOCOMPLETE_PROVIDER_ID, +} from "../src/autocomplete" + +describe("DEFAULT_AUTOCOMPLETE_MODEL", () => { + test("resolves to Mercury Next Edit through Kilo Gateway", () => { + const match = AUTOCOMPLETE_MODELS.find( + (m) => m.providerID === DEFAULT_AUTOCOMPLETE_PROVIDER_ID && m.modelID === DEFAULT_AUTOCOMPLETE_MODEL_ID, + ) + expect(DEFAULT_AUTOCOMPLETE_PROVIDER_ID).toBe("kilo") + expect(DEFAULT_AUTOCOMPLETE_MODEL_ID).toBe("inception/mercury-next-edit") + expect(match).toBeDefined() + expect(DEFAULT_AUTOCOMPLETE_MODEL).toBe(match!) + expect(DEFAULT_AUTOCOMPLETE_MODEL.kind).toBe("edit") + }) +}) + +describe("Next Edit FIM models", () => { + test("reference a FIM model from the same provider", () => { + for (const model of AUTOCOMPLETE_MODELS) { + if (model.kind !== "edit") continue + const sibling = AUTOCOMPLETE_MODELS.find((candidate) => candidate.id === model.fimModelID) + expect(sibling).toBeDefined() + expect(sibling?.kind).not.toBe("edit") + expect(sibling?.providerID).toBe(model.providerID) + } + }) +}) diff --git a/packages/kilo-gateway/test/cloud-sessions.test.ts b/packages/kilo-gateway/test/cloud-sessions.test.ts new file mode 100644 index 00000000000..2c292bb5ef6 --- /dev/null +++ b/packages/kilo-gateway/test/cloud-sessions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { fetchCloudSession, fetchCloudSessionForImport } from "../src/cloud-sessions" + +async function expectStalledFetchToTimeOut(run: () => Promise) { + const fetch = globalThis.fetch + const timeout = AbortSignal.timeout + let delay: number | undefined + + AbortSignal.timeout = (ms) => { + delay = ms + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + })) as typeof globalThis.fetch + + try { + const outcome = await Promise.race([ + run().then( + () => "resolved" as const, + (err) => { + if (err instanceof DOMException && err.name === "TimeoutError") return "timed-out" as const + throw err + }, + ), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("timed-out") + expect(delay).toBe(30_000) + } finally { + globalThis.fetch = fetch + AbortSignal.timeout = timeout + } +} + +describe("cloud session export requests", () => { + test("times out a stalled preview request", async () => { + await expectStalledFetchToTimeOut(() => fetchCloudSession("token", "session-id")) + }) + + test("times out a stalled import request", async () => { + await expectStalledFetchToTimeOut(() => fetchCloudSessionForImport("token", "session-id")) + }) +}) diff --git a/packages/kilo-gateway/test/edit-prompt.test.ts b/packages/kilo-gateway/test/edit-prompt.test.ts new file mode 100644 index 00000000000..6aafce62a69 --- /dev/null +++ b/packages/kilo-gateway/test/edit-prompt.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { + buildMercuryEditPrompt, + currentFileContentBlock, + editDiffHistoryBlock, + recentlyViewedSnippetsBlock, +} from "../src/edit-prompt" + +describe("recentlyViewedSnippetsBlock", () => { + test("wraps in open/close sentinels even when empty", () => { + const out = recentlyViewedSnippetsBlock([]) + expect(out.startsWith("<|recently_viewed_code_snippets|>")).toBe(true) + expect(out.endsWith("<|/recently_viewed_code_snippets|>")).toBe(true) + }) + + test("emits one inner block per snippet with the file-path header", () => { + const out = recentlyViewedSnippetsBlock([ + { filepath: "src/a.ts", content: "const a = 1" }, + { filepath: "src/b.ts", content: "const b = 2" }, + ]) + expect(out).toContain("code_snippet_file_path: src/a.ts") + expect(out).toContain("code_snippet_file_path: src/b.ts") + expect(out).toContain("const a = 1") + expect(out).toContain("const b = 2") + }) +}) + +describe("currentFileContentBlock", () => { + test("inserts <|cursor|> at the right character and wraps the editable region", () => { + const file = ["function foo() {", " return 1", "}"].join("\n") + const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2) + expect(out).toContain("<|current_file_content|>") + expect(out).toContain("<|/current_file_content|>") + expect(out).toContain("current_file_path: src/foo.ts") + expect(out).toContain(" <|cursor|>return 1") + const openIdx = out.indexOf("<|code_to_edit|>") + const lineIdx = out.indexOf("return 1") + const closeIdx = out.indexOf("<|/code_to_edit|>") + expect(openIdx).toBeGreaterThan(-1) + expect(closeIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeGreaterThan(openIdx) + expect(lineIdx).toBeLessThan(closeIdx) + }) + + test("clamps an out-of-range cursor instead of throwing", () => { + const out = currentFileContentBlock("p.ts", "only-line", 0, 0, 0, 9999) + expect(out).toContain("only-line<|cursor|>") + }) +}) + +describe("editDiffHistoryBlock", () => { + test("strips the createPatch index+separator lines from each diff", () => { + const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n") + const out = editDiffHistoryBlock([fakeDiff]) + expect(out).toContain("@@ -1,1 +1,1 @@") + expect(out).not.toContain("Index: foo.ts") + expect(out.startsWith("<|edit_diff_history|>")).toBe(true) + expect(out.endsWith("<|/edit_diff_history|>")).toBe(true) + }) + + test("separates multiple diffs with a blank line", () => { + const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n") + const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n") + const out = editDiffHistoryBlock([diff1, diff2]) + const idx1 = out.indexOf("@@ -1,1 +1,1 @@") + const idx2 = out.indexOf("@@ -2,1 +2,1 @@") + expect(idx2).toBeGreaterThan(idx1) + expect(out.slice(idx1, idx2)).toContain("\n\n") + }) +}) + +describe("buildMercuryEditPrompt", () => { + test("assembles the three blocks in order and ends with the NES token", () => { + const out = buildMercuryEditPrompt({ + currentFilePath: "p.ts", + currentFileContent: "a\nb\nc", + cursorLine: 1, + cursorCharacter: 0, + editableRegionStartLine: 1, + editableRegionEndLine: 1, + recentlyViewedSnippets: [], + editDiffHistory: [], + }) + const snippetsIdx = out.indexOf("<|recently_viewed_code_snippets|>") + const fileIdx = out.indexOf("<|current_file_content|>") + const diffIdx = out.indexOf("<|edit_diff_history|>") + expect(snippetsIdx).toBeGreaterThan(-1) + expect(fileIdx).toBeGreaterThan(snippetsIdx) + expect(diffIdx).toBeGreaterThan(fileIdx) + expect(out.endsWith("<|!@#IS_NEXT_EDIT!@#|>")).toBe(true) + }) +}) diff --git a/packages/kilo-gateway/test/edit.test.ts b/packages/kilo-gateway/test/edit.test.ts new file mode 100644 index 00000000000..ad21982b7fc --- /dev/null +++ b/packages/kilo-gateway/test/edit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { extractFencedBody, resolveEditTarget } from "../src/edit" + +describe("Edit target resolution", () => { + test("routes the Inception next-edit model to Inception's edit endpoint", () => { + expect(resolveEditTarget("inception", "mercury-next-edit")).toEqual({ + provider: "inception", + model: "mercury-edit-2", + url: "https://api.inceptionlabs.ai/v1/edit/completions", + }) + }) + + test("does NOT route the FIM Mercury model to the edit endpoint", () => { + // `mercury-edit-2` (kind: fim) must fall through to the kilo placeholder, + // not the edit endpoint — only `mercury-next-edit` (kind: edit) is NES. + expect(resolveEditTarget("inception", "mercury-edit-2").provider).toBe("kilo") + }) + + test("routes the Kilo Gateway next-edit model to the gateway proxy", () => { + const target = resolveEditTarget("kilo", "inception/mercury-next-edit") + expect(target.provider).toBe("kilo") + expect(target.model).toBe("inception/mercury-edit-2") + expect(target.url).toMatch(/\/api\/edit\/completions$/) + }) + + test("falls back to a kilo placeholder (no upstream) for non-edit models", () => { + expect(resolveEditTarget("kilo", "mistralai/codestral-2508")).toEqual({ + provider: "kilo", + model: "mistralai/codestral-2508", + url: "", + }) + }) + + test("routes the default model to the gateway proxy", () => { + expect(resolveEditTarget()).toEqual({ + provider: "kilo", + model: "inception/mercury-edit-2", + url: "https://api.kilo.ai/api/edit/completions", + }) + }) +}) + +describe("extractFencedBody", () => { + test("extracts a plain triple-backtick fenced body", () => { + expect(extractFencedBody("```\nconst x = 1\n```")).toBe("const x = 1") + }) + + test("handles a language tag on the opening fence", () => { + expect(extractFencedBody("```typescript\nconst x = 1\n```")).toBe("const x = 1") + }) + + test("strips embedded <|code_to_edit|> sentinels", () => { + expect(extractFencedBody("```\n<|code_to_edit|>\nconst x = 2\n<|/code_to_edit|>\n```")).toBe("const x = 2") + }) + + test("returns the raw message when there is no fence", () => { + expect(extractFencedBody("just text, no fence")).toBe("just text, no fence") + }) + + test("returns the empty string for empty input", () => { + expect(extractFencedBody("")).toBe("") + }) + + test("suppresses a replacement when the closing fence is missing", () => { + expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("") + }) + + test("preserves internal blank lines and indentation", () => { + const body = "def f():\n if True:\n\n return 1" + expect(extractFencedBody("```python\n" + body + "\n```")).toBe(body) + }) +}) diff --git a/packages/kilo-gateway/test/fim.test.ts b/packages/kilo-gateway/test/fim.test.ts new file mode 100644 index 00000000000..86d7e08b556 --- /dev/null +++ b/packages/kilo-gateway/test/fim.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { resolveFimTarget } from "../src/fim" + +describe("FIM target resolution", () => { + test("keeps gateway autocomplete models on Kilo Gateway", () => { + expect(resolveFimTarget("kilo", "mistralai/codestral-2508")).toEqual({ + provider: "kilo", + model: "mistralai/codestral-2508", + url: "https://api.kilo.ai/api/fim/completions", + }) + expect(resolveFimTarget("kilo", "inception/mercury-edit-2")).toEqual({ + provider: "kilo", + model: "inception/mercury-edit-2", + url: "https://api.kilo.ai/api/fim/completions", + }) + }) + + test("routes explicit provider autocomplete models directly", () => { + expect(resolveFimTarget("mistral", "codestral-2508")).toEqual({ + provider: "mistral", + model: "codestral-2508", + }) + expect(resolveFimTarget("inception", "mercury-edit-2")).toEqual({ + provider: "inception", + model: "mercury-edit-2", + url: "https://api.inceptionlabs.ai/v1/fim/completions", + }) + }) + + test("preserves gateway model pass-through behavior", () => { + expect(resolveFimTarget()).toEqual({ + provider: "kilo", + model: "mistralai/codestral-2501", + url: "https://api.kilo.ai/api/fim/completions", + }) + expect(resolveFimTarget(undefined, "mistralai/codestral-2508")).toEqual({ + provider: "kilo", + model: "mistralai/codestral-2508", + url: "https://api.kilo.ai/api/fim/completions", + }) + expect(resolveFimTarget(undefined, "inception/mercury-edit")).toEqual({ + provider: "kilo", + model: "inception/mercury-edit", + url: "https://api.kilo.ai/api/fim/completions", + }) + expect(resolveFimTarget("kilo", "custom/fim-model")).toEqual({ + provider: "kilo", + model: "custom/fim-model", + url: "https://api.kilo.ai/api/fim/completions", + }) + }) +}) diff --git a/packages/kilo-gateway/test/mistral-fim-endpoint.test.ts b/packages/kilo-gateway/test/mistral-fim-endpoint.test.ts new file mode 100644 index 00000000000..d10c6177287 --- /dev/null +++ b/packages/kilo-gateway/test/mistral-fim-endpoint.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { + CODESTRAL_FIM_URL, + MISTRAL_FIM_URL, + clearMistralFimEndpointCache, + getCachedMistralFimEndpoint, + requestMistralFim, +} from "../src/mistral-fim-endpoint" + +function response(status: number) { + return new Response(null, { status }) +} + +describe("Mistral FIM endpoint cache", () => { + test("remembers Codestral endpoint after successful fallback", async () => { + clearMistralFimEndpointCache() + const urls: string[] = [] + const first = await requestMistralFim(async (url) => { + urls.push(url) + return response(url === MISTRAL_FIM_URL ? 401 : 200) + }) + const second = await requestMistralFim(async (url) => { + urls.push(url) + return response(200) + }) + + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + expect(urls).toEqual([MISTRAL_FIM_URL, CODESTRAL_FIM_URL, CODESTRAL_FIM_URL]) + expect(getCachedMistralFimEndpoint()).toBe(CODESTRAL_FIM_URL) + }) + + test("does not remember fallback for invalid credentials", async () => { + clearMistralFimEndpointCache() + const urls: string[] = [] + const res = await requestMistralFim(async (url) => { + urls.push(url) + return response(401) + }) + + expect(res.status).toBe(401) + expect(urls).toEqual([MISTRAL_FIM_URL, CODESTRAL_FIM_URL]) + expect(getCachedMistralFimEndpoint()).toBeUndefined() + }) + + test("uses one process-local endpoint preference", async () => { + clearMistralFimEndpointCache() + const urls: string[] = [] + await requestMistralFim(async (url) => { + urls.push(url) + return response(url === MISTRAL_FIM_URL ? 403 : 200) + }) + await requestMistralFim(async (url) => { + urls.push(url) + return response(200) + }) + + expect(urls).toEqual([MISTRAL_FIM_URL, CODESTRAL_FIM_URL, CODESTRAL_FIM_URL]) + expect(getCachedMistralFimEndpoint()).toBe(CODESTRAL_FIM_URL) + }) + + test("clears stale preference and probes alternate endpoint", async () => { + clearMistralFimEndpointCache() + const urls: string[] = [] + await requestMistralFim(async (url) => response(url === MISTRAL_FIM_URL ? 401 : 200)) + const res = await requestMistralFim(async (url) => { + urls.push(url) + return response(url === CODESTRAL_FIM_URL ? 401 : 200) + }) + + expect(res.ok).toBe(true) + expect(urls).toEqual([CODESTRAL_FIM_URL, MISTRAL_FIM_URL]) + expect(getCachedMistralFimEndpoint()).toBe(MISTRAL_FIM_URL) + }) +}) diff --git a/packages/kilo-gateway/test/responses.test.ts b/packages/kilo-gateway/test/responses.test.ts index 49520300f50..9d73d65c781 100644 --- a/packages/kilo-gateway/test/responses.test.ts +++ b/packages/kilo-gateway/test/responses.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { sanitizeResponsesBody } from "../src/responses" +import { transformRequestBody } from "../src/responses" describe("Responses request sanitization", () => { test("strips item ids when storage is disabled", () => { @@ -30,7 +30,7 @@ describe("Responses request sanitization", () => { ], }) - const result = sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body) + const result = transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body) const data = JSON.parse(result as string) expect(data.input).toHaveLength(3) @@ -60,19 +60,19 @@ describe("Responses request sanitization", () => { ], }) - expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + expect(transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) }) test("leaves non-responses requests unchanged", () => { const body = "not json" - expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/chat/completions", body)).toBe(body) + expect(transformRequestBody("https://api.kilo.ai/api/openrouter/chat/completions", body)).toBe(body) }) test("leaves invalid responses JSON unchanged", () => { const body = "not json" - expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + expect(transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) }) test("matches relative responses paths without a placeholder host", () => { @@ -86,9 +86,32 @@ describe("Responses request sanitization", () => { }, ], }) - const result = sanitizeResponsesBody("/api/openrouter/responses?stream=true", body) + const result = transformRequestBody("/api/openrouter/responses?stream=true", body) const data = JSON.parse(result as string) expect(data.input[0].id).toBeUndefined() }) + + test("sanitizes responses and denies data collection in one transform", () => { + const body = JSON.stringify({ + input: [{ type: "message", role: "assistant", id: "msg_tmp_123" }], + provider: { order: ["anthropic"] }, + }) + const result = transformRequestBody("https://api.kilo.ai/api/openrouter/responses", body, "deny") + + expect(JSON.parse(result as string)).toEqual({ + input: [{ type: "message", role: "assistant" }], + provider: { order: ["anthropic"], data_collection: "deny" }, + }) + }) + + test("denies data collection for non-responses requests", () => { + const body = JSON.stringify({ model: "anthropic/claude-sonnet-4" }) + const result = transformRequestBody("https://api.kilo.ai/api/openrouter/chat/completions", body, "deny") + + expect(JSON.parse(result as string)).toEqual({ + model: "anthropic/claude-sonnet-4", + provider: { data_collection: "deny" }, + }) + }) }) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index bd5b2396af6..047744aec3e 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.3.8", + "version": "7.3.54", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index d7a778caa65..6a90119a1eb 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "قم بزيارة ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " للحصول على مفتاح API الخاص بك.", + "provider.connect.kiloGateway.byok.prefix": "للحصول على المزيد من إحصائيات الاستخدام، استخدم ", + "provider.connect.kiloGateway.byok.link": "BYOK عبر Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "موصى به", - "dialog.provider.kilo.note": "الوصول إلى أكثر من 500 نموذج ذكاء اصطناعي", + // Provider settings translations + "settings.providers.group.recommended": "موصى به", + "settings.providers.note.kilo": "الوصول إلى أكثر من 500 نموذج ذكاء اصطناعي", + "settings.providers.note.opencode": "نماذج منتقاة تشمل Claude وGPT وGemini والمزيد", + "settings.providers.note.anthropic": "وصول مباشر إلى نماذج Claude، بما في ذلك Pro وMax", + "settings.providers.note.deepseek": "نماذج DeepSeek لمهام الاستدلال والبرمجة", + "settings.providers.note.copilot": "نماذج Claude للمساعدة في البرمجة", + "settings.providers.note.openai": "نماذج GPT وCodex باستخدام مفتاح API أو تسجيل دخول ChatGPT", + "settings.providers.note.google": "نماذج Gemini لاستجابات سريعة ومنظمة", + "settings.providers.note.openrouter": "الوصول إلى كل النماذج المدعومة من موفر واحد", + "settings.providers.note.vercel": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي", // Reasoning block label "ui.permission.run": "تشغيل", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "المهارات", "marketplace.tab.mcpServers": "خوادم MCP", - "marketplace.tab.modes": "الأوضاع", "marketplace.category.all": "الكل", "marketplace.placeholder": "سيتم تنفيذه لاحقاً", "marketplace.card.installed": "مثبت", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "إلغاء", "marketplace.remove.confirm.button": "إزالة", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "وكلاء", "marketplace.search": "بحث...", "marketplace.filter.all": "جميع العناصر", "marketplace.filter.notInstalled": "غير مثبت", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "عام", "marketplace.remove.type.mcp": "خادم MCP", "marketplace.remove.type.skill": "مهارة", - "marketplace.remove.type.mode": "وضع", + "marketplace.remove.type.agent": "وكيل", "marketplace.remove.failed": "فشلت إزالة {{name}}", "marketplace.install": "تثبيت", "marketplace.filter.installed": "مثبت", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "عدة جلسات تعمل وستتوقف", "marketplace.warning.installAnyway": "تثبيت على أي حال", "marketplace.warning.cancel": "إلغاء", - "marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟", + "marketplace.contribute.prompt": "هل تفتقد مهارة أو وكيلاً أو خادم MCP؟", "marketplace.contribute.cta": "ساهم على GitHub", + "marketplace.migration.notice": + "تم استبدال الأوضاع بالوكلاء. إذا كنت قد قمت بتثبيت أوضاع السوق سابقاً، يرجى إزالتها وإعادة تثبيتها كوكلاء للانتقال إلى التنسيق الجديد.", // Plan follow-up question shown after plan_exit "plan.followup.header": "نفّذ", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index bbf53cd2ff3..916f93b0dbe 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visite ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " para obter sua chave de API.", + "provider.connect.kiloGateway.byok.prefix": "Para mais estatísticas de uso, utilize ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Recomendados", - "dialog.provider.kilo.note": "Acesso a mais de 500 modelos de IA", + // Provider settings translations + "settings.providers.group.recommended": "Recomendados", + "settings.providers.note.kilo": "Acesso a mais de 500 modelos de IA", + "settings.providers.note.opencode": "Modelos selecionados, incluindo Claude, GPT, Gemini e mais", + "settings.providers.note.anthropic": "Acesso direto aos modelos Claude, incluindo Pro e Max", + "settings.providers.note.deepseek": "Modelos DeepSeek para tarefas de raciocínio e programação", + "settings.providers.note.copilot": "Modelos Claude para assistência em programação", + "settings.providers.note.openai": "Modelos GPT e Codex com chave de API ou login do ChatGPT", + "settings.providers.note.google": "Modelos Gemini para respostas rápidas e estruturadas", + "settings.providers.note.openrouter": "Acesse todos os modelos compatíveis em um só provedor", + "settings.providers.note.vercel": "Acesso unificado a modelos de IA com roteamento inteligente", // Reasoning block label "ui.permission.run": "Executar", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "Servidores MCP", - "marketplace.tab.modes": "Modos", "marketplace.category.all": "Todos", "marketplace.placeholder": "A ser implementado", "marketplace.card.installed": "Instalado", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Cancelar", "marketplace.remove.confirm.button": "Remover", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agentes", "marketplace.search": "Pesquisar...", "marketplace.filter.all": "Todos os Itens", "marketplace.filter.notInstalled": "Não Instalado", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "servidor MCP", "marketplace.remove.type.skill": "habilidade", - "marketplace.remove.type.mode": "modo", + "marketplace.remove.type.agent": "agente", "marketplace.remove.failed": "Falha ao remover {{name}}", "marketplace.install": "Instalar", "marketplace.filter.installed": "Instalado", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Várias sessões estão em execução e serão interrompidas", "marketplace.warning.installAnyway": "Instalar mesmo assim", "marketplace.warning.cancel": "Cancelar", - "marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?", + "marketplace.contribute.prompt": "Está faltando uma skill, agente ou servidor MCP?", "marketplace.contribute.cta": "Contribuir no GitHub", + "marketplace.migration.notice": + "Os modos foram substituídos por agentes. Se você instalou modos do marketplace anteriormente, remova-os e reinstale-os como agentes para migrar para o novo formato.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implementar", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 2e4983260dc..6cbbb36e1d3 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Posjetite ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " da preuzmete svoj API ključ.", + "provider.connect.kiloGateway.byok.prefix": "Za više statistika korištenja, koristite ", + "provider.connect.kiloGateway.byok.link": "BYOK putem Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Preporučeno", - "dialog.provider.kilo.note": "Pristup za 500+ AI modela", + // Provider settings translations + "settings.providers.group.recommended": "Preporučeno", + "settings.providers.note.kilo": "Pristup za 500+ AI modela", + "settings.providers.note.opencode": "Odabrani modeli uključujući Claude, GPT, Gemini i još mnogo toga", + "settings.providers.note.anthropic": "Direktan pristup Claude modelima, uključujući Pro i Max", + "settings.providers.note.deepseek": "DeepSeek modeli za zadatke rezonovanja i programiranja", + "settings.providers.note.copilot": "Claude modeli za pomoć pri programiranju", + "settings.providers.note.openai": "GPT i Codex modeli uz API ključ ili ChatGPT prijavu", + "settings.providers.note.google": "Gemini modeli za brze, strukturirane odgovore", + "settings.providers.note.openrouter": "Pristup svim podržanim modelima od jednog provajdera", + "settings.providers.note.vercel": "Objedinjen pristup AI modelima s pametnim usmjeravanjem", // Desktop translations "desktop.menu.reloadWebview": "Ponovno učitavanje webview-a", @@ -24,7 +35,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Vještine", "marketplace.tab.mcpServers": "MCP Serveri", - "marketplace.tab.modes": "Modovi", "marketplace.category.all": "Sve", "marketplace.placeholder": "Biće implementirano", "marketplace.card.installed": "Instalirano", @@ -50,6 +60,7 @@ export const dict = { "marketplace.remove.cancel": "Otkaži", "marketplace.remove.confirm.button": "Ukloni", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agenti", "marketplace.search": "Pretraži...", "marketplace.filter.all": "Sve stavke", "marketplace.filter.notInstalled": "Nije instalirano", @@ -65,7 +76,7 @@ export const dict = { "marketplace.scope.global": "globalno", "marketplace.remove.type.mcp": "MCP server", "marketplace.remove.type.skill": "vještina", - "marketplace.remove.type.mode": "režim", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Uklanjanje {{name}} nije uspjelo", "marketplace.install": "Instaliraj", "marketplace.filter.installed": "Instalirano", @@ -74,8 +85,10 @@ export const dict = { "marketplace.warning.busyMany": "Nekoliko sesija je pokrenuto i bit će prekinuto", "marketplace.warning.installAnyway": "Instaliraj svejedno", "marketplace.warning.cancel": "Otkaži", - "marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?", + "marketplace.contribute.prompt": "Nedostaje vještina, agent ili MCP server?", "marketplace.contribute.cta": "Doprinesi na GitHub-u", + "marketplace.migration.notice": + "Modovi su zamijenjeni agentima. Ako ste prethodno instalirali marketplace modove, uklonite ih i ponovo instalirajte kao agente da biste prešli na novi format.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implementiraj", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index e930733acbe..c4a176c2b3d 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Besøg ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " for at hente din API-nøgle.", + "provider.connect.kiloGateway.byok.prefix": "For flere brugsstatistikker, brug ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Anbefalede", - "dialog.provider.kilo.note": "Adgang til 500+ AI-modeller", + // Provider settings translations + "settings.providers.group.recommended": "Anbefalede", + "settings.providers.note.kilo": "Adgang til 500+ AI-modeller", + "settings.providers.note.opencode": "Udvalgte modeller inklusive Claude, GPT, Gemini og mere", + "settings.providers.note.anthropic": "Direkte adgang til Claude-modeller, inklusive Pro og Max", + "settings.providers.note.deepseek": "DeepSeek-modeller til ræsonnement og kodningsopgaver", + "settings.providers.note.copilot": "Claude-modeller til kodningsassistance", + "settings.providers.note.openai": "GPT- og Codex-modeller med API-nøgle eller ChatGPT-login", + "settings.providers.note.google": "Gemini-modeller til hurtige, strukturerede svar", + "settings.providers.note.openrouter": "Adgang til alle understøttede modeller fra én udbyder", + "settings.providers.note.vercel": "Samlet adgang til AI-modeller med smart routing", // Reasoning block label "ui.permission.run": "Kør", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "MCP-servere", - "marketplace.tab.modes": "Tilstande", "marketplace.category.all": "Alle", "marketplace.placeholder": "Skal implementeres", "marketplace.card.installed": "Installeret", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Annuller", "marketplace.remove.confirm.button": "Fjern", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agenter", "marketplace.search": "Søg...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installeret", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "MCP-server", "marketplace.remove.type.skill": "færdighed", - "marketplace.remove.type.mode": "tilstand", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Kunne ikke fjerne {{name}}", "marketplace.install": "Installer", "marketplace.filter.installed": "Installeret", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Flere sessioner kører og vil blive afbrudt", "marketplace.warning.installAnyway": "Installer alligevel", "marketplace.warning.cancel": "Annuller", - "marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?", + "marketplace.contribute.prompt": "Mangler du en skill, agent eller MCP-server?", "marketplace.contribute.cta": "Bidrag på GitHub", + "marketplace.migration.notice": + "Tilstande er blevet erstattet af agenter. Hvis du tidligere har installeret marketplace-tilstande, skal du fjerne dem og geninstallere dem som agenter for at migrere til det nye format.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implementér", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index ce73601069b..aa77ae3035b 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Besuchen Sie ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": ", um Ihren API-Schlüssel zu erhalten.", + "provider.connect.kiloGateway.byok.prefix": "Für weitere Nutzungsstatistiken ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": " nutzen.", - // Provider dialog translations - "dialog.provider.group.recommended": "Empfohlen", - "dialog.provider.kilo.note": "Zugriff auf 500+ KI-Modelle", + // Provider settings translations + "settings.providers.group.recommended": "Empfohlen", + "settings.providers.note.kilo": "Zugriff auf 500+ KI-Modelle", + "settings.providers.note.opencode": "Kuratierte Modelle, darunter Claude, GPT, Gemini und mehr", + "settings.providers.note.anthropic": "Direkter Zugriff auf Claude-Modelle, einschließlich Pro und Max", + "settings.providers.note.deepseek": "DeepSeek-Modelle für Denk- und Programmieraufgaben", + "settings.providers.note.copilot": "Claude-Modelle für Programmierunterstützung", + "settings.providers.note.openai": "GPT- und Codex-Modelle mit API-Schlüssel oder ChatGPT-Anmeldung", + "settings.providers.note.google": "Gemini-Modelle für schnelle, strukturierte Antworten", + "settings.providers.note.openrouter": "Zugriff auf alle unterstützten Modelle über einen Anbieter", + "settings.providers.note.vercel": "Einheitlicher Zugriff auf KI-Modelle mit intelligentem Routing", // Reasoning block label "ui.permission.run": "Ausführen", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "MCP-Server", - "marketplace.tab.modes": "Modi", "marketplace.category.all": "Alle", "marketplace.placeholder": "Noch nicht implementiert", "marketplace.card.installed": "Installiert", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Abbrechen", "marketplace.remove.confirm.button": "Entfernen", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agenten", "marketplace.search": "Suchen...", "marketplace.filter.all": "Alle Elemente", "marketplace.filter.notInstalled": "Nicht installiert", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "Global", "marketplace.remove.type.mcp": "MCP-Server", "marketplace.remove.type.skill": "Skill", - "marketplace.remove.type.mode": "Modus", + "marketplace.remove.type.agent": "Agent", "marketplace.remove.failed": "Fehler beim Entfernen von {{name}}", "marketplace.install": "Installieren", "marketplace.filter.installed": "Installiert", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Mehrere Sitzungen laufen und werden unterbrochen", "marketplace.warning.installAnyway": "Trotzdem installieren", "marketplace.warning.cancel": "Abbrechen", - "marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?", + "marketplace.contribute.prompt": "Fehlt ein Skill, Agent oder MCP-Server?", "marketplace.contribute.cta": "Auf GitHub beitragen", + "marketplace.migration.notice": + "Modi wurden durch Agenten ersetzt. Wenn Sie zuvor Marketplace-Modi installiert haben, entfernen Sie diese bitte und installieren Sie sie als Agenten neu, um zum neuen Format zu migrieren.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Umsetzen", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 7f908032995..459ec98a10d 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -9,10 +9,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visit ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " to collect your API key.", + "provider.connect.kiloGateway.byok.prefix": "For more usage stats, ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Recommended", - "dialog.provider.kilo.note": "Access 500+ AI models", + // Provider settings translations + "settings.providers.group.recommended": "Recommended", + "settings.providers.note.kilo": "Access 500+ AI models", + "settings.providers.note.opencode": "Curated models including Claude, GPT, Gemini and more", + "settings.providers.note.anthropic": "Direct access to Claude models, including Pro and Max", + "settings.providers.note.deepseek": "DeepSeek models for reasoning and coding tasks", + "settings.providers.note.copilot": "Claude models for coding assistance", + "settings.providers.note.openai": "GPT and Codex models with API key or ChatGPT login", + "settings.providers.note.google": "Gemini models for fast, structured responses", + "settings.providers.note.openrouter": "Access all supported models from one provider", + "settings.providers.note.vercel": "Unified access to AI models with smart routing", // Reasoning block label "ui.permission.run": "Run", @@ -21,7 +32,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "MCP Servers", - "marketplace.tab.modes": "Modes", "marketplace.category.all": "All", "marketplace.placeholder": "To be implemented", "marketplace.card.installed": "Installed", @@ -47,6 +57,7 @@ export const dict = { "marketplace.remove.cancel": "Cancel", "marketplace.remove.confirm.button": "Remove", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agents", "marketplace.search": "Search...", "marketplace.filter.all": "All Items", "marketplace.filter.notInstalled": "Not Installed", @@ -62,7 +73,7 @@ export const dict = { "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "MCP server", "marketplace.remove.type.skill": "skill", - "marketplace.remove.type.mode": "mode", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Failed to remove {{name}}", "marketplace.install": "Install", "marketplace.filter.installed": "Installed", @@ -71,8 +82,10 @@ export const dict = { "marketplace.warning.busyMany": "Several sessions are running and will be interrupted", "marketplace.warning.installAnyway": "Install anyway", "marketplace.warning.cancel": "Cancel", - "marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?", + "marketplace.contribute.prompt": "Missing a skill, agent, or MCP server?", "marketplace.contribute.cta": "Contribute on GitHub", + "marketplace.migration.notice": + "Modes have been replaced by agents. If you previously installed marketplace modes, please remove and reinstall them as agents to migrate to the new format.", // Plan follow-up question shown after plan_exit. The English strings here must match // the canonical `label`/`header`/`question` sent by the backend — those canonical labels diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 07af68a1a0f..8f572f79637 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visita ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " para obtener tu clave API.", + "provider.connect.kiloGateway.byok.prefix": "Para más estadísticas de uso, utiliza ", + "provider.connect.kiloGateway.byok.link": "BYOK a través de Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Recomendados", - "dialog.provider.kilo.note": "Acceso a más de 500 modelos de IA", + // Provider settings translations + "settings.providers.group.recommended": "Recomendados", + "settings.providers.note.kilo": "Acceso a más de 500 modelos de IA", + "settings.providers.note.opencode": "Modelos seleccionados, incluidos Claude, GPT, Gemini y más", + "settings.providers.note.anthropic": "Acceso directo a modelos Claude, incluidos Pro y Max", + "settings.providers.note.deepseek": "Modelos DeepSeek para tareas de razonamiento y programación", + "settings.providers.note.copilot": "Modelos Claude para asistencia de programación", + "settings.providers.note.openai": "Modelos GPT y Codex con clave de API o inicio de sesión de ChatGPT", + "settings.providers.note.google": "Modelos Gemini para respuestas rápidas y estructuradas", + "settings.providers.note.openrouter": "Accede a todos los modelos compatibles desde un solo proveedor", + "settings.providers.note.vercel": "Acceso unificado a modelos de IA con enrutamiento inteligente", // Reasoning block label "ui.permission.run": "Ejecutar", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "Servidores MCP", - "marketplace.tab.modes": "Modos", "marketplace.category.all": "Todos", "marketplace.placeholder": "Por implementar", "marketplace.card.installed": "Instalado", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Cancelar", "marketplace.remove.confirm.button": "Eliminar", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agentes", "marketplace.search": "Buscar...", "marketplace.filter.all": "Todos los elementos", "marketplace.filter.notInstalled": "No instalado", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "servidor MCP", "marketplace.remove.type.skill": "habilidad", - "marketplace.remove.type.mode": "modo", + "marketplace.remove.type.agent": "agente", "marketplace.remove.failed": "Error al eliminar {{name}}", "marketplace.install": "Instalar", "marketplace.filter.installed": "Instalado", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Varias sesiones están en ejecución y se interrumpirán", "marketplace.warning.installAnyway": "Instalar de todas formas", "marketplace.warning.cancel": "Cancelar", - "marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?", + "marketplace.contribute.prompt": "¿Falta una skill, agente o servidor MCP?", "marketplace.contribute.cta": "Contribuir en GitHub", + "marketplace.migration.notice": + "Los modos han sido reemplazados por agentes. Si anteriormente instalaste modos del marketplace, elimínalos y reinstálalos como agentes para migrar al nuevo formato.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implementar", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 2faf61d345b..9d23df435d9 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Visitez ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " pour obtenir votre clé API.", + "provider.connect.kiloGateway.byok.prefix": "Pour plus de statistiques d'utilisation, utilisez ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Recommandés", - "dialog.provider.kilo.note": "Accès à plus de 500 modèles d'IA", + // Provider settings translations + "settings.providers.group.recommended": "Recommandés", + "settings.providers.note.kilo": "Accès à plus de 500 modèles d'IA", + "settings.providers.note.opencode": "Modèles sélectionnés, dont Claude, GPT, Gemini et plus encore", + "settings.providers.note.anthropic": "Accès direct aux modèles Claude, y compris Pro et Max", + "settings.providers.note.deepseek": "Modèles DeepSeek pour les tâches de raisonnement et de codage", + "settings.providers.note.copilot": "Modèles Claude pour l'assistance au codage", + "settings.providers.note.openai": "Modèles GPT et Codex avec clé API ou connexion ChatGPT", + "settings.providers.note.google": "Modèles Gemini pour des réponses rapides et structurées", + "settings.providers.note.openrouter": "Accédez à tous les modèles pris en charge depuis un seul fournisseur", + "settings.providers.note.vercel": "Accès unifié aux modèles IA avec routage intelligent", // Reasoning block label "ui.permission.run": "Exécuter", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "Serveurs MCP", - "marketplace.tab.modes": "Modes", "marketplace.category.all": "Tout", "marketplace.placeholder": "À implémenter", "marketplace.card.installed": "Installé", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Annuler", "marketplace.remove.confirm.button": "Supprimer", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agents", "marketplace.search": "Rechercher...", "marketplace.filter.all": "Tous les éléments", "marketplace.filter.notInstalled": "Non installé", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "serveur MCP", "marketplace.remove.type.skill": "compétence", - "marketplace.remove.type.mode": "mode", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Échec de la suppression de {{name}}", "marketplace.install": "Installer", "marketplace.filter.installed": "Installé", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Plusieurs sessions sont en cours et seront interrompues", "marketplace.warning.installAnyway": "Installer quand même", "marketplace.warning.cancel": "Annuler", - "marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?", + "marketplace.contribute.prompt": "Il manque une compétence, un agent ou un serveur MCP ?", "marketplace.contribute.cta": "Contribuer sur GitHub", + "marketplace.migration.notice": + "Les modes ont été remplacés par des agents. Si vous avez précédemment installé des modes depuis le marketplace, veuillez les supprimer et les réinstaller en tant qu'agents pour migrer vers le nouveau format.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implémenter", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts new file mode 100644 index 00000000000..adff571865d --- /dev/null +++ b/packages/kilo-i18n/src/it.ts @@ -0,0 +1,115 @@ +// Kilo-specific translations and overrides +// Keys here will override any matching keys from upstream translations +export const dict = { + // Kilo Gateway provider translations + "provider.connect.kiloGateway.line1": + "Kilo Gateway ti offre una selezione curata di modelli affidabili e ottimizzati per agenti di coding.", + "provider.connect.kiloGateway.line2": + "Con una sola API key puoi accedere a modelli come Claude, GPT, Gemini, GLM e altri.", + "provider.connect.kiloGateway.visit.prefix": "Visita ", + "provider.connect.kiloGateway.visit.link": "kilo.ai", + "provider.connect.kiloGateway.visit.suffix": " per ottenere la tua API key.", + "provider.connect.kiloGateway.byok.prefix": "Per ulteriori statistiche sull'utilizzo, utilizza ", + "provider.connect.kiloGateway.byok.link": "BYOK tramite Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", + + // Provider settings translations + "settings.providers.group.recommended": "Consigliati", + "settings.providers.note.kilo": "Accesso a oltre 500 modelli AI", + "settings.providers.note.opencode": "Modelli selezionati, inclusi Claude, GPT, Gemini e altri", + "settings.providers.note.anthropic": "Accesso diretto ai modelli Claude, inclusi Pro e Max", + "settings.providers.note.deepseek": "Modelli DeepSeek per attività di ragionamento e programmazione", + "settings.providers.note.copilot": "Modelli Claude per assistenza alla programmazione", + "settings.providers.note.openai": "Modelli GPT e Codex con chiave API o accesso ChatGPT", + "settings.providers.note.google": "Modelli Gemini per risposte rapide e strutturate", + "settings.providers.note.openrouter": "Accesso a tutti i modelli supportati da un unico provider", + "settings.providers.note.vercel": "Accesso unificato ai modelli IA con routing intelligente", + + // Reasoning block label + "ui.permission.run": "Esegui", + "ui.reasoning.label": "Ragionamento", + + // Marketplace + "marketplace.tab.skills": "Skill", + "marketplace.tab.mcpServers": "Server MCP", + "marketplace.tab.agents": "Agenti", + "marketplace.category.all": "Tutti", + "marketplace.placeholder": "Da implementare", + "marketplace.card.installed": "Installato", + "marketplace.card.install": "Installa", + "marketplace.card.remove": "Rimuovi", + "marketplace.card.removeScope": "Rimuovi ({{scope}})", + "marketplace.card.showMore": "Mostra altro", + "marketplace.card.showLess": "Mostra meno", + "marketplace.install.title": "Installa {{name}}", + "marketplace.install.scope": "Ambito", + "marketplace.install.scope.project": "Progetto", + "marketplace.install.scope.global": "Globale", + "marketplace.install.prerequisites": "Prerequisiti", + "marketplace.install.installing": "Installazione...", + "marketplace.install.cancel": "Annulla", + "marketplace.install.success": "Installato correttamente!", + "marketplace.install.failed": "Installazione non riuscita", + "marketplace.install.done": "Fatto", + "marketplace.install.close": "Chiudi", + "marketplace.remove.title": "Rimuovere {{name}}?", + "marketplace.remove.confirm": "Vuoi davvero rimuovere questo {{type}}? Verrà rimosso dalla configurazione {{scope}}.", + "marketplace.remove.cancel": "Annulla", + "marketplace.remove.confirm.button": "Rimuovi", + "marketplace.tab.mcp": "MCP", + "marketplace.search": "Cerca...", + "marketplace.filter.all": "Tutti gli elementi", + "marketplace.filter.notInstalled": "Non installati", + "marketplace.empty": "Nessun elemento trovato", + "marketplace.badge.mcpServer": "Server MCP", + "marketplace.badge.mode": "Modalità", + "marketplace.card.by": "di {{author}}", + "marketplace.install.method": "Metodo di installazione", + "marketplace.install.parameters": "Parametri", + "marketplace.install.optional": "(opzionale)", + "marketplace.install.required": "{{name}} è obbligatorio", + "marketplace.scope.project": "progetto", + "marketplace.scope.global": "globale", + "marketplace.remove.type.mcp": "server MCP", + "marketplace.remove.type.skill": "skill", + "marketplace.remove.type.agent": "agente", + "marketplace.remove.failed": "Rimozione di {{name}} non riuscita", + "marketplace.install": "Installa", + "marketplace.filter.installed": "Installati", + "marketplace.error.dismiss": "Ignora", + "marketplace.warning.busyOne": "Una sessione è attiva e verrà interrotta", + "marketplace.warning.busyMany": "Più sessioni sono attive e verranno interrotte", + "marketplace.warning.installAnyway": "Installa comunque", + "marketplace.warning.cancel": "Annulla", + "marketplace.contribute.prompt": "Manca una skill, una modalità o un server MCP?", + "marketplace.contribute.cta": "Contribuisci su GitHub", + "marketplace.migration.notice": + "Le Modalità sono state sostituite dagli agenti. Se in precedenza hai installato Modalità dal marketplace, rimuovile e reinstallale come agenti per migrare al nuovo formato.", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementa", + "plan.followup.question": "Pronto per implementare?", + "plan.followup.answer.newSession": "Avvia una nuova sessione", + "plan.followup.answer.newSession.description": "Implementa in una nuova sessione con contesto vuoto", + "plan.followup.answer.continue": "Continua qui", + "plan.followup.answer.continue.description": "Implementa il piano in questa sessione", + + "snapshot.slowRepo.header": "Snapshot lento", + "snapshot.slowRepo.question": + "L'inizializzazione del sistema snapshot sta richiedendo molto tempo, probabilmente a causa delle dimensioni del repository.\n\nVuoi disabilitare gli snapshot per questo repository?", + "snapshot.slowRepo.answer.continue": "Continua con gli snapshot", + "snapshot.slowRepo.answer.continue.description": + "Continua ad attendere il completamento dello snapshot. Le iterazioni successive saranno rapide dopo la creazione dello snapshot iniziale.", + "snapshot.slowRepo.answer.disable": "Disabilita per questo progetto", + "snapshot.slowRepo.answer.disable.description": + "Disattiva gli snapshot di Kilo per questo progetto. Perderai annulla/ripeti sulle modifiche ai file fatte da Kilo, ma git continuerà a tracciare tutto.", + + "ui.messagePart.openInDiffViewer": "Apri nel visualizzatore diff", + "ui.messagePart.shell.command": "Comando", + "ui.messagePart.shell.output": "Output", + "ui.messagePart.openInEditor": "Apri nell'editor", + + "ui.message.feedback.helpful": "È stato utile", + "ui.message.feedback.notHelpful": "Non è stato utile", + "ui.message.feedback.clearRating": "Cancella valutazione", +} diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index f30eae15557..25e4263ef3b 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -6,10 +6,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " にアクセスしてAPIキーを取得してください。", + "provider.connect.kiloGateway.byok.prefix": "詳細な使用統計については、", + "provider.connect.kiloGateway.byok.link": "Kilo's Gateway経由でBYOK", + "provider.connect.kiloGateway.byok.suffix": "をご利用ください。", - // Provider dialog translations - "dialog.provider.group.recommended": "おすすめ", - "dialog.provider.kilo.note": "500以上のAIモデルにアクセス", + // Provider settings translations + "settings.providers.group.recommended": "おすすめ", + "settings.providers.note.kilo": "500以上のAIモデルにアクセス", + "settings.providers.note.opencode": "Claude、GPT、Geminiなどの厳選モデル", + "settings.providers.note.anthropic": "ProやMaxを含むClaudeモデルへ直接アクセス", + "settings.providers.note.deepseek": "推論とコーディング作業向けのDeepSeekモデル", + "settings.providers.note.copilot": "コーディング支援向けのClaudeモデル", + "settings.providers.note.openai": "APIキーまたはChatGPTログインで使えるGPTとCodexモデル", + "settings.providers.note.google": "高速で構造化された応答向けのGeminiモデル", + "settings.providers.note.openrouter": "1つのプロバイダーからすべての対応モデルにアクセス", + "settings.providers.note.vercel": "スマートルーティングによるAIモデルへの統合アクセス", // Reasoning block label "ui.permission.run": "実行", @@ -18,7 +29,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "スキル", "marketplace.tab.mcpServers": "MCPサーバー", - "marketplace.tab.modes": "モード", "marketplace.category.all": "すべて", "marketplace.placeholder": "未実装", "marketplace.card.installed": "インストール済み", @@ -43,6 +53,7 @@ export const dict = { "marketplace.remove.cancel": "キャンセル", "marketplace.remove.confirm.button": "削除", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "エージェント", "marketplace.search": "検索...", "marketplace.filter.all": "すべてのアイテム", "marketplace.filter.notInstalled": "未インストール", @@ -58,7 +69,7 @@ export const dict = { "marketplace.scope.global": "グローバル", "marketplace.remove.type.mcp": "MCPサーバー", "marketplace.remove.type.skill": "スキル", - "marketplace.remove.type.mode": "モード", + "marketplace.remove.type.agent": "エージェント", "marketplace.remove.failed": "{{name}} の削除に失敗しました", "marketplace.install": "インストール", "marketplace.filter.installed": "インストール済み", @@ -67,8 +78,10 @@ export const dict = { "marketplace.warning.busyMany": "複数のセッションが実行中で中断されます", "marketplace.warning.installAnyway": "それでもインストール", "marketplace.warning.cancel": "キャンセル", - "marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?", + "marketplace.contribute.prompt": "スキル、エージェント、またはMCPサーバーが見つかりませんか?", "marketplace.contribute.cta": "GitHub で貢献する", + "marketplace.migration.notice": + "モードはエージェントに置き換えられました。以前にマーケットプレイスのモードをインストールしていた場合は、新しい形式に移行するためにそれらを削除してエージェントとして再インストールしてください。", // Plan follow-up question shown after plan_exit "plan.followup.header": "実装", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 8a9692001fe..52c674ceb57 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -6,10 +6,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": "를 방문하여 API 키를 받으세요.", + "provider.connect.kiloGateway.byok.prefix": "더 많은 사용 통계를 보려면 ", + "provider.connect.kiloGateway.byok.link": "Kilo's Gateway를 통해 BYOK", + "provider.connect.kiloGateway.byok.suffix": "를 사용하세요.", - // Provider dialog translations - "dialog.provider.group.recommended": "추천", - "dialog.provider.kilo.note": "500개 이상의 AI 모델 이용 가능", + // Provider settings translations + "settings.providers.group.recommended": "추천", + "settings.providers.note.kilo": "500개 이상의 AI 모델 이용 가능", + "settings.providers.note.opencode": "Claude, GPT, Gemini 등을 포함한 엄선된 모델", + "settings.providers.note.anthropic": "Pro 및 Max를 포함한 Claude 모델에 직접 액세스", + "settings.providers.note.deepseek": "추론 및 코딩 작업을 위한 DeepSeek 모델", + "settings.providers.note.copilot": "코딩 지원을 위한 Claude 모델", + "settings.providers.note.openai": "API 키 또는 ChatGPT 로그인으로 사용하는 GPT 및 Codex 모델", + "settings.providers.note.google": "빠르고 구조화된 응답을 위한 Gemini 모델", + "settings.providers.note.openrouter": "하나의 제공업체에서 모든 지원 모델에 액세스", + "settings.providers.note.vercel": "스마트 라우팅으로 AI 모델에 통합 액세스", // Reasoning block label "ui.permission.run": "실행", @@ -18,7 +29,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "스킬", "marketplace.tab.mcpServers": "MCP 서버", - "marketplace.tab.modes": "모드", "marketplace.category.all": "전체", "marketplace.placeholder": "구현 예정", "marketplace.card.installed": "설치됨", @@ -43,6 +53,7 @@ export const dict = { "marketplace.remove.cancel": "취소", "marketplace.remove.confirm.button": "제거", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "에이전트", "marketplace.search": "검색...", "marketplace.filter.all": "모든 항목", "marketplace.filter.notInstalled": "설치되지 않음", @@ -58,7 +69,7 @@ export const dict = { "marketplace.scope.global": "글로벌", "marketplace.remove.type.mcp": "MCP 서버", "marketplace.remove.type.skill": "스킬", - "marketplace.remove.type.mode": "모드", + "marketplace.remove.type.agent": "에이전트", "marketplace.remove.failed": "{{name}} 제거 실패", "marketplace.install": "설치", "marketplace.filter.installed": "설치됨", @@ -67,8 +78,10 @@ export const dict = { "marketplace.warning.busyMany": "여러 세션이 실행 중이며 중단됩니다", "marketplace.warning.installAnyway": "그래도 설치", "marketplace.warning.cancel": "취소", - "marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?", + "marketplace.contribute.prompt": "스킬, 에이전트 또는 MCP 서버가 없나요?", "marketplace.contribute.cta": "GitHub에서 기여하기", + "marketplace.migration.notice": + "모드가 에이전트로 대체되었습니다. 이전에 마켓플레이스 모드를 설치한 경우 새 형식으로 마이그레이션하려면 해당 모드를 제거하고 에이전트로 다시 설치하세요.", // Plan follow-up question shown after plan_exit "plan.followup.header": "구현", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 142c0958286..cb602379f6e 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -9,10 +9,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Bezoek ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " om je API key op te halen.", + "provider.connect.kiloGateway.byok.prefix": "Voor meer gebruiksstatistieken, gebruik ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Aanbevolen", - "dialog.provider.kilo.note": "Toegang tot 500+ AI modellen", + // Provider settings translations + "settings.providers.group.recommended": "Aanbevolen", + "settings.providers.note.kilo": "Toegang tot 500+ AI modellen", + "settings.providers.note.opencode": "Geselecteerde modellen, waaronder Claude, GPT, Gemini en meer", + "settings.providers.note.anthropic": "Directe toegang tot Claude-modellen, inclusief Pro en Max", + "settings.providers.note.deepseek": "DeepSeek-modellen voor redeneer- en codeertaken", + "settings.providers.note.copilot": "Claude-modellen voor hulp bij programmeren", + "settings.providers.note.openai": "GPT- en Codex-modellen met API-sleutel of ChatGPT-login", + "settings.providers.note.google": "Gemini-modellen voor snelle, gestructureerde antwoorden", + "settings.providers.note.openrouter": "Toegang tot alle ondersteunde modellen via één provider", + "settings.providers.note.vercel": "Geïntegreerde toegang tot AI-modellen met slimme routering", // Reasoning block label "ui.permission.run": "Uitvoeren", @@ -21,7 +32,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "MCP Servers", - "marketplace.tab.modes": "Modi", "marketplace.category.all": "Alle", "marketplace.placeholder": "Nog te implementeren", "marketplace.card.installed": "Geïnstalleerd", @@ -47,6 +57,7 @@ export const dict = { "marketplace.remove.cancel": "Annuleren", "marketplace.remove.confirm.button": "Verwijderen", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agenten", "marketplace.search": "Zoeken...", "marketplace.filter.all": "Alle items", "marketplace.filter.notInstalled": "Niet geïnstalleerd", @@ -62,7 +73,7 @@ export const dict = { "marketplace.scope.global": "globaal", "marketplace.remove.type.mcp": "MCP server", "marketplace.remove.type.skill": "skill", - "marketplace.remove.type.mode": "modus", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Verwijderen van {{name}} mislukt", "marketplace.install": "Installeren", "marketplace.filter.installed": "Geïnstalleerd", @@ -71,8 +82,10 @@ export const dict = { "marketplace.warning.busyMany": "Er zijn meerdere sessies actief en deze zullen worden onderbroken", "marketplace.warning.installAnyway": "Toch installeren", "marketplace.warning.cancel": "Annuleren", - "marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?", + "marketplace.contribute.prompt": "Mist u een skill, agent of MCP-server?", "marketplace.contribute.cta": "Bijdragen op GitHub", + "marketplace.migration.notice": + "Modi zijn vervangen door agenten. Als u eerder marketplace-modi hebt geïnstalleerd, verwijder ze dan en installeer ze opnieuw als agenten om naar het nieuwe formaat te migreren.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implementeren", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index e7f223b923b..7a14230d3ba 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Besøk ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " for å hente API-nøkkelen din.", + "provider.connect.kiloGateway.byok.prefix": "For mer bruksstatistikk, bruk ", + "provider.connect.kiloGateway.byok.link": "BYOK via Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Anbefalt", - "dialog.provider.kilo.note": "Tilgang til 500+ AI-modeller", + // Provider settings translations + "settings.providers.group.recommended": "Anbefalt", + "settings.providers.note.kilo": "Tilgang til 500+ AI-modeller", + "settings.providers.note.opencode": "Utvalgte modeller, inkludert Claude, GPT, Gemini og mer", + "settings.providers.note.anthropic": "Direkte tilgang til Claude-modeller, inkludert Pro og Max", + "settings.providers.note.deepseek": "DeepSeek-modeller for resonnering og kodeoppgaver", + "settings.providers.note.copilot": "Claude-modeller for kodeassistanse", + "settings.providers.note.openai": "GPT- og Codex-modeller med API-nøkkel eller ChatGPT-innlogging", + "settings.providers.note.google": "Gemini-modeller for raske, strukturerte svar", + "settings.providers.note.openrouter": "Tilgang til alle støttede modeller fra én leverandør", + "settings.providers.note.vercel": "Samlet tilgang til AI-modeller med smart ruting", // Reasoning block label "ui.permission.run": "Kjør", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "MCP-servere", - "marketplace.tab.modes": "Moduser", "marketplace.category.all": "Alle", "marketplace.placeholder": "Skal implementeres", "marketplace.card.installed": "Installert", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Avbryt", "marketplace.remove.confirm.button": "Fjern", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agenter", "marketplace.search": "Søk...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installert", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "global", "marketplace.remove.type.mcp": "MCP-server", "marketplace.remove.type.skill": "ferdighet", - "marketplace.remove.type.mode": "modus", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Kunne ikke fjerne {{name}}", "marketplace.install": "Installer", "marketplace.filter.installed": "Installert", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Flere økter kjører og vil bli avbrutt", "marketplace.warning.installAnyway": "Installer uansett", "marketplace.warning.cancel": "Avbryt", - "marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?", + "marketplace.contribute.prompt": "Mangler du en skill, agent eller MCP-server?", "marketplace.contribute.cta": "Bidra på GitHub", + "marketplace.migration.notice": + "Modi er erstattet av agenter. Hvis du tidligere har installert marketplace-modi, fjern dem og installer dem på nytt som agenter for å migrere til det nye formatet.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Implementer", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 8cfd61e3c2b..90244403794 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Odwiedź ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": ", aby otrzymać swój klucz API.", + "provider.connect.kiloGateway.byok.prefix": "Aby uzyskać więcej statystyk użycia, użyj ", + "provider.connect.kiloGateway.byok.link": "BYOK przez Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Polecane", - "dialog.provider.kilo.note": "Dostęp do ponad 500 modeli AI", + // Provider settings translations + "settings.providers.group.recommended": "Polecane", + "settings.providers.note.kilo": "Dostęp do ponad 500 modeli AI", + "settings.providers.note.opencode": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne", + "settings.providers.note.anthropic": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max", + "settings.providers.note.deepseek": "Modele DeepSeek do zadań rozumowania i kodowania", + "settings.providers.note.copilot": "Modele Claude do pomocy w kodowaniu", + "settings.providers.note.openai": "Modele GPT i Codex z kluczem API lub logowaniem ChatGPT", + "settings.providers.note.google": "Modele Gemini do szybkich, ustrukturyzowanych odpowiedzi", + "settings.providers.note.openrouter": "Dostęp do wszystkich obsługiwanych modeli od jednego dostawcy", + "settings.providers.note.vercel": "Ujednolicony dostęp do modeli AI z inteligentnym routingiem", // Reasoning block label "ui.permission.run": "Uruchom", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Skills", "marketplace.tab.mcpServers": "Serwery MCP", - "marketplace.tab.modes": "Tryby", "marketplace.category.all": "Wszystkie", "marketplace.placeholder": "Do zaimplementowania", "marketplace.card.installed": "Zainstalowano", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Anuluj", "marketplace.remove.confirm.button": "Usuń", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Agenci", "marketplace.search": "Szukaj...", "marketplace.filter.all": "Wszystkie elementy", "marketplace.filter.notInstalled": "Nie zainstalowano", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "globalny", "marketplace.remove.type.mcp": "serwer MCP", "marketplace.remove.type.skill": "umiejętność", - "marketplace.remove.type.mode": "tryb", + "marketplace.remove.type.agent": "agent", "marketplace.remove.failed": "Nie udało się usunąć {{name}}", "marketplace.install": "Zainstaluj", "marketplace.filter.installed": "Zainstalowano", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Kilka sesji jest uruchomionych i zostanie przerwanych", "marketplace.warning.installAnyway": "Zainstaluj mimo to", "marketplace.warning.cancel": "Anuluj", - "marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?", + "marketplace.contribute.prompt": "Brakuje umiejętności, agenta lub serwera MCP?", "marketplace.contribute.cta": "Wnieś wkład na GitHubie", + "marketplace.migration.notice": + "Tryby zostały zastąpione przez agentów. Jeśli wcześniej instalowałeś tryby z marketplace, usuń je i zainstaluj ponownie jako agenty, aby przejść na nowy format.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Wdróż", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 2a923436c22..53ed2cd440e 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Посетите ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": ", чтобы получить ваш API-ключ.", + "provider.connect.kiloGateway.byok.prefix": "Для получения дополнительной статистики использования используйте ", + "provider.connect.kiloGateway.byok.link": "BYOK через Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Рекомендуемые", - "dialog.provider.kilo.note": "Доступ к 500+ моделям ИИ", + // Provider settings translations + "settings.providers.group.recommended": "Рекомендуемые", + "settings.providers.note.kilo": "Доступ к 500+ моделям ИИ", + "settings.providers.note.opencode": "Подобранные модели, включая Claude, GPT, Gemini и другие", + "settings.providers.note.anthropic": "Прямой доступ к моделям Claude, включая Pro и Max", + "settings.providers.note.deepseek": "Модели DeepSeek для задач рассуждения и программирования", + "settings.providers.note.copilot": "Модели Claude для помощи в программировании", + "settings.providers.note.openai": "Модели GPT и Codex с API-ключом или входом через ChatGPT", + "settings.providers.note.google": "Модели Gemini для быстрых структурированных ответов", + "settings.providers.note.openrouter": "Доступ ко всем поддерживаемым моделям через одного провайдера", + "settings.providers.note.vercel": "Единый доступ к AI-моделям с умной маршрутизацией", // Reasoning block label "ui.permission.run": "Запустить", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Навыки", "marketplace.tab.mcpServers": "MCP-серверы", - "marketplace.tab.modes": "Режимы", "marketplace.category.all": "Все", "marketplace.placeholder": "Будет реализовано", "marketplace.card.installed": "Установлено", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Отмена", "marketplace.remove.confirm.button": "Удалить", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Агенты", "marketplace.search": "Поиск...", "marketplace.filter.all": "Все элементы", "marketplace.filter.notInstalled": "Не установлено", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "глобально", "marketplace.remove.type.mcp": "MCP-сервер", "marketplace.remove.type.skill": "навык", - "marketplace.remove.type.mode": "режим", + "marketplace.remove.type.agent": "агент", "marketplace.remove.failed": "Не удалось удалить {{name}}", "marketplace.install": "Установить", "marketplace.filter.installed": "Установлено", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Несколько сеансов выполняются и будут прерваны", "marketplace.warning.installAnyway": "Установить в любом случае", "marketplace.warning.cancel": "Отмена", - "marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?", + "marketplace.contribute.prompt": "Не хватает навыка, агента или MCP-сервера?", "marketplace.contribute.cta": "Внести вклад на GitHub", + "marketplace.migration.notice": + "Режимы заменены агентами. Если вы ранее устанавливали режимы из магазина, удалите их и переустановите как агенты, чтобы перейти на новый формат.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Реализовать", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index ae0c5a4d88c..48448e548ec 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "เยี่ยมชม ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " เพื่อรับ API key ของคุณ", + "provider.connect.kiloGateway.byok.prefix": "สำหรับสถิติการใช้งานเพิ่มเติม โปรดใช้ ", + "provider.connect.kiloGateway.byok.link": "BYOK ผ่าน Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": "", - // Provider dialog translations - "dialog.provider.group.recommended": "แนะนำ", - "dialog.provider.kilo.note": "เข้าถึงโมเดล AI มากกว่า 500 รายการ", + // Provider settings translations + "settings.providers.group.recommended": "แนะนำ", + "settings.providers.note.kilo": "เข้าถึงโมเดล AI มากกว่า 500 รายการ", + "settings.providers.note.opencode": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ", + "settings.providers.note.anthropic": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max", + "settings.providers.note.deepseek": "โมเดล DeepSeek สำหรับงานใช้เหตุผลและเขียนโค้ด", + "settings.providers.note.copilot": "โมเดล Claude สำหรับช่วยเขียนโค้ด", + "settings.providers.note.openai": "โมเดล GPT และ Codex พร้อมคีย์ API หรือการเข้าสู่ระบบ ChatGPT", + "settings.providers.note.google": "โมเดล Gemini สำหรับคำตอบที่รวดเร็วและเป็นโครงสร้าง", + "settings.providers.note.openrouter": "เข้าถึงโมเดลที่รองรับทั้งหมดจากผู้ให้บริการเดียว", + "settings.providers.note.vercel": "เข้าถึงโมเดล AI แบบรวมศูนย์พร้อมการกำหนดเส้นทางอัจฉริยะ", // Reasoning block label "ui.permission.run": "เรียกใช้", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "ทักษะ", "marketplace.tab.mcpServers": "เซิร์ฟเวอร์ MCP", - "marketplace.tab.modes": "โหมด", "marketplace.category.all": "ทั้งหมด", "marketplace.placeholder": "ยังไม่ได้ดำเนินการ", "marketplace.card.installed": "ติดตั้งแล้ว", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "ยกเลิก", "marketplace.remove.confirm.button": "ลบ", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "เอเจนต์", "marketplace.search": "ค้นหา...", "marketplace.filter.all": "รายการทั้งหมด", "marketplace.filter.notInstalled": "ยังไม่ได้ติดตั้ง", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "โกลบอล", "marketplace.remove.type.mcp": "เซิร์ฟเวอร์ MCP", "marketplace.remove.type.skill": "ทักษะ", - "marketplace.remove.type.mode": "โหมด", + "marketplace.remove.type.agent": "เอเจนต์", "marketplace.remove.failed": "ไม่สามารถลบ {{name}} ได้", "marketplace.install": "ติดตั้ง", "marketplace.filter.installed": "ติดตั้งแล้ว", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "มีหลายเซสชันกำลังทำงานและจะถูกขัดจังหวะ", "marketplace.warning.installAnyway": "ติดตั้งต่อไป", "marketplace.warning.cancel": "ยกเลิก", - "marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?", + "marketplace.contribute.prompt": "ขาดสกิล เอเจนต์ หรือเซิร์ฟเวอร์ MCP?", "marketplace.contribute.cta": "ร่วมสมทบบน GitHub", + "marketplace.migration.notice": + "โหมดถูกแทนที่ด้วยเอเจนต์แล้ว หากคุณเคยติดตั้งโหมดจาก marketplace กรุณาลบและติดตั้งใหม่เป็นเอเจนต์เพื่อย้ายไปยังรูปแบบใหม่", // Plan follow-up question shown after plan_exit "plan.followup.header": "ดำเนินการ", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 6297ed3295a..5714c919c7b 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "API anahtarınızı almak için ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " adresini ziyaret edin.", + "provider.connect.kiloGateway.byok.prefix": "Daha fazla kullanım istatistiği için ", + "provider.connect.kiloGateway.byok.link": "Kilo's Gateway üzerinden BYOK", + "provider.connect.kiloGateway.byok.suffix": " kullanın.", - // Provider dialog translations - "dialog.provider.group.recommended": "Önerilen", - "dialog.provider.kilo.note": "500+ AI modeline erişim", + // Provider settings translations + "settings.providers.group.recommended": "Önerilen", + "settings.providers.note.kilo": "500+ AI modeline erişim", + "settings.providers.note.opencode": "Claude, GPT, Gemini ve daha fazlasını içeren seçilmiş modeller", + "settings.providers.note.anthropic": "Pro ve Max dahil Claude modellerine doğrudan erişim", + "settings.providers.note.deepseek": "Akıl yürütme ve kodlama görevleri için DeepSeek modelleri", + "settings.providers.note.copilot": "Kodlama yardımı için Claude modelleri", + "settings.providers.note.openai": "API anahtarı veya ChatGPT girişiyle GPT ve Codex modelleri", + "settings.providers.note.google": "Hızlı, yapılandırılmış yanıtlar için Gemini modelleri", + "settings.providers.note.openrouter": "Desteklenen tüm modellere tek sağlayıcıdan erişim", + "settings.providers.note.vercel": "Akıllı yönlendirme ile AI modellerine birleşik erişim", // Reasoning block label "ui.permission.run": "Çalıştır", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Yetenekler", "marketplace.tab.mcpServers": "MCP Sunucuları", - "marketplace.tab.modes": "Modlar", "marketplace.category.all": "Tümü", "marketplace.placeholder": "Uygulanacak", "marketplace.card.installed": "Yüklendi", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "İptal", "marketplace.remove.confirm.button": "Kaldır", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Ajanlar", "marketplace.search": "Ara...", "marketplace.filter.all": "Tüm Öğeler", "marketplace.filter.notInstalled": "Yüklü Değil", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "genel", "marketplace.remove.type.mcp": "MCP sunucusu", "marketplace.remove.type.skill": "yetenek", - "marketplace.remove.type.mode": "mod", + "marketplace.remove.type.agent": "ajan", "marketplace.remove.failed": "{{name}} kaldırılamadı", "marketplace.install": "Yükle", "marketplace.filter.installed": "Yüklendi", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Birden fazla oturum çalışıyor ve kesintiye uğrayacak", "marketplace.warning.installAnyway": "Yine de yükle", "marketplace.warning.cancel": "İptal", - "marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?", + "marketplace.contribute.prompt": "Bir yetenek, ajan veya MCP sunucusu mu eksik?", "marketplace.contribute.cta": "GitHub'da katkıda bulun", + "marketplace.migration.notice": + "Modlar agentlarla değiştirildi. Daha önce marketplace modları yüklediyseniz, yeni formata geçiş yapmak için bunları kaldırın ve agent olarak yeniden yükleyin.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Uygula", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index afc11ae14e1..323bd314feb 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -7,10 +7,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "Відвідайте ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " щоб отримати свій API-ключ.", + "provider.connect.kiloGateway.byok.prefix": "Для отримання додаткової статистики використання використовуйте ", + "provider.connect.kiloGateway.byok.link": "BYOK через Kilo's Gateway", + "provider.connect.kiloGateway.byok.suffix": ".", - // Provider dialog translations - "dialog.provider.group.recommended": "Рекомендовані", - "dialog.provider.kilo.note": "Доступ до 500+ моделей ШІ", + // Provider settings translations + "settings.providers.group.recommended": "Рекомендовані", + "settings.providers.note.kilo": "Доступ до 500+ моделей ШІ", + "settings.providers.note.opencode": "Добірні моделі, зокрема Claude, GPT, Gemini та інші", + "settings.providers.note.anthropic": "Прямий доступ до моделей Claude, зокрема Pro і Max", + "settings.providers.note.deepseek": "Моделі DeepSeek для завдань міркування та програмування", + "settings.providers.note.copilot": "Моделі Claude для допомоги з програмуванням", + "settings.providers.note.openai": "Моделі GPT і Codex з API-ключем або входом через ChatGPT", + "settings.providers.note.google": "Моделі Gemini для швидких структурованих відповідей", + "settings.providers.note.openrouter": "Доступ до всіх підтримуваних моделей від одного провайдера", + "settings.providers.note.vercel": "Єдиний доступ до моделей ШІ з розумною маршрутизацією", // Reasoning block label "ui.permission.run": "Виконати", @@ -19,7 +30,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "Навички", "marketplace.tab.mcpServers": "MCP-сервери", - "marketplace.tab.modes": "Режими", "marketplace.category.all": "Усі", "marketplace.placeholder": "Буде реалізовано", "marketplace.card.installed": "Встановлено", @@ -45,6 +55,7 @@ export const dict = { "marketplace.remove.cancel": "Скасувати", "marketplace.remove.confirm.button": "Видалити", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "Агенти", "marketplace.search": "Пошук...", "marketplace.filter.all": "Усі елементи", "marketplace.filter.notInstalled": "Не встановлено", @@ -60,7 +71,7 @@ export const dict = { "marketplace.scope.global": "глобально", "marketplace.remove.type.mcp": "MCP-сервер", "marketplace.remove.type.skill": "навичка", - "marketplace.remove.type.mode": "режим", + "marketplace.remove.type.agent": "агент", "marketplace.remove.failed": "Не вдалося видалити {{name}}", "marketplace.install": "Встановити", "marketplace.filter.installed": "Встановлено", @@ -69,8 +80,10 @@ export const dict = { "marketplace.warning.busyMany": "Виконується кілька сесій, їх буде перервано", "marketplace.warning.installAnyway": "Встановити все одно", "marketplace.warning.cancel": "Скасувати", - "marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?", + "marketplace.contribute.prompt": "Бракує навички, агента або MCP-сервера?", "marketplace.contribute.cta": "Зробити внесок на GitHub", + "marketplace.migration.notice": + "Режими замінено агентами. Якщо ви раніше встановлювали режими з маркетплейсу, видаліть їх та перевстановіть як агенти для переходу на новий формат.", // Plan follow-up question shown after plan_exit "plan.followup.header": "Реалізувати", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 37954ff59d2..ec0da59e6ed 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -5,10 +5,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "访问 ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " 获取您的 API 密钥。", + "provider.connect.kiloGateway.byok.prefix": "如需更多使用统计信息,请", + "provider.connect.kiloGateway.byok.link": "通过 Kilo's Gateway 进行 BYOK", + "provider.connect.kiloGateway.byok.suffix": "。", - // Provider dialog translations - "dialog.provider.group.recommended": "推荐", - "dialog.provider.kilo.note": "访问 500+ AI 模型", + // Provider settings translations + "settings.providers.group.recommended": "推荐", + "settings.providers.note.kilo": "访问 500+ AI 模型", + "settings.providers.note.opencode": "精选模型,包括 Claude、GPT、Gemini 等", + "settings.providers.note.anthropic": "直接访问 Claude 模型,包括 Pro 和 Max", + "settings.providers.note.deepseek": "用于推理和编码任务的 DeepSeek 模型", + "settings.providers.note.copilot": "用于编码辅助的 Claude 模型", + "settings.providers.note.openai": "使用 API 密钥或 ChatGPT 登录访问 GPT 和 Codex 模型", + "settings.providers.note.google": "用于快速结构化响应的 Gemini 模型", + "settings.providers.note.openrouter": "通过一个提供商访问所有支持的模型", + "settings.providers.note.vercel": "通过智能路由统一访问 AI 模型", // Reasoning block label "ui.permission.run": "运行", @@ -17,7 +28,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "技能", "marketplace.tab.mcpServers": "MCP 服务器", - "marketplace.tab.modes": "模式", "marketplace.category.all": "全部", "marketplace.placeholder": "待实现", "marketplace.card.installed": "已安装", @@ -42,6 +52,7 @@ export const dict = { "marketplace.remove.cancel": "取消", "marketplace.remove.confirm.button": "移除", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "智能体", "marketplace.search": "搜索...", "marketplace.filter.all": "所有项目", "marketplace.filter.notInstalled": "未安装", @@ -57,7 +68,7 @@ export const dict = { "marketplace.scope.global": "全局", "marketplace.remove.type.mcp": "MCP 服务器", "marketplace.remove.type.skill": "技能", - "marketplace.remove.type.mode": "模式", + "marketplace.remove.type.agent": "智能体", "marketplace.remove.failed": "移除 {{name}} 失败", "marketplace.install": "安装", "marketplace.filter.installed": "已安装", @@ -66,8 +77,10 @@ export const dict = { "marketplace.warning.busyMany": "多个会话正在运行,将被中断", "marketplace.warning.installAnyway": "仍然安装", "marketplace.warning.cancel": "取消", - "marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?", + "marketplace.contribute.prompt": "缺少技能、智能体或 MCP 服务器?", "marketplace.contribute.cta": "在 GitHub 上贡献", + "marketplace.migration.notice": + "模式已被智能体取代。如果您之前安装了市场中的模式,请将其删除并重新安装为智能体,以迁移到新格式。", // Plan follow-up question shown after plan_exit "plan.followup.header": "实现", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 5561173b89d..d6f1ccd27e9 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -5,10 +5,21 @@ export const dict = { "provider.connect.kiloGateway.visit.prefix": "訪問 ", "provider.connect.kiloGateway.visit.link": "kilo.ai", "provider.connect.kiloGateway.visit.suffix": " 獲取您的 API 金鑰。", + "provider.connect.kiloGateway.byok.prefix": "如需更多使用統計資訊,請", + "provider.connect.kiloGateway.byok.link": "透過 Kilo's Gateway 進行 BYOK", + "provider.connect.kiloGateway.byok.suffix": "。", - // Provider dialog translations - "dialog.provider.group.recommended": "推薦", - "dialog.provider.kilo.note": "存取 500+ AI 模型", + // Provider settings translations + "settings.providers.group.recommended": "推薦", + "settings.providers.note.kilo": "存取 500+ AI 模型", + "settings.providers.note.opencode": "精選模型,包括 Claude、GPT、Gemini 等", + "settings.providers.note.anthropic": "直接存取 Claude 模型,包括 Pro 和 Max", + "settings.providers.note.deepseek": "用於推理和程式設計工作的 DeepSeek 模型", + "settings.providers.note.copilot": "用於程式設計輔助的 Claude 模型", + "settings.providers.note.openai": "使用 API 金鑰或 ChatGPT 登入存取 GPT 和 Codex 模型", + "settings.providers.note.google": "用於快速結構化回應的 Gemini 模型", + "settings.providers.note.openrouter": "透過單一供應商存取所有支援的模型", + "settings.providers.note.vercel": "透過智慧路由統一存取 AI 模型", // Reasoning block label "ui.permission.run": "執行", @@ -17,7 +28,6 @@ export const dict = { // Marketplace "marketplace.tab.skills": "技能", "marketplace.tab.mcpServers": "MCP 伺服器", - "marketplace.tab.modes": "模式", "marketplace.category.all": "全部", "marketplace.placeholder": "待實作", "marketplace.card.installed": "已安裝", @@ -42,6 +52,7 @@ export const dict = { "marketplace.remove.cancel": "取消", "marketplace.remove.confirm.button": "移除", "marketplace.tab.mcp": "MCP", + "marketplace.tab.agents": "智能體", "marketplace.search": "搜尋...", "marketplace.filter.all": "所有項目", "marketplace.filter.notInstalled": "未安裝", @@ -57,7 +68,7 @@ export const dict = { "marketplace.scope.global": "全域", "marketplace.remove.type.mcp": "MCP 伺服器", "marketplace.remove.type.skill": "技能", - "marketplace.remove.type.mode": "模式", + "marketplace.remove.type.agent": "智能體", "marketplace.remove.failed": "移除 {{name}} 失敗", "marketplace.install": "安裝", "marketplace.filter.installed": "已安裝", @@ -66,8 +77,10 @@ export const dict = { "marketplace.warning.busyMany": "多個工作階段正在執行,將被中斷", "marketplace.warning.installAnyway": "仍然安裝", "marketplace.warning.cancel": "取消", - "marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?", + "marketplace.contribute.prompt": "缺少技能、智能體或 MCP 伺服器?", "marketplace.contribute.cta": "在 GitHub 上貢獻", + "marketplace.migration.notice": + "模式已被智能體取代。如果您之前安裝了市場中的模式,請將其刪除並重新安裝為智能體,以遷移到新格式。", // Plan follow-up question shown after plan_exit "plan.followup.header": "實作", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index e6069c60a4d..4d8f2381e1a 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.3.8", + "version": "7.3.54", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-indexing/src/config.ts b/packages/kilo-indexing/src/config.ts index 8eae226e0ca..b68097af855 100644 --- a/packages/kilo-indexing/src/config.ts +++ b/packages/kilo-indexing/src/config.ts @@ -1,8 +1,11 @@ import { Schema } from "effect" import z from "zod" import type { IndexingConfigInput } from "./indexing/config-manager" +import { DEFAULT_VECTOR_STORE } from "./indexing/constants" import type { EmbedderProvider } from "./indexing/interfaces/manager" +export { DEFAULT_VECTOR_STORE } from "./indexing/constants" + const providers = [ "kilo", "openai", @@ -21,14 +24,15 @@ export const IndexingConfig = z .object({ enabled: z.boolean().optional().describe("Enable codebase indexing"), provider: z.enum(providers).optional().describe("Embedding provider to use for codebase indexing"), - model: z.string().optional().describe("Embedding model ID (uses provider default if omitted)"), + model: z.string().nullable().optional().describe("Embedding model ID (uses provider default if omitted)"), dimension: z .number() .int() .positive() + .nullable() .optional() .describe("Override embedding vector dimension (auto-detected from model if omitted)"), - vectorStore: z.enum(stores).optional().describe("Vector store backend (default: qdrant)"), + vectorStore: z.enum(stores).optional().describe("Vector store backend (default: lancedb)"), kilo: z .object({ apiKey: z.string().optional(), @@ -140,13 +144,13 @@ export const IndexingSchema = Schema.Struct({ provider: Schema.optional(Provider).annotate({ description: "Embedding provider to use for codebase indexing", }), - model: Schema.optional(Schema.String).annotate({ + model: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Embedding model ID (uses provider default if omitted)", }), - dimension: Schema.optional(PositiveInt).annotate({ + dimension: Schema.optional(Schema.NullOr(PositiveInt)).annotate({ description: "Override embedding vector dimension (auto-detected from model if omitted)", }), - vectorStore: Schema.optional(Store).annotate({ description: "Vector store backend (default: qdrant)" }), + vectorStore: Schema.optional(Store).annotate({ description: "Vector store backend (default: lancedb)" }), kilo: Schema.optional( Schema.Struct({ apiKey: Schema.optional(Schema.String), @@ -236,9 +240,9 @@ export function toIndexingConfigInput(cfg: IndexingConfig | undefined): Indexing return { enabled: cfg?.enabled ?? false, embedderProvider: provider, - vectorStoreProvider: cfg?.vectorStore, - modelId: cfg?.model, - modelDimension: cfg?.dimension, + vectorStoreProvider: cfg?.vectorStore ?? DEFAULT_VECTOR_STORE, + modelId: cfg?.model ?? undefined, + modelDimension: cfg?.dimension ?? undefined, lancedbVectorStoreDirectory: cfg?.lancedb?.directory, qdrantUrl: cfg?.qdrant?.url, qdrantApiKey: cfg?.qdrant?.apiKey, diff --git a/packages/kilo-indexing/src/file/ignore.ts b/packages/kilo-indexing/src/file/ignore.ts index 292405cb9f9..df5c45e889f 100644 --- a/packages/kilo-indexing/src/file/ignore.ts +++ b/packages/kilo-indexing/src/file/ignore.ts @@ -44,6 +44,8 @@ export namespace FileIgnore { "**/*.log", "**/coverage/**", "**/.nyc_output/**", + "**/.kilo/worktrees/**", + "**/.kilocode/worktrees/**", ] export const PATTERNS = [...files, ...folders] diff --git a/packages/kilo-indexing/src/indexing/cache-manager.ts b/packages/kilo-indexing/src/indexing/cache-manager.ts index 5973cf96c74..ba5cd3735f1 100644 --- a/packages/kilo-indexing/src/indexing/cache-manager.ts +++ b/packages/kilo-indexing/src/indexing/cache-manager.ts @@ -16,6 +16,7 @@ export class CacheManager implements ICacheManager { private readonly cachePath: string private fileHashes: Record = {} private saveTimer: ReturnType | undefined + private saveTask = Promise.resolve() constructor( private readonly cacheDirectory: string, @@ -36,28 +37,36 @@ export class CacheManager implements ICacheManager { private scheduleSave(): void { if (this.saveTimer) clearTimeout(this.saveTimer) - this.saveTimer = setTimeout(() => this.performSave(), 1500) + this.saveTimer = setTimeout(() => { + void this.flush().catch((err) => log.error("failed to save cache", { err })) + }, 1500) } private async performSave(): Promise { - try { - await fs.mkdir(path.dirname(this.cachePath), { recursive: true }) - const tmp = `${this.cachePath}.tmp` - await fs.writeFile(tmp, JSON.stringify(this.fileHashes), "utf-8") - await fs.rename(tmp, this.cachePath) - } catch (err) { + await fs.mkdir(path.dirname(this.cachePath), { recursive: true }) + const tmp = `${this.cachePath}.tmp` + await fs.writeFile(tmp, JSON.stringify(this.fileHashes), "utf-8") + await fs.rename(tmp, this.cachePath) + } + + async flush(): Promise { + if (this.saveTimer) clearTimeout(this.saveTimer) + this.saveTimer = undefined + const task = this.saveTask.then(() => this.performSave()) + this.saveTask = task.catch((err) => { log.error("failed to save cache", { err }) - } + }) + await task + } + + seedHashes(hashes: Readonly>): void { + this.fileHashes = { ...hashes } + this.scheduleSave() } async clearCacheFile(): Promise { - try { - this.fileHashes = {} - await fs.mkdir(path.dirname(this.cachePath), { recursive: true }) - await fs.writeFile(this.cachePath, "{}", "utf-8") - } catch (err) { - log.error("failed to clear cache file", { err }) - } + this.fileHashes = {} + await this.flush() } getHash(filePath: string): string | undefined { @@ -77,4 +86,16 @@ export class CacheManager implements ICacheManager { getAllHashes(): Record { return { ...this.fileHashes } } + + signature(): string { + const entries = Object.entries(this.fileHashes).sort(([left], [right]) => left.localeCompare(right)) + return createHash("sha256").update(JSON.stringify(entries)).digest("hex") + } + + async stamp(): Promise { + return fs + .stat(this.cachePath) + .then((value) => `${value.mtimeMs}:${value.ctimeMs}:${value.size}`) + .catch(() => undefined) + } } diff --git a/packages/kilo-indexing/src/indexing/config-manager.ts b/packages/kilo-indexing/src/indexing/config-manager.ts index fd8c63210d9..574ea644207 100644 --- a/packages/kilo-indexing/src/indexing/config-manager.ts +++ b/packages/kilo-indexing/src/indexing/config-manager.ts @@ -1,6 +1,6 @@ import type { EmbedderProvider } from "./interfaces/manager" import type { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config" -import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants" +import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_VECTOR_STORE } from "./constants" import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "./model-registry" import { isEmbeddingProfileEqual, resolveEmbeddingProfile } from "./embedding-profile" @@ -50,14 +50,14 @@ export interface IndexingConfigInput { export class CodeIndexConfigManager { private enabled = false private embedderProvider: EmbedderProvider = "openai" - private vectorStoreProvider: "lancedb" | "qdrant" = "qdrant" + private vectorStoreProvider: "lancedb" | "qdrant" = DEFAULT_VECTOR_STORE private lancedbVectorStoreDirectory?: string private modelId?: string private modelDimension?: number private kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string } private openAiOptions?: { apiKey: string } private ollamaOptions?: { baseUrl: string; modelId?: string } - private openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + private openAiCompatibleOptions?: { baseUrl: string; apiKey?: string } private geminiOptions?: { apiKey: string } private mistralOptions?: { apiKey: string } private vercelAiGatewayOptions?: { apiKey: string } @@ -88,7 +88,7 @@ export class CodeIndexConfigManager { private applyInput(input: IndexingConfigInput): void { this.enabled = input.enabled this.embedderProvider = input.embedderProvider - this.vectorStoreProvider = input.vectorStoreProvider ?? "qdrant" + this.vectorStoreProvider = input.vectorStoreProvider ?? DEFAULT_VECTOR_STORE this.lancedbVectorStoreDirectory = input.lancedbVectorStoreDirectory this.qdrantUrl = input.qdrantUrl ?? "http://localhost:6333" this.qdrantApiKey = input.qdrantApiKey @@ -112,10 +112,9 @@ export class CodeIndexConfigManager { this.openAiOptions = input.openAiKey ? { apiKey: input.openAiKey } : undefined const url = input.ollamaBaseUrl ?? (input.embedderProvider === "ollama" ? "http://localhost:11434" : undefined) this.ollamaOptions = url ? { baseUrl: url, modelId: input.modelId } : undefined - this.openAiCompatibleOptions = - input.openAiCompatibleBaseUrl && input.openAiCompatibleApiKey - ? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey } - : undefined + this.openAiCompatibleOptions = input.openAiCompatibleBaseUrl + ? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey?.trim() || undefined } + : undefined this.geminiOptions = input.geminiApiKey ? { apiKey: input.geminiApiKey } : undefined this.mistralOptions = input.mistralApiKey ? { apiKey: input.mistralApiKey } : undefined this.vercelAiGatewayOptions = input.vercelAiGatewayApiKey ? { apiKey: input.vercelAiGatewayApiKey } : undefined @@ -168,8 +167,7 @@ export class CodeIndexConfigManager { return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore) if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore) if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore) - if (provider === "openai-compatible") - return !!(this.openAiCompatibleOptions?.baseUrl && this.openAiCompatibleOptions?.apiKey && hasStore) + if (provider === "openai-compatible") return !!(this.openAiCompatibleOptions?.baseUrl && hasStore) if (provider === "gemini") return !!(this.geminiOptions?.apiKey && hasStore) if (provider === "mistral") return !!(this.mistralOptions?.apiKey && hasStore) if (provider === "vercel-ai-gateway") return !!(this.vercelAiGatewayOptions?.apiKey && hasStore) @@ -196,7 +194,7 @@ export class CodeIndexConfigManager { if (prevProvider !== this.embedderProvider) return true // Vector store provider change - if ((prev.vectorStoreProvider ?? "qdrant") !== this.vectorStoreProvider) return true + if ((prev.vectorStoreProvider ?? DEFAULT_VECTOR_STORE) !== this.vectorStoreProvider) return true // LanceDB path change if ( @@ -258,7 +256,7 @@ export class CodeIndexConfigManager { return { isConfigured: this.isConfigured(), embedderProvider: this.embedderProvider, - vectorStoreProvider: this.vectorStoreProvider ?? "qdrant", + vectorStoreProvider: this.vectorStoreProvider, lancedbVectorStoreDirectoryPlaceholder: this.lancedbVectorStoreDirectory, modelId: this.modelId, modelDimension: this.modelDimension, diff --git a/packages/kilo-indexing/src/indexing/constants/index.ts b/packages/kilo-indexing/src/indexing/constants/index.ts index a381a6351c0..ef8a6666733 100644 --- a/packages/kilo-indexing/src/indexing/constants/index.ts +++ b/packages/kilo-indexing/src/indexing/constants/index.ts @@ -1,6 +1,8 @@ /** * Codebase Index Constants */ +export const DEFAULT_VECTOR_STORE = "lancedb" as const + export const CODEBASE_INDEX_DEFAULTS = { MIN_SEARCH_RESULTS: 10, MAX_SEARCH_RESULTS: 200, diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts index 5eb7500a6ae..6b65c486e0a 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -42,7 +42,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private embeddingsClient: OpenAI private readonly defaultModelId: string private readonly baseUrl: string - private readonly apiKey: string + private readonly apiKey?: string private readonly isFullUrl: boolean private readonly maxItemTokens: number private readonly headers: Record @@ -61,13 +61,13 @@ export class OpenAICompatibleEmbedder implements IEmbedder { /** * Creates a new OpenAI Compatible embedder * @param baseUrl The base URL for the OpenAI-compatible API endpoint - * @param apiKey The API key for authentication + * @param apiKey Optional API key for authentication * @param modelId Optional model identifier (defaults to "text-embedding-3-small") * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) */ constructor( baseUrl: string, - apiKey: string, + apiKey?: string, modelId?: string, maxItemTokens?: number, options: OpenAICompatibleOptions = {}, @@ -75,22 +75,24 @@ export class OpenAICompatibleEmbedder implements IEmbedder { if (!baseUrl) { throw new Error("Base URL is required for OpenAI-compatible embedder") } - if (!apiKey) { - throw new Error("API key is required for OpenAI-compatible embedder") - } this.baseUrl = baseUrl - this.apiKey = apiKey - - try { - this.embeddingsClient = new OpenAI({ - baseURL: baseUrl, - apiKey: apiKey, - defaultHeaders: options.headers, - }) - } catch (error) { - throw error instanceof Error ? error : new Error(String(error)) - } + this.apiKey = apiKey?.trim() || undefined + + const defaults = new Headers(options.headers) + this.embeddingsClient = new OpenAI({ + baseURL: baseUrl, + apiKey: this.apiKey ?? "EMPTY", + defaultHeaders: options.headers, + fetch: this.apiKey + ? undefined + : async (input, init) => { + const headers = new Headers(init?.headers) + if (!defaults.has("authorization")) headers.delete("authorization") + if (!defaults.has("api-key")) headers.delete("api-key") + return globalThis.fetch(input, { ...init, headers: Object.fromEntries(headers) }) + }, + }) this.defaultModelId = modelId || getDefaultModelId("openai-compatible") // Cache the URL type check for performance @@ -213,10 +215,12 @@ export class OpenAICompatibleEmbedder implements IEmbedder { headers: { "Content-Type": "application/json", ...this.headers, - // Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization' - // We'll try 'api-key' first for Azure compatibility - "api-key": this.apiKey, - Authorization: `Bearer ${this.apiKey}`, + ...(this.apiKey + ? { + "api-key": this.apiKey, + Authorization: `Bearer ${this.apiKey}`, + } + : {}), }, body: JSON.stringify({ input: batchTexts, diff --git a/packages/kilo-indexing/src/indexing/interfaces/config.ts b/packages/kilo-indexing/src/indexing/interfaces/config.ts index 817ddac977a..a0eee87973f 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/config.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/config.ts @@ -17,7 +17,7 @@ export interface CodeIndexConfig { kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string } openAiOptions?: { apiKey: string } ollamaOptions?: { baseUrl: string; modelId?: string } - openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + openAiCompatibleOptions?: { baseUrl: string; apiKey?: string } geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } vercelAiGatewayOptions?: { apiKey: string } diff --git a/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts b/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts index 03a80a823c2..704d5761ee7 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts @@ -1,6 +1,7 @@ import type { PointStruct } from "./vector-store" import type { Disposable, Emitter } from "../runtime" import type { IndexingTelemetryMode } from "./telemetry" +import type { WorktreeOverlay } from "../worktree-overlay" export interface ICodeParser { parseFile( @@ -36,6 +37,8 @@ export interface IFileWatcher extends Disposable { initialize(): Promise updateBatchSegmentThreshold(newThreshold: number): void setCollecting(collecting: boolean): void + setOverlay?(overlay?: WorktreeOverlay): void + shutdown?(): Promise readonly onDidStartBatchProcessing: Emitter readonly onBatchProgressUpdate: Emitter<{ diff --git a/packages/kilo-indexing/src/indexing/interfaces/manager.ts b/packages/kilo-indexing/src/indexing/interfaces/manager.ts index 5d1e0433f49..c4ffad9efc0 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/manager.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/manager.ts @@ -31,7 +31,7 @@ export interface ICodeIndexManager { totalItems: number currentItemUnit: string } - dispose(): void + dispose(): Promise } export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" diff --git a/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts b/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts index 43253e0d8d1..7712502f14a 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts @@ -8,6 +8,12 @@ export type PointStruct = { } export interface IVectorStore { + /** + * Opens an existing complete store without mutating it. + */ + openExisting?(): Promise + close?(): Promise + /** * Initializes the vector store * @returns Promise resolving to boolean indicating if a new collection was created @@ -90,6 +96,7 @@ export interface VectorStoreSearchResult { export interface Payload { filePath: string + fileHash?: string codeChunk: string startLine: number endLine: number diff --git a/packages/kilo-indexing/src/indexing/manager.ts b/packages/kilo-indexing/src/indexing/manager.ts index 5fc017f18c5..05e4c5fc15d 100644 --- a/packages/kilo-indexing/src/indexing/manager.ts +++ b/packages/kilo-indexing/src/indexing/manager.ts @@ -1,8 +1,9 @@ -import type { VectorStoreSearchResult } from "./interfaces" +import path from "path" +import type { IVectorStore, VectorStoreSearchResult } from "./interfaces" import type { IndexingState } from "./interfaces/manager" import type { IndexingTelemetryEvent, IndexingTelemetryMeta, IndexingTelemetryTrigger } from "./interfaces/telemetry" import { CodeIndexConfigManager, type IndexingConfigInput } from "./config-manager" -import { INITIAL_MANAGER_RECOVERY_DELAY_MS, MAX_MANAGER_RECOVERY_ATTEMPTS } from "./constants" +import { DEFAULT_VECTOR_STORE, INITIAL_MANAGER_RECOVERY_DELAY_MS, MAX_MANAGER_RECOVERY_ATTEMPTS } from "./constants" import { CodeIndexStateManager } from "./state-manager" import { CodeIndexServiceFactory } from "./service-factory" import { CodeIndexSearchService } from "./search-service" @@ -12,8 +13,18 @@ import { Emitter } from "./runtime" import { Log } from "../util/log" import { loadIgnore } from "./shared/load-ignore" import { sanitizeErrorMessage } from "./shared/validation-helpers" +import { WorktreeOverlay } from "./worktree-overlay" const log = Log.create({ service: "indexing-manager" }) +const BASELINE_CHECK_INTERVAL = 1_000 +const BASELINE_SIGNATURE_INTERVAL = 30_000 + +type Baseline = { + store?: IVectorStore + signature: string + stamp?: string + overlay?: WorktreeOverlay +} /** * RATIONALE: Removed the static singleton Map and vscode.ExtensionContext. @@ -29,6 +40,13 @@ export class CodeIndexManager { private _orchestrator: CodeIndexOrchestrator | undefined private _searchService: CodeIndexSearchService | undefined private _cacheManager: CacheManager | undefined + private _baselineStore: IVectorStore | undefined + private _baselineSignature: string | undefined + private _baselineStamp: string | undefined + private _baselineChecked = 0 + private _baselineSigned = 0 + private _baselineRefresh: Promise | undefined + private _overlay: WorktreeOverlay | undefined private _isRecoveringFromError = false private _retryTimer: ReturnType | undefined private _retryResolve: (() => void) | undefined @@ -41,6 +59,7 @@ export class CodeIndexManager { constructor( public readonly workspacePath: string, private readonly cacheDirectory: string, + public readonly baselinePath?: string, ) { this._stateManager = new CodeIndexStateManager() } @@ -60,7 +79,7 @@ export class CodeIndexManager { const cfg = this._configManager.getConfig() return { provider: cfg.embedderProvider, - vectorStore: cfg.vectorStoreProvider ?? "qdrant", + vectorStore: cfg.vectorStoreProvider ?? DEFAULT_VECTOR_STORE, modelId: cfg.modelId, } } @@ -130,7 +149,7 @@ export class CodeIndexManager { } if (event.type !== "error") return - if (event.location !== "orchestrator:startIndexing") return + if (event.location !== "orchestrator:startIndexing" && event.location !== "orchestrator:watcher") return if (!this.isFeatureEnabled || !this.isFeatureConfigured) return if (this._retryTask || this._isRecoveringFromError) return @@ -405,15 +424,13 @@ export class CodeIndexManager { await task } - public dispose(): void { + public async dispose(): Promise { if (this._disposed) return this._disposed = true this.clearRetryTimer() this._retryTask = undefined - // RATIONALE: cancelIndexing() sets _cancelRequested and calls stopWatcher() + - // scanner.cancel(), which cooperatively aborts any in-flight scan. Using only - // stopWatcher() left the orchestrator's _runScan() unaware it should exit. - this._orchestrator?.cancelIndexing() + await this._orchestrator?.shutdown?.() + await this._baselineStore?.close?.() this._stateManager.dispose() this._telemetry.dispose() } @@ -437,30 +454,103 @@ export class CodeIndexManager { public async searchIndex(query: string, directoryPrefix?: string): Promise { if (!this.isFeatureEnabled) return [] this.assertInitialized() + await this.refreshBaseline() return this._searchService!.searchIndex(query, directoryPrefix) } - private async _recreateServices(): Promise { - log.info("starting indexing service recreation", { workspacePath: this.workspacePath }) - this._orchestrator?.stopWatcher() - this._orchestrator = undefined - this._searchService = undefined + private async refreshBaseline(): Promise { + if (!this.baselinePath || this._disposed) return + if (this._baselineRefresh) return this._baselineRefresh + const now = Date.now() + if (now - this._baselineChecked < BASELINE_CHECK_INTERVAL) return + this._baselineChecked = now + const baselinePath = this.baselinePath + + const task = (async () => { + const cache = new CacheManager(this.cacheDirectory, baselinePath) + const stamp = await cache.stamp() + const force = now - this._baselineSigned >= BASELINE_SIGNATURE_INTERVAL + if (!force && stamp === this._baselineStamp) return + + await cache.initialize() + const signature = cache.signature() + this._baselineStamp = stamp + this._baselineSigned = now + if (signature === this._baselineSignature && this._baselineStore) return + + const baseline = await this.createBaseline(this._serviceFactory!) + this._baselineStamp = baseline?.stamp ?? stamp + this._baselineSigned = now + if (!baseline?.store) { + if (!this._baselineStore) this._baselineSignature = signature + return + } + + log.info("shared indexing baseline changed; rebuilding worktree delta", { + workspacePath: this.workspacePath, + baselinePath, + }) + await this._recreateServices(baseline) + if (this._disposed) return + await this._orchestrator?.startIndexing("background") + })().finally(() => { + this._baselineRefresh = undefined + }) + this._baselineRefresh = task + return task + } + + private async createBaseline(factory: CodeIndexServiceFactory): Promise { + if (!this.baselinePath) return + + const cache = new CacheManager(this.cacheDirectory, this.baselinePath) + await cache.initialize() + const signature = cache.signature() + const stamp = await cache.stamp() + const hashes = new Map() + for (const [filePath, hash] of Object.entries(cache.getAllHashes())) { + const rel = path.relative(this.baselinePath, filePath) + if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue + hashes.set(rel.replaceAll("\\", "/"), hash) + } + + const store = factory.createVectorStore(this.baselinePath) + try { + if (!store.openExisting) throw new Error("The configured vector store cannot open a shared baseline") + // Validate compatibility without keeping every worktree baseline connection open. + await store.openExisting() + await store.close?.() + return { + store, + signature, + stamp, + overlay: new WorktreeOverlay(this.workspacePath, this.baselinePath, hashes), + } + } catch (err) { + await store.close?.() + log.warn("shared indexing baseline is unavailable; using an independent worktree index", { + workspacePath: this.workspacePath, + baselinePath: this.baselinePath, + err, + }) + return { signature, stamp } + } + } - this._serviceFactory = new CodeIndexServiceFactory( + private async _recreateServices(prepared?: Baseline): Promise { + log.info("starting indexing service recreation", { workspacePath: this.workspacePath }) + const factory = new CodeIndexServiceFactory( this._configManager!, this.workspacePath, this._cacheManager!, this.cacheDirectory, (event) => this.handleTelemetry(event), ) - const ignoreInstance = await loadIgnore(this.workspacePath) - const config = this._configManager!.getConfig() - const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices( - this._cacheManager!, - ignoreInstance, - ) + const baseline = prepared ?? (await this.createBaseline(factory)) + const { embedder, vectorStore, scanner, fileWatcher } = factory.createServices(this._cacheManager!, ignoreInstance) + fileWatcher.setOverlay?.(baseline?.overlay) log.info("created indexing services", { workspacePath: this.workspacePath, provider: embedder.embedderInfo.name, @@ -469,13 +559,12 @@ export class CodeIndexManager { }) const shouldValidate = embedder && embedder.embedderInfo.name === config.embedderProvider - if (shouldValidate) { log.info("validating embedder configuration", { workspacePath: this.workspacePath, provider: embedder.embedderInfo.name, }) - const validationResult = await this._serviceFactory.validateEmbedder(embedder) + const validationResult = await factory.validateEmbedder(embedder) if (!validationResult.valid) { const errorMessage = validationResult.error || "Embedder configuration validation failed" this._stateManager.setSystemState("Error", errorMessage) @@ -487,7 +576,7 @@ export class CodeIndexManager { }) } - this._orchestrator = new CodeIndexOrchestrator( + const orchestrator = new CodeIndexOrchestrator( this._configManager!, this._stateManager, this.workspacePath, @@ -496,10 +585,33 @@ export class CodeIndexManager { scanner, fileWatcher, (event) => this.handleTelemetry(event), + baseline?.overlay, + Boolean(this.baselinePath && !baseline?.store), + ) + const search = new CodeIndexSearchService( + this._configManager!, + this._stateManager, + embedder, + vectorStore, + baseline?.store && baseline.overlay ? { store: baseline.store, overlay: baseline.overlay } : undefined, ) - this._searchService = new CodeIndexSearchService(this._configManager!, this._stateManager, embedder, vectorStore) + await this._orchestrator?.shutdown?.() + await this._baselineStore?.close?.() + if (this._disposed) { + await orchestrator.shutdown() + await baseline?.store?.close?.() + return + } + this._serviceFactory = factory + this._orchestrator = orchestrator + this._searchService = search + this._baselineStore = baseline?.store + this._baselineSignature = baseline?.signature + this._baselineStamp = baseline?.stamp + this._baselineSigned = Date.now() + this._overlay = baseline?.overlay this._stateManager.setSystemState("Standby", "") log.info("indexing services are ready", { workspacePath: this.workspacePath }) } diff --git a/packages/kilo-indexing/src/indexing/orchestrator.ts b/packages/kilo-indexing/src/indexing/orchestrator.ts index f20805bfc10..8e2d08ef8bf 100644 --- a/packages/kilo-indexing/src/indexing/orchestrator.ts +++ b/packages/kilo-indexing/src/indexing/orchestrator.ts @@ -16,6 +16,8 @@ import type { CacheManager } from "./cache-manager" import type { Disposable } from "./runtime" import { Log } from "../util/log" import { sanitizeErrorMessage } from "./shared/validation-helpers" +import { DEFAULT_VECTOR_STORE } from "./constants" +import type { WorktreeOverlay } from "./worktree-overlay" const log = Log.create({ service: "indexing-orchestrator" }) @@ -23,6 +25,7 @@ export class CodeIndexOrchestrator { private _fileWatcherSubscriptions: Disposable[] = [] private _isProcessing = false private _cancelRequested = false + private _active?: Promise constructor( private readonly configManager: CodeIndexConfigManager, @@ -33,13 +36,15 @@ export class CodeIndexOrchestrator { private readonly scanner: DirectoryScanner, private readonly fileWatcher: IFileWatcher, private readonly onTelemetry?: IndexingTelemetryReporter, + private readonly overlay?: WorktreeOverlay, + private readonly independent = false, ) {} private getTelemetryMeta(): IndexingTelemetryMeta { const cfg = this.configManager.getConfig() return { provider: cfg.embedderProvider, - vectorStore: cfg.vectorStoreProvider ?? "qdrant", + vectorStore: cfg.vectorStoreProvider ?? DEFAULT_VECTOR_STORE, modelId: cfg.modelId, } } @@ -81,6 +86,9 @@ export class CodeIndexOrchestrator { this.stateManager.setSystemState("Indexing", "Initializing file watcher...") try { + this.fileWatcher.setCollecting(false) + for (const sub of this._fileWatcherSubscriptions) sub.dispose() + this._fileWatcherSubscriptions = [] await this.fileWatcher.initialize() log.info("file watcher initialized", { workspacePath: this.workspacePath }) @@ -105,6 +113,7 @@ export class CodeIndexOrchestrator { workspacePath: this.workspacePath, totalInBatch, }) + if (this.stateManager.state === "Error") return if (totalInBatch > 0) { this.stateManager.setSystemState("Indexed", "File changes processed. Index up-to-date.") } else if (this.stateManager.state === "Indexing") { @@ -113,9 +122,11 @@ export class CodeIndexOrchestrator { } }), this.fileWatcher.onDidFinishBatchProcessing.on((summary: BatchProcessingSummary) => { - if (summary.batchError) { - log.error("batch processing failed", { err: summary.batchError }) - } + if (!summary.batchError) return + log.error("batch processing failed", { err: summary.batchError }) + this.overlay?.prepare() + this.stateManager.setSystemState("Error", `Failed to process file changes: ${summary.batchError.message}`) + this.emitError("orchestrator:watcher", summary.batchError, "watcher") }), ] this.fileWatcher.setCollecting(false) @@ -126,7 +137,16 @@ export class CodeIndexOrchestrator { } } - public async startIndexing(trigger: IndexingTelemetryTrigger = "background"): Promise { + public startIndexing(trigger: IndexingTelemetryTrigger = "background"): Promise { + if (this._active) return this._active + const task = this.runIndexing(trigger).finally(() => { + if (this._active === task) this._active = undefined + }) + this._active = task + return task + } + + private async runIndexing(trigger: IndexingTelemetryTrigger): Promise { log.info("indexing start requested", { workspacePath: this.workspacePath, state: this.stateManager.state, @@ -165,6 +185,7 @@ export class CodeIndexOrchestrator { let mode: IndexingTelemetryMode | undefined try { + this.overlay?.prepare() await this._startWatcher() if (this._cancelRequested) { @@ -182,12 +203,27 @@ export class CodeIndexOrchestrator { return } - if (collectionCreated) { + if (this.overlay) { + if (!collectionCreated) await this.vectorStore.clearCollection() await this.cacheManager.clearCacheFile() - log.info("cleared indexing cache after new collection creation", { workspacePath: this.workspacePath }) + this.cacheManager.seedHashes(this.overlay.seed()) + await this.cacheManager.flush?.() + log.info("seeded worktree index from shared baseline", { + workspacePath: this.workspacePath, + baselinePath: this.overlay.baselinePath, + files: this.overlay.baseline.size, + }) } - const hasExistingData = await this.vectorStore.hasIndexedData() + const hasExistingData = this.overlay || this.independent ? false : await this.vectorStore.hasIndexedData() + if (!this.overlay && !hasExistingData) { + if (!collectionCreated) await this.vectorStore.clearCollection() + await this.cacheManager.clearCacheFile() + log.info("cleared indexing cache before full scan", { + workspacePath: this.workspacePath, + collectionCreated, + }) + } log.info("checked vector store indexed data", { workspacePath: this.workspacePath, hasExistingData, @@ -241,6 +277,8 @@ export class CodeIndexOrchestrator { private async _runScan(mode: IndexingTelemetryMode, trigger: IndexingTelemetryTrigger): Promise { if (this._cancelRequested) { + if (mode === "incremental") await this.vectorStore.markIndexingComplete() + this.stateManager.setSystemState("Standby", "Indexing cancelled.") log.info("scan skipped: cancellation was requested", { workspacePath: this.workspacePath, mode }) return } @@ -283,6 +321,10 @@ export class CodeIndexOrchestrator { }) if (this._cancelRequested || this.scanner.isCancelled) { + if (mode === "incremental" && result.stats.processed === 0 && batchErrors.length === 0) { + await this.vectorStore.markIndexingComplete() + log.info("preserved unchanged index after cancelled scan", { workspacePath: this.workspacePath }) + } this._isProcessing = false if (this.stateManager.state !== "Error") { this.stateManager.setSystemState("Standby", "Indexing cancelled.") @@ -291,6 +333,10 @@ export class CodeIndexOrchestrator { return } + if (this.overlay && batchErrors.length > 0) { + throw batchErrors[0] + } + if (mode === "full") { // Validate full scan results if (cumulativeFilesIndexed === 0 && cumulativeFilesFound > 0) { @@ -311,8 +357,11 @@ export class CodeIndexOrchestrator { } } - this.fileWatcher.setCollecting(true) + this.overlay?.reconcile(this.cacheManager.getAllHashes()) + await this.cacheManager.flush?.() await this.vectorStore.markIndexingComplete() + await this.vectorStore.close?.() + this.fileWatcher.setCollecting(true) this.stateManager.setSystemState("Indexed", "File watcher started. Index up-to-date.") log.info("workspace scan finalized", { workspacePath: this.workspacePath, @@ -334,6 +383,19 @@ export class CodeIndexOrchestrator { }) } + public async shutdown(): Promise { + this._cancelRequested = true + this.scanner.cancel() + this.fileWatcher.setCollecting(false) + await this._active + for (const sub of this._fileWatcherSubscriptions) sub.dispose() + this._fileWatcherSubscriptions = [] + if (this.fileWatcher.shutdown) await this.fileWatcher.shutdown() + else this.fileWatcher.dispose() + await this.vectorStore.close?.() + this._isProcessing = false + } + public stopWatcher(): void { log.info("stopping file watcher", { workspacePath: this.workspacePath }) this.fileWatcher.dispose() diff --git a/packages/kilo-indexing/src/indexing/processors/file-watcher.ts b/packages/kilo-indexing/src/indexing/processors/file-watcher.ts index 22ece9fd5d3..eda57909ab6 100644 --- a/packages/kilo-indexing/src/indexing/processors/file-watcher.ts +++ b/packages/kilo-indexing/src/indexing/processors/file-watcher.ts @@ -31,6 +31,7 @@ import { } from "../shared/get-relative-path" import { FileIgnore } from "../../file/ignore" import { Log } from "../../util/log" +import type { WorktreeOverlay } from "../worktree-overlay" import { sanitizeErrorMessage } from "../shared/validation-helpers" const log = Log.create({ service: "file-watcher" }) @@ -50,9 +51,11 @@ export class FileWatcher implements IFileWatcher { private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10 private batchSegmentThreshold: number private maxBatchRetries: number - private collecting = true + private collecting = false private draining = false + private drainTask?: Promise private ready?: Promise + private overlay?: WorktreeOverlay public readonly onDidStartBatchProcessing = new Emitter() public readonly onBatchProgressUpdate = new Emitter<{ @@ -150,8 +153,16 @@ export class FileWatcher implements IFileWatcher { log.info("file watcher ready", { workspacePath: this.workspacePath }) } + setOverlay(overlay?: WorktreeOverlay): void { + this.overlay = overlay + } + setCollecting(collecting: boolean): void { this.collecting = collecting + if (!collecting && this.batchProcessDebounceTimer) { + clearTimeout(this.batchProcessDebounceTimer) + this.batchProcessDebounceTimer = undefined + } log.info("updated watcher collection mode", { workspacePath: this.workspacePath, collecting, @@ -170,11 +181,20 @@ export class FileWatcher implements IFileWatcher { /** * Disposes the file watcher and cleans up resources. */ + async shutdown(): Promise { + this.collecting = false + if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer) + this.batchProcessDebounceTimer = undefined + await this.watcher?.close() + await this.drainTask + this.dispose() + } + dispose(): void { - this.watcher?.close() - if (this.batchProcessDebounceTimer) { - clearTimeout(this.batchProcessDebounceTimer) - } + this.collecting = false + void this.watcher?.close() + if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer) + this.batchProcessDebounceTimer = undefined this.onDidStartBatchProcessing.dispose() this.onBatchProgressUpdate.dispose() this.onDidFinishBatchProcessing.dispose() @@ -187,6 +207,7 @@ export class FileWatcher implements IFileWatcher { */ private handleFileEvent(filePath: string, type: "create" | "change" | "delete"): void { if (!this.shouldIndex(filePath)) return + this.overlay?.block(filePath) this.accumulatedEvents.set(filePath, { path: filePath, type }) if (!this.collecting) return this.scheduleBatchProcessing() @@ -196,11 +217,22 @@ export class FileWatcher implements IFileWatcher { * Schedules batch processing with debounce. */ private scheduleBatchProcessing(): void { - if (!this.collecting) return + if (!this.collecting || this.drainTask) return if (this.batchProcessDebounceTimer) { clearTimeout(this.batchProcessDebounceTimer) } - this.batchProcessDebounceTimer = setTimeout(() => this.triggerBatchProcessing(), this.BATCH_DEBOUNCE_DELAY_MS) + this.batchProcessDebounceTimer = setTimeout(() => { + this.batchProcessDebounceTimer = undefined + const task = this.triggerBatchProcessing().catch((err) => { + const error = err instanceof Error ? err : new Error(String(err)) + this.collecting = false + this.onDidFinishBatchProcessing.fire({ processedFiles: [], batchError: error }) + }) + this.drainTask = task.finally(() => { + this.drainTask = undefined + if (this.collecting && this.accumulatedEvents.size > 0) this.scheduleBatchProcessing() + }) + }, this.BATCH_DEBOUNCE_DELAY_MS) } /** @@ -217,17 +249,20 @@ export class FileWatcher implements IFileWatcher { pendingEvents: this.accumulatedEvents.size, }) - while (this.collecting && this.accumulatedEvents.size > 0) { - const eventsToProcess = new Map(this.accumulatedEvents) - this.accumulatedEvents.clear() + try { + while (this.collecting && this.accumulatedEvents.size > 0) { + const eventsToProcess = new Map(this.accumulatedEvents) + this.accumulatedEvents.clear() - const filePathsInBatch = Array.from(eventsToProcess.keys()) - this.onDidStartBatchProcessing.fire(filePathsInBatch) - await this.processBatch(eventsToProcess) + const filePathsInBatch = Array.from(eventsToProcess.keys()) + this.onDidStartBatchProcessing.fire(filePathsInBatch) + await this.processBatch(eventsToProcess) + } + } finally { + this.draining = false + if (this.collecting && this.accumulatedEvents.size > 0) this.scheduleBatchProcessing() + log.info("completed watcher event drain", { workspacePath: this.workspacePath }) } - - this.draining = false - log.info("completed watcher event drain", { workspacePath: this.workspacePath }) } private shouldIndex(filePath: string) { @@ -414,45 +449,49 @@ export class FileWatcher implements IFileWatcher { batchResults: FileProcessingResult[], overallBatchError?: Error, ): Promise { - if (pointsForBatchUpsert.length > 0 && this.vectorStore && !overallBatchError) { + if (!overallBatchError) { try { - for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) { - const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold) - let retryCount = 0 - let upsertError: Error | undefined - - while (retryCount < this.maxBatchRetries) { - try { - await this.vectorStore.upsertPoints(batch) - break - } catch (error) { - upsertError = error as Error - retryCount++ - if (retryCount === this.maxBatchRetries) { - log.error("upsert retry exhausted", { - error: sanitizeErrorMessage(upsertError.message), - location: "upsertPoints", - errorType: "upsert_retry_exhausted", - retryCount: this.maxBatchRetries, - }) - this.emitError("file-watcher:upsert_retry_exhausted", upsertError, this.maxBatchRetries) - throw new Error(`Failed to upsert batch after ${this.maxBatchRetries} retries: ${upsertError.message}`) + if (pointsForBatchUpsert.length > 0 && this.vectorStore) { + for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) { + const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold) + let retryCount = 0 + let upsertError: Error | undefined + + while (retryCount < this.maxBatchRetries) { + try { + await this.vectorStore.upsertPoints(batch) + break + } catch (error) { + upsertError = error as Error + retryCount++ + if (retryCount === this.maxBatchRetries) { + log.error("upsert retry exhausted", { + error: sanitizeErrorMessage(upsertError.message), + location: "upsertPoints", + errorType: "upsert_retry_exhausted", + retryCount: this.maxBatchRetries, + }) + this.emitError("file-watcher:upsert_retry_exhausted", upsertError, this.maxBatchRetries) + throw new Error( + `Failed to upsert batch after ${this.maxBatchRetries} retries: ${upsertError.message}`, + ) + } + this.emitRetry(retryCount, batch.length, upsertError) + await new Promise((resolve) => + setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1)), + ) } - this.emitRetry(retryCount, batch.length, upsertError) - await new Promise((resolve) => setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1))) } } } - for (const { path, newHash } of successfullyProcessedForUpsert) { - if (newHash) { - this.cacheManager.updateHash(path, newHash) - } - batchResults.push({ path, status: "success" }) + for (const item of successfullyProcessedForUpsert) { + if (item.newHash) this.cacheManager.updateHash(item.path, item.newHash) + batchResults.push({ path: item.path, status: "success", newHash: item.newHash }) } } catch (error) { const err = error as Error - overallBatchError = overallBatchError || err + overallBatchError = err this.emitError("file-watcher:batch_upsert_error", err) log.error("batch upsert error", { error: sanitizeErrorMessage(err.message), @@ -460,13 +499,13 @@ export class FileWatcher implements IFileWatcher { errorType: "batch_upsert_error", affectedFiles: successfullyProcessedForUpsert.length, }) - for (const { path } of successfullyProcessedForUpsert) { - batchResults.push({ path, status: "error", error: err }) + for (const item of successfullyProcessedForUpsert) { + batchResults.push({ path: item.path, status: "error", error: err }) } } - } else if (overallBatchError && pointsForBatchUpsert.length > 0) { - for (const { path } of successfullyProcessedForUpsert) { - batchResults.push({ path, status: "error", error: overallBatchError }) + } else { + for (const item of successfullyProcessedForUpsert) { + batchResults.push({ path: item.path, status: "error", error: overallBatchError }) } } @@ -497,16 +536,33 @@ export class FileWatcher implements IFileWatcher { // Categorize events const pathsToExplicitlyDelete: string[] = [] const filesToUpsertDetails: Array<{ path: string; originalType: "create" | "change" }> = [] + const reverts = new Map() for (const event of eventsToProcess.values()) { if (event.type === "delete") { pathsToExplicitlyDelete.push(event.path) - } else { - filesToUpsertDetails.push({ - path: event.path, - originalType: event.type, - }) + continue + } + + const cached = this.cacheManager.getHash(event.path) + const hash = await readFile(event.path, "utf-8") + .then((content) => createHash("sha256").update(content).digest("hex")) + .catch(() => undefined) + if (cached && hash === cached) { + batchResults.push({ path: event.path, status: "success", newHash: cached }) + processedCountInBatch++ + continue + } + if (hash && hash === this.overlay?.baselineHash(event.path)) { + pathsToExplicitlyDelete.push(event.path) + reverts.set(event.path, hash) + continue } + + filesToUpsertDetails.push({ + path: event.path, + originalType: event.type, + }) } log.info("processing file watcher batch", { @@ -526,6 +582,9 @@ export class FileWatcher implements IFileWatcher { ) overallBatchError = deletionError processedCountInBatch = deletionCount + if (!deletionError) { + for (const [filePath, hash] of reverts) this.cacheManager.updateHash(filePath, hash) + } // Phase 2: Process files and prepare upserts const { @@ -549,6 +608,16 @@ export class FileWatcher implements IFileWatcher { overallBatchError, ) + const resultError = batchResults.find((item) => item.status === "error" || item.status === "local_error")?.error + overallBatchError ??= resultError + await this.cacheManager.flush() + + for (const event of eventsToProcess.values()) { + const result = batchResults.findLast((item) => item.path === event.path) + if (result?.status !== "success") continue + this.overlay?.settle(event.path, this.cacheManager.getHash(event.path), this.accumulatedEvents.has(event.path)) + } + // Finalize this.onDidFinishBatchProcessing.fire({ processedFiles: batchResults, @@ -668,6 +737,7 @@ export class FileWatcher implements IFileWatcher { vector, payload: { filePath: generateRelativeFilePath(normalizedAbsolutePath, this.workspacePath), + fileHash: block.fileHash, codeChunk: block.content, startLine: block.start_line, endLine: block.end_line, diff --git a/packages/kilo-indexing/src/indexing/processors/scanner.ts b/packages/kilo-indexing/src/indexing/processors/scanner.ts index 0c137589689..564c0d47c60 100644 --- a/packages/kilo-indexing/src/indexing/processors/scanner.ts +++ b/packages/kilo-indexing/src/indexing/processors/scanner.ts @@ -295,6 +295,10 @@ export class DirectoryScanner implements IDirectoryScanner { onFileParsed?.() processedCount++ + if (!isNewFile && this.vectorStore) { + await this.vectorStore.deletePointsByMultipleFilePaths([filePath]) + } + // Process embeddings if configured if (this.embedder && this.vectorStore && blocks.length > 0) { // Add to batch accumulators @@ -303,7 +307,7 @@ export class DirectoryScanner implements IDirectoryScanner { const info = { filePath, fileHash: currentFileHash, - isNew: isNewFile, + isNew: true, } for (const block of blocks) { if (this._cancelled) break @@ -608,6 +612,7 @@ export class DirectoryScanner implements IDirectoryScanner { vector, payload: { filePath: generateRelativeFilePath(normalizedAbsolutePath, scanWorkspace), + fileHash: block.fileHash, codeChunk: block.content, startLine: block.start_line, endLine: block.end_line, diff --git a/packages/kilo-indexing/src/indexing/search-service.ts b/packages/kilo-indexing/src/indexing/search-service.ts index 954ba7e0ea7..139c1a41a85 100644 --- a/packages/kilo-indexing/src/indexing/search-service.ts +++ b/packages/kilo-indexing/src/indexing/search-service.ts @@ -5,6 +5,12 @@ import type { IVectorStore } from "./interfaces/vector-store" import type { CodeIndexConfigManager } from "./config-manager" import type { CodeIndexStateManager } from "./state-manager" import { Log } from "../util/log" +import type { WorktreeOverlay } from "./worktree-overlay" + +interface BaselineSearch { + store: IVectorStore + overlay: WorktreeOverlay +} const log = Log.create({ service: "indexing-search" }) @@ -14,6 +20,7 @@ export class CodeIndexSearchService { private readonly stateManager: CodeIndexStateManager, private readonly embedder: IEmbedder, private readonly vectorStore: IVectorStore, + private readonly baseline?: BaselineSearch, ) {} public async searchIndex(query: string, directoryPrefix?: string): Promise { @@ -37,7 +44,44 @@ export class CodeIndexSearchService { } const normalizedPrefix = directoryPrefix ? path.normalize(directoryPrefix) : undefined - return await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults) + if (!this.baseline) return await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults) + if (!this.baseline.overlay.ready) throw new Error("Worktree index reconciliation is not complete.") + + const ceiling = Math.max(maxResults, Math.min(maxResults * 16, 1000)) + const delta = (async () => { + const search = async (limit: number): Promise => { + const results = await this.vectorStore.search(vector, normalizedPrefix, minScore, limit) + const filtered = results.filter((result) => this.baseline!.overlay.deltaResult(result)) + if (filtered.length >= maxResults || results.length < limit || limit >= ceiling) return filtered + return search(Math.min(limit * 2, ceiling)) + } + return search(maxResults) + })() + const base = (async () => { + const checks = new Map>() + const search = async (limit: number): Promise => { + const results = await this.baseline!.store.search(vector, normalizedPrefix, minScore, limit) + const accepted = await Promise.all( + results.map((result) => this.baseline!.overlay.baselineResult(result, checks)), + ) + const filtered = results.filter((_, index) => accepted[index]) + if (filtered.length >= maxResults || results.length < limit || limit >= ceiling) return filtered + return search(Math.min(limit * 2, ceiling)) + } + return search(maxResults) + })() + const [baseline, current] = await Promise.all([base, delta]) + const merged = new Map() + const key = (result: VectorStoreSearchResult) => + [result.payload?.filePath, result.payload?.startLine, result.payload?.endLine, result.payload?.codeChunk].join( + "\0", + ) + + for (const result of baseline) merged.set(key(result), result) + for (const result of current) { + if (this.baseline.overlay.deltaResult(result)) merged.set(key(result), result) + } + return [...merged.values()].sort((left, right) => right.score - left.score).slice(0, maxResults) } catch (err) { log.error("search failed", { err }) throw err diff --git a/packages/kilo-indexing/src/indexing/service-factory.ts b/packages/kilo-indexing/src/indexing/service-factory.ts index aa8f14eef7e..af4ac4cbf3b 100644 --- a/packages/kilo-indexing/src/indexing/service-factory.ts +++ b/packages/kilo-indexing/src/indexing/service-factory.ts @@ -23,6 +23,7 @@ import type { CacheManager } from "./cache-manager" import type { IndexingTelemetryMeta, IndexingTelemetryReporter } from "./interfaces/telemetry" import { BATCH_SEGMENT_THRESHOLD, + DEFAULT_VECTOR_STORE, OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS, REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, } from "./constants" @@ -55,7 +56,7 @@ export class CodeIndexServiceFactory { const cfg = this.configManager.getConfig() return { provider: cfg.embedderProvider, - vectorStore: cfg.vectorStoreProvider ?? "qdrant", + vectorStore: cfg.vectorStoreProvider ?? DEFAULT_VECTOR_STORE, modelId: cfg.modelId, } } @@ -84,8 +85,7 @@ export class CodeIndexServiceFactory { return new CodeIndexOllamaEmbedder(config.ollamaOptions.baseUrl, config.modelId, config.modelDimension) } if (provider === "openai-compatible") { - if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) - throw new Error("OpenAI-compatible base URL and API key are required.") + if (!config.openAiCompatibleOptions?.baseUrl) throw new Error("OpenAI-compatible base URL is required.") return new OpenAICompatibleEmbedder( config.openAiCompatibleOptions.baseUrl, config.openAiCompatibleOptions.apiKey, @@ -169,7 +169,7 @@ export class CodeIndexServiceFactory { } } - public createVectorStore(): IVectorStore { + public createVectorStore(workspacePath = this.workspacePath): IVectorStore { const config = this.configManager.getConfig() const profile = resolveEmbeddingProfile(config.embedderProvider, config.modelId, config.modelDimension) @@ -191,7 +191,7 @@ export class CodeIndexServiceFactory { vectorSize: profile.dimension, dbDir, }) - return new LanceDBVectorStore(this.workspacePath, profile.dimension, dbDir, profile) + return new LanceDBVectorStore(workspacePath, profile.dimension, dbDir, profile) } if (!config.qdrantUrl) throw new Error("Qdrant URL is required.") @@ -201,7 +201,7 @@ export class CodeIndexServiceFactory { model: profile.modelId, vectorSize: profile.dimension, }) - return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, profile.dimension, config.qdrantApiKey, profile) + return new QdrantVectorStore(workspacePath, config.qdrantUrl, profile.dimension, config.qdrantApiKey, profile) } public createDirectoryScanner( diff --git a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts index 39bc14760dc..f97363af68c 100644 --- a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts +++ b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts @@ -10,8 +10,20 @@ import type { EmbeddingProfile } from "../embedding-profile" import { loadLanceDB } from "./lancedb-loader" const log = Log.create({ service: "lancedb-store" }) +let nativeQueue = Promise.resolve() + +function native(run: () => Promise): Promise { + const task = nativeQueue.then(run) + nativeQueue = task.then( + () => undefined, + () => undefined, + ) + return task +} +const SCHEMA = "2" const KEY = { + schema: "index_schema", size: "vector_size", complete: "indexing_complete", provider: "embedding_provider", @@ -74,19 +86,16 @@ export class LanceDBVectorStore implements IVectorStore { * @returns The LanceDB connection. */ private async getDb(): Promise { - if (this.db) { - return this.db - } + if (this.db) return this.db - const lancedb = await this.loadLanceDBModule() + return native(async () => { + if (this.db) return this.db + const lancedb = await this.loadLanceDBModule() - // Create parent directory if needed - if (!fs.existsSync(this.dbPath)) { - fs.mkdirSync(this.dbPath, { recursive: true }) - } - - this.db = await lancedb.connect(this.dbPath) - return this.db as Connection + if (!fs.existsSync(this.dbPath)) fs.mkdirSync(this.dbPath, { recursive: true }) + this.db = await lancedb.connect(this.dbPath) + return this.db as Connection + }) } /** @@ -102,7 +111,7 @@ export class LanceDBVectorStore implements IVectorStore { try { // Try to open existing table - const table = await db.openTable(this.vectorTableName) + const table = await native(() => db.openTable(this.vectorTableName)) this.table = table return table } catch (error) { @@ -121,6 +130,7 @@ export class LanceDBVectorStore implements IVectorStore { id: "sample", vector: new Array(this.vectorSize).fill(0), filePath: "sample", + fileHash: "sample", codeChunk: "sample", startLine: 0, endLine: 0, @@ -134,9 +144,13 @@ export class LanceDBVectorStore implements IVectorStore { */ private _createMetadataData() { return [ + { + key: KEY.schema, + value: SCHEMA, + }, { key: KEY.size, - value: this.vectorSize, + value: String(this.vectorSize), }, { key: KEY.provider, @@ -148,11 +162,11 @@ export class LanceDBVectorStore implements IVectorStore { }, { key: KEY.dimension, - value: this.profile.dimension, + value: String(this.profile.dimension), }, { key: KEY.complete, - value: false, + value: "false", }, ] } @@ -162,7 +176,7 @@ export class LanceDBVectorStore implements IVectorStore { * @param db The LanceDB connection. */ private async _createVectorTable(db: Connection): Promise { - this.table = await db.createTable(this.vectorTableName, this._createSampleData()) + this.table = await native(() => db.createTable(this.vectorTableName, this._createSampleData())) if (this.table) { await this.table.delete("id = 'sample'") } @@ -173,7 +187,7 @@ export class LanceDBVectorStore implements IVectorStore { * @param db The LanceDB connection. */ private async _createMetadataTable(db: Connection): Promise { - await db.createTable(this.metadataTableName, this._createMetadataData()) + await native(() => db.createTable(this.metadataTableName, this._createMetadataData())) } /** @@ -194,15 +208,10 @@ export class LanceDBVectorStore implements IVectorStore { * @returns The stored vector size, or null if not found. */ private async _getStoredVectorSize(db: Connection): Promise { - try { - const value = await this._getMetadataValue(db, KEY.size) - if (value === undefined) return null - const dim = this._parseNumber(value) - return dim ?? null - } catch (error) { - log.warn("Failed to read metadata table", { error }) - return null - } + const value = await this._getMetadataValue(db, KEY.size) + if (value === undefined) return null + const dim = this._parseNumber(value) + return dim ?? null } private isValidMetadataKey(key: string): boolean { @@ -219,27 +228,22 @@ export class LanceDBVectorStore implements IVectorStore { if (!this.isValidMetadataKey(key)) { throw new Error(`Invalid metadata key: ${key}`) } - const metadataTable = await db.openTable(this.metadataTableName) + const metadataTable = await native(() => db.openTable(this.metadataTableName)) const rows = await metadataTable.query().where(`key = '${key}'`).toArray() return rows.length > 0 ? rows[0].value : undefined } private async _getStoredEmbeddingProfile(db: Connection): Promise { - try { - const provider = await this._getMetadataValue(db, KEY.provider) - const modelId = await this._getMetadataValue(db, KEY.model) - const dimension = await this._getMetadataValue(db, KEY.dimension) - if (typeof provider !== "string" || typeof modelId !== "string") return undefined - const dim = this._parseNumber(dimension) - if (!dim) return undefined - return { - provider: provider as EmbeddingProfile["provider"], - modelId, - dimension: dim, - } - } catch (error) { - log.warn("Failed to read embedding profile metadata", { error }) - return undefined + const provider = await this._getMetadataValue(db, KEY.provider) + const modelId = await this._getMetadataValue(db, KEY.model) + const dimension = await this._getMetadataValue(db, KEY.dimension) + if (typeof provider !== "string" || typeof modelId !== "string") return undefined + const dim = this._parseNumber(dimension) + if (!dim) return undefined + return { + provider: provider as EmbeddingProfile["provider"], + modelId, + dimension: dim, } } @@ -251,6 +255,27 @@ export class LanceDBVectorStore implements IVectorStore { ) } + async openExisting(): Promise { + if (!fs.existsSync(this.dbPath)) throw new Error("Baseline LanceDB store does not exist") + + const db = await this.getDb() + const tables = await db.tableNames() + if (!tables.includes(this.vectorTableName) || !tables.includes(this.metadataTableName)) { + throw new Error("Baseline LanceDB store is incomplete") + } + + const profile = await this._getStoredEmbeddingProfile(db) + if (!profile || !this._isEmbeddingProfileMatch(profile)) { + throw new Error("Baseline LanceDB embedding profile does not match the worktree") + } + + const schema = await this._getMetadataValue(db, KEY.schema) + if (String(schema) !== SCHEMA) throw new Error("Baseline LanceDB index schema does not match the worktree") + const complete = await this._getMetadataValue(db, KEY.complete) + if (String(complete) !== "true") throw new Error("Baseline LanceDB index is not complete") + this.table = await native(() => db.openTable(this.vectorTableName)) + } + async initialize(): Promise { try { await this.closeConnect() @@ -274,12 +299,13 @@ export class LanceDBVectorStore implements IVectorStore { return true } - this.table = await db.openTable(this.vectorTableName) + this.table = await native(() => db.openTable(this.vectorTableName)) const storedVectorSize = metadataTableExists ? await this._getStoredVectorSize(db) : null + const storedSchema = metadataTableExists ? await this._getMetadataValue(db, KEY.schema) : undefined const pointCount = await this.table.countRows() - if (storedVectorSize === null || storedVectorSize !== this.vectorSize) { + if (String(storedSchema) !== SCHEMA || storedVectorSize === null || storedVectorSize !== this.vectorSize) { needsRecreation = true } @@ -344,6 +370,7 @@ export class LanceDBVectorStore implements IVectorStore { id: point.id, vector: point.vector, filePath: point.payload.filePath, + fileHash: point.payload.fileHash, codeChunk: point.payload.codeChunk, startLine: point.payload.startLine, endLine: point.payload.endLine, @@ -392,7 +419,7 @@ export class LanceDBVectorStore implements IVectorStore { if (!payload) { return false } - const validKeys = ["filePath", "codeChunk", "startLine", "endLine"] + const validKeys = ["filePath", "fileHash", "codeChunk", "startLine", "endLine"] const hasValidKeys = validKeys.every((key) => key in payload) return hasValidKeys } @@ -431,6 +458,7 @@ export class LanceDBVectorStore implements IVectorStore { score: 1 - result._distance, // Convert distance to similarity score payload: { filePath: result.filePath, + fileHash: result.fileHash, codeChunk: result.codeChunk, startLine: result.startLine, endLine: result.endLine, @@ -501,7 +529,7 @@ export class LanceDBVectorStore implements IVectorStore { const tableNames = await db.tableNames() if (tableNames.includes(this.metadataTableName)) { - const metadataTable = await db.openTable(this.metadataTableName) + const metadataTable = await native(() => db.openTable(this.metadataTableName)) await metadataTable.delete("true") } } catch (metadataError) { @@ -526,6 +554,10 @@ export class LanceDBVectorStore implements IVectorStore { } } + async close(): Promise { + await this.closeConnect() + } + private async closeConnect(): Promise { if (this.table) { this.table = null @@ -570,9 +602,9 @@ export class LanceDBVectorStore implements IVectorStore { }) return false } - const metadataTable = await db.openTable(this.metadataTableName) + const metadataTable = await native(() => db.openTable(this.metadataTableName)) const metadataResults = await metadataTable.query().where(`key = '${KEY.complete}'`).toArray() - const indexed = metadataResults.length > 0 ? metadataResults[0].value : false + const indexed = metadataResults.length > 0 ? String(metadataResults[0].value) === "true" : false log.info("LanceDB indexing metadata evaluated", { workspacePath: this.workspacePath, pointCount, @@ -580,8 +612,8 @@ export class LanceDBVectorStore implements IVectorStore { }) return indexed } catch (error) { - log.warn("Failed to check if collection has data", { error }) - return false + log.error("Failed to check if collection has data", { error }) + throw error } } @@ -590,10 +622,13 @@ export class LanceDBVectorStore implements IVectorStore { throw new Error(`Invalid metadata key: ${key}`) } await metadataTable.delete(`key = '${key}'`) - await metadataTable.add([{ key, value }]) + // All values must be strings to prevent LanceDB from inferring the value column + // type as number from the first row, which corrupts subsequent string/boolean values. + await metadataTable.add([{ key, value: String(value) }]) } private async _persistEmbeddingProfile(metadataTable: Table): Promise { + await this._upsertMetadata(metadataTable, KEY.schema, SCHEMA) await this._upsertMetadata(metadataTable, KEY.provider, this.profile.provider) await this._upsertMetadata(metadataTable, KEY.model, this.profile.modelId) await this._upsertMetadata(metadataTable, KEY.dimension, this.profile.dimension) @@ -607,9 +642,9 @@ export class LanceDBVectorStore implements IVectorStore { async markIndexingComplete(): Promise { try { const db = await this.getDb() - const metadataTable = await db.openTable(this.metadataTableName) + const metadataTable = await native(() => db.openTable(this.metadataTableName)) await this._persistEmbeddingProfile(metadataTable) - await this._upsertMetadata(metadataTable, KEY.complete, true) + await this._upsertMetadata(metadataTable, KEY.complete, "true") log.info("Marked indexing as complete") } catch (error) { log.error("Failed to mark indexing as complete", { error }) @@ -624,9 +659,9 @@ export class LanceDBVectorStore implements IVectorStore { async markIndexingIncomplete(): Promise { try { const db = await this.getDb() - const metadataTable = await db.openTable(this.metadataTableName) + const metadataTable = await native(() => db.openTable(this.metadataTableName)) await this._persistEmbeddingProfile(metadataTable) - await this._upsertMetadata(metadataTable, KEY.complete, false) + await this._upsertMetadata(metadataTable, KEY.complete, "false") log.info("Marked indexing as incomplete (in progress)") } catch (error) { log.error("Failed to mark indexing as incomplete", { error }) diff --git a/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts b/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts index 44b45240966..53a4df91561 100644 --- a/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts +++ b/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts @@ -9,7 +9,9 @@ import type { EmbeddingProfile } from "../embedding-profile" const log = Log.create({ service: "qdrant-store" }) +const SCHEMA = 2 const KEY = { + schema: "index_schema", complete: "indexing_complete", provider: "embedding_provider", model: "embedding_model_id", @@ -221,12 +223,12 @@ export class QdrantVectorStore implements IVectorStore { }) } - private async recreateCollectionForProfile(stored?: EmbeddingProfile): Promise { + private async recreateCollectionForCompatibility(stored?: EmbeddingProfile): Promise { const from = stored ? `${stored.provider}:${stored.modelId}:${stored.dimension}` : "missing embedding metadata on populated collection" const to = `${this.profile.provider}:${this.profile.modelId}:${this.profile.dimension}` - log.warn(`Collection ${this.collectionName} embedding profile changed (${from} -> ${to}). Recreating collection.`) + log.warn(`Collection ${this.collectionName} is incompatible (${from} -> ${to}). Recreating collection.`) await this.client.deleteCollection(this.collectionName) await new Promise((resolve) => setTimeout(resolve, 100)) @@ -240,6 +242,28 @@ export class QdrantVectorStore implements IVectorStore { return true } + async openExisting(): Promise { + const info = await this.getCollectionInfo() + if (!info) throw new Error("Baseline Qdrant collection does not exist") + + const vectors = info.config?.params?.vectors + const size = + typeof vectors === "number" + ? vectors + : vectors && typeof vectors === "object" && "size" in vectors && typeof vectors.size === "number" + ? vectors.size + : 0 + if (size !== this.vectorSize) throw new Error("Baseline Qdrant vector dimension does not match the worktree") + + const payload = await this.getMetadataPayload() + const profile = this.getStoredProfile(payload) + if (!profile || !this.isProfileMatch(profile)) { + throw new Error("Baseline Qdrant embedding profile does not match the worktree") + } + if (payload?.[KEY.schema] !== SCHEMA) throw new Error("Baseline Qdrant index schema does not match the worktree") + if (payload?.[KEY.complete] !== true) throw new Error("Baseline Qdrant index is not complete") + } + /** * Initializes the vector store * @returns Promise resolving to boolean indicating if a new collection was created @@ -278,8 +302,8 @@ export class QdrantVectorStore implements IVectorStore { } else { const payload = await this.getMetadataPayload() const profile = this.getStoredProfile(payload) - created = - !profile || !this.isProfileMatch(profile) ? await this.recreateCollectionForProfile(profile) : false + const compatible = payload?.[KEY.schema] === SCHEMA && profile && this.isProfileMatch(profile) + created = compatible ? false : await this.recreateCollectionForCompatibility(profile) } } else { // Exists but wrong vector size, recreate with enhanced error handling @@ -453,7 +477,7 @@ export class QdrantVectorStore implements IVectorStore { if (!payload) { return false } - const validKeys = ["filePath", "codeChunk", "startLine", "endLine"] + const validKeys = ["filePath", "fileHash", "codeChunk", "startLine", "endLine"] const hasValidKeys = validKeys.every((key) => key in payload) return hasValidKeys } @@ -523,7 +547,7 @@ export class QdrantVectorStore implements IVectorStore { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, } @@ -655,14 +679,7 @@ export class QdrantVectorStore implements IVectorStore { */ async hasIndexedData(): Promise { try { - const collectionInfo = await this.getCollectionInfo() - if (!collectionInfo) { - log.info("Qdrant collection has no indexed data", { - collection: this.collectionName, - reason: "collection_missing", - }) - return false - } + const collectionInfo = await this.client.getCollection(this.collectionName) // Check if the collection has any points indexed const pointsCount = collectionInfo.points_count ?? 0 if (pointsCount === 0) { @@ -692,8 +709,8 @@ export class QdrantVectorStore implements IVectorStore { log.info("No indexing metadata marker found. Using backward compatibility mode (checking points_count > 0).") return pointsCount > 0 } catch (error) { - log.warn("Failed to check if collection has data", { error }) - return false + log.error("Failed to check if collection has data", { error }) + throw error } } @@ -710,6 +727,7 @@ export class QdrantVectorStore implements IVectorStore { vector: new Array(this.vectorSize).fill(0), payload: { type: "metadata", + [KEY.schema]: SCHEMA, [KEY.complete]: true, [KEY.provider]: this.profile.provider, [KEY.model]: this.profile.modelId, @@ -740,6 +758,7 @@ export class QdrantVectorStore implements IVectorStore { vector: new Array(this.vectorSize).fill(0), payload: { type: "metadata", + [KEY.schema]: SCHEMA, [KEY.complete]: false, [KEY.provider]: this.profile.provider, [KEY.model]: this.profile.modelId, diff --git a/packages/kilo-indexing/src/indexing/worktree-overlay.ts b/packages/kilo-indexing/src/indexing/worktree-overlay.ts new file mode 100644 index 00000000000..741bf4ae807 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/worktree-overlay.ts @@ -0,0 +1,92 @@ +import { createHash } from "crypto" +import { readFile } from "fs/promises" +import path from "path" +import type { VectorStoreSearchResult } from "./interfaces/vector-store" + +const normalize = (value: string) => value.replaceAll("\\", "/") + +export class WorktreeOverlay { + readonly shadows = new Set() + readonly blocked = new Set() + ready = false + + constructor( + readonly workspacePath: string, + readonly baselinePath: string, + readonly baseline: ReadonlyMap, + ) {} + + relative(filePath: string): string | undefined { + const rel = path.isAbsolute(filePath) ? path.relative(this.workspacePath, filePath) : filePath + if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return + return normalize(path.normalize(rel)) + } + + baselineHash(filePath: string): string | undefined { + const rel = this.relative(filePath) + if (!rel) return + return this.baseline.get(rel) + } + + seed(): Record { + return Object.fromEntries( + [...this.baseline].map(([filePath, hash]) => [path.join(this.workspacePath, ...filePath.split("/")), hash]), + ) + } + + prepare(): void { + this.ready = false + this.shadows.clear() + this.blocked.clear() + } + + block(filePath: string): void { + const rel = this.relative(filePath) + if (rel) this.blocked.add(rel) + } + + settle(filePath: string, hash: string | undefined, pending = false): void { + const rel = this.relative(filePath) + if (!rel) return + + const baseline = this.baseline.get(rel) + if (baseline !== undefined && baseline !== hash) this.shadows.add(rel) + if (baseline === undefined || baseline === hash) this.shadows.delete(rel) + if (!pending) this.blocked.delete(rel) + } + + reconcile(current: Readonly>): void { + this.shadows.clear() + for (const [filePath, hash] of this.baseline) { + const absolute = path.join(this.workspacePath, ...filePath.split("/")) + if (current[absolute] !== hash) this.shadows.add(filePath) + } + this.ready = true + } + + async baselineResult(result: VectorStoreSearchResult, checks: Map>): Promise { + const filePath = result.payload?.filePath + if (typeof filePath !== "string") return false + const rel = this.relative(filePath) + if (!rel || this.shadows.has(rel) || this.blocked.has(rel)) return false + + const expected = this.baseline.get(rel) + if (!expected || result.payload?.fileHash !== expected) return false + const existing = checks.get(rel) + const valid = + existing ?? + readFile(path.join(this.workspacePath, ...rel.split("/")), "utf-8") + .then((content) => createHash("sha256").update(content).digest("hex") === expected) + .catch(() => false) + checks.set(rel, valid) + return valid + } + + deltaResult(result: VectorStoreSearchResult): boolean { + const filePath = result.payload?.filePath + if (typeof filePath !== "string") return false + const rel = this.relative(filePath) + if (!rel) return false + return !this.blocked.has(rel) + } +} diff --git a/packages/kilo-indexing/test/kilocode/indexing/cache-manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/cache-manager.test.ts new file mode 100644 index 00000000000..11c6126ac59 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/cache-manager.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { CacheManager } from "../../../src/indexing/cache-manager" + +describe("CacheManager", () => { + test("flushes a stable signature used to detect baseline changes", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "index-cache-")) + const workspace = path.join(cacheDir, "workspace") + const first = new CacheManager(cacheDir, workspace) + await first.initialize() + first.seedHashes({ + [path.join(workspace, "b.ts")]: "b", + [path.join(workspace, "a.ts")]: "a", + }) + await first.flush() + + const second = new CacheManager(cacheDir, workspace) + await second.initialize() + expect(second.signature()).toBe(first.signature()) + + second.updateHash(path.join(workspace, "a.ts"), "changed") + expect(second.signature()).not.toBe(first.signature()) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts index 4ba3338df49..091ff3bbd90 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { toIndexingConfigInput } from "../../../src/config" import { CodeIndexConfigManager, type IndexingConfigInput } from "../../../src/indexing/config-manager" function createInput(input: Partial = {}): IndexingConfigInput { @@ -25,9 +26,49 @@ describe("CodeIndexConfigManager", () => { expect(cfg.getConfig().ollamaOptions?.baseUrl).toBe("http://localhost:11434") }) - test("defaults vector store to qdrant when omitted", () => { + test("configures an OpenAI-compatible endpoint without an API key", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "openai-compatible", + openAiKey: undefined, + openAiCompatibleBaseUrl: "http://localhost:1234/v1", + }), + ) + + expect(cfg.isFeatureConfigured).toBe(true) + expect(cfg.getConfig().openAiCompatibleOptions).toEqual({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + }) + }) + + test("requires a base URL for an OpenAI-compatible endpoint", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "openai-compatible", + openAiKey: undefined, + openAiCompatibleApiKey: "sk-test", + }), + ) + + expect(cfg.isFeatureConfigured).toBe(false) + }) + + test("defaults vector store to LanceDB when omitted", () => { const cfg = new CodeIndexConfigManager(createInput({ vectorStoreProvider: undefined })) + expect(cfg.getConfig().vectorStoreProvider).toBe("lancedb") + }) + + test("normalizes omitted vector store config to LanceDB for hosts", () => { + expect(toIndexingConfigInput(undefined).vectorStoreProvider).toBe("lancedb") + }) + + test("preserves an explicit Qdrant override", () => { + const input = toIndexingConfigInput({ vectorStore: "qdrant" }) + const cfg = new CodeIndexConfigManager(input) + + expect(input.vectorStoreProvider).toBe("qdrant") expect(cfg.getConfig().vectorStoreProvider).toBe("qdrant") }) @@ -129,6 +170,19 @@ describe("CodeIndexConfigManager", () => { expect(result.requiresRestart).toBe(true) }) + test("requires restart when OpenAI-compatible auth is added or removed", () => { + const input = createInput({ + embedderProvider: "openai-compatible", + openAiKey: undefined, + openAiCompatibleBaseUrl: "http://localhost:1234/v1", + }) + const cfg = new CodeIndexConfigManager(input) + + expect(cfg.loadConfiguration({ ...input, openAiCompatibleApiKey: "sk-test" }).requiresRestart).toBe(true) + expect(cfg.loadConfiguration(input).requiresRestart).toBe(true) + expect(cfg.loadConfiguration(input).requiresRestart).toBe(false) + }) + test("requires restart when Kilo auth changes", () => { const cfg = new CodeIndexConfigManager( createInput({ diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts index 4bed6ea72ed..cfc8c7d9a73 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts @@ -63,10 +63,10 @@ describe("OpenAICompatibleEmbedder", () => { ) }) - test("should throw error when apiKey is missing", () => { - expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow( - "API key is required for OpenAI-compatible embedder", - ) + test("should create embedder without an API key", () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId) + + expect(embedder).toBeDefined() }) test("should throw error when both baseUrl and apiKey are missing", () => { @@ -649,6 +649,69 @@ describe("OpenAICompatibleEmbedder", () => { expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6]) }) + test("should omit auth headers for a keyless full endpoint", async () => { + const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId) + const base64String = createBase64Embedding([0.1, 0.2, 0.3]) + mockFetch.mockResolvedValue( + createMockResponse({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) as any, + ) + + await embedder.createEmbeddings(["test"]) + + const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined + const headers = new Headers(init?.headers) + expect(headers.get("authorization")).toBeNull() + expect(headers.get("api-key")).toBeNull() + }) + + test("should preserve custom headers for a keyless full endpoint", async () => { + const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId, undefined, { + headers: { Authorization: "Custom token", "x-fixture": "present" }, + }) + const base64String = createBase64Embedding([0.1, 0.2, 0.3]) + mockFetch.mockResolvedValue( + createMockResponse({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) as any, + ) + + await embedder.createEmbeddings(["test"]) + + const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined + const headers = new Headers(init?.headers) + expect(headers.get("authorization")).toBe("Custom token") + expect(headers.get("x-fixture")).toBe("present") + }) + + test("should omit generated SDK auth headers when no key is configured", async () => { + let request: ((input: string | URL | Request, init?: RequestInit) => Promise) | undefined + setOpenAIConstructorHook((config) => { + request = config.fetch + }) + new OpenAICompatibleEmbedder(baseUrl, undefined, testModelId) + mockFetch.mockResolvedValue(new Response()) + + if (!request) throw new Error("Missing OpenAI-compatible fetch adapter") + await request("https://api.example.com/v1/embeddings", { + headers: { + Authorization: "Bearer EMPTY", + "api-key": "EMPTY", + "x-fixture": "present", + }, + }) + + const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined + const headers = new Headers(init?.headers) + expect(init?.headers).not.toBeInstanceOf(Headers) + expect(headers.get("authorization")).toBeNull() + expect(headers.get("api-key")).toBeNull() + expect(headers.get("x-fixture")).toBe("present") + }) + test.each([ [401, "Authentication failed. Please check your API key."], [500, "Embedding request failed after 3 attempts"], diff --git a/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts index cbffc433eac..649d4a1444e 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { CacheManager } from "../../../src/indexing/cache-manager" import { CodeIndexManager } from "../../../src/indexing/manager" import type { IndexingConfigInput } from "../../../src/indexing/config-manager" import type { IndexingTelemetryEvent, IndexingTelemetryTrigger } from "../../../src/indexing/interfaces/telemetry" @@ -73,6 +77,59 @@ function createStartError(location = "orchestrator:startIndexing"): IndexingTele } describe("CodeIndexManager", () => { + test("falls back when the shared baseline is not ready", async () => { + const mgr = new CodeIndexManager("/tmp/worktree", "/tmp/cache", "/tmp/main") + let closed = 0 + const data = mgr as unknown as { + createBaseline(factory: { createVectorStore(): unknown }): Promise<{ store?: unknown }> + } + const baseline = await data.createBaseline({ + createVectorStore() { + return { + async openExisting() { + throw new Error("baseline rebuilding") + }, + async close() { + closed += 1 + }, + } + }, + }) + + expect(baseline.store).toBeUndefined() + expect(closed).toBe(1) + }) + + test("throttles unchanged baseline cache checks between searches", async () => { + const mgr = new CodeIndexManager("/tmp/worktree", "/tmp/cache", "/tmp/main") + const data = createData(mgr) as Data & { + _baselineStamp: string + _baselineSigned: number + _orchestrator: { state: string } + _searchService: { searchIndex(): Promise<[]> } + } + data._baselineStamp = "same" + data._baselineSigned = Date.now() + data._orchestrator = { state: "Indexed" } + data._searchService = { + async searchIndex() { + return [] + }, + } + const stamp = spyOn(CacheManager.prototype, "stamp").mockResolvedValue("same") + const initialize = spyOn(CacheManager.prototype, "initialize").mockResolvedValue() + + try { + await mgr.searchIndex("first") + await mgr.searchIndex("second") + expect(stamp).toHaveBeenCalledTimes(1) + expect(initialize).not.toHaveBeenCalled() + } finally { + stamp.mockRestore() + initialize.mockRestore() + } + }) + test("returns standby state before services are initialized", () => { const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") const data = mgr as unknown as { @@ -100,6 +157,73 @@ describe("CodeIndexManager", () => { expect(mgr.getCurrentStatus().message).toContain("not configured") }) + test("initializes an unauthenticated OpenAI-compatible endpoint without auth headers", async () => { + const root = await mkdtemp(join(tmpdir(), "kilo-keyless-indexing-")) + const workspace = join(root, "workspace") + const cache = join(root, "cache") + const requests: Array<{ authorization: string | null; apiKey: string | null }> = [] + await Bun.write(join(workspace, ".gitkeep"), "") + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + requests.push({ + authorization: req.headers.get("authorization"), + apiKey: req.headers.get("api-key"), + }) + return Response.json({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2, 0.3] }], + model: "fixture-model", + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + }, + }) + + try { + const script = ` + import { CodeIndexManager } from "./src/indexing/manager.ts" + import { normalizeIndexingStatus } from "./src/status.ts" + const mgr = new CodeIndexManager(${JSON.stringify(workspace)}, ${JSON.stringify(cache)}) + try { + await mgr.initialize({ + enabled: true, + embedderProvider: "openai-compatible", + vectorStoreProvider: "lancedb", + modelId: "fixture-model", + modelDimension: 3, + openAiCompatibleBaseUrl: ${JSON.stringify(`http://127.0.0.1:${server.port}/v1`)}, + }) + if (!mgr.isInitialized) throw new Error("Manager did not initialize") + if (normalizeIndexingStatus(mgr).state === "Disabled") throw new Error("Indexing remained disabled") + } finally { + await mgr.dispose() + } + ` + const child = Bun.spawn([process.execPath, "-e", script], { + cwd: join(import.meta.dir, "../../.."), + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const gate = Promise.withResolvers() + const timeout = setTimeout(() => { + child.kill() + gate.reject(new Error("Indexing subprocess timed out")) + }, 10_000) + const [exit, stderr] = await Promise.race([ + Promise.all([child.exited, new Response(child.stderr).text()]), + gate.promise, + ]).finally(() => clearTimeout(timeout)) + if (exit !== 0) throw new Error(stderr) + + expect(requests).toEqual([{ authorization: null, apiKey: null }]) + } finally { + server.stop(true) + await rm(root, { recursive: true, force: true }) + } + }) + test("cancels active indexing when configuration is removed", async () => { const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") let stop = 0 @@ -245,6 +369,30 @@ describe("CodeIndexManager", () => { expect(calls).toBe(1) }) + test("schedules auto-recovery for watcher failures", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + let calls = 0 + + data._recreateServices = async () => { + data._orchestrator = { + state: "Standby", + stopWatcher() {}, + async startIndexing() { + calls += 1 + this.state = "Indexed" + data._stateManager.setSystemState("Indexed", "done") + }, + } + data._searchService = {} + } + + data.handleTelemetry(createStartError("orchestrator:watcher")) + await data._retryTask + + expect(calls).toBe(1) + }) + test("ignores non-orchestrator telemetry errors for auto-recovery", async () => { const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") const data = createData(mgr) @@ -323,30 +471,24 @@ describe("CodeIndexManager", () => { expect(mgr.getCurrentStatus().systemStatus).toBe("Indexed") }) - test("dispose calls cancelIndexing on orchestrator", () => { + test("dispose waits for orchestrator shutdown", async () => { const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") - let cancel = 0 - let stop = 0 + let shutdown = 0 const data = mgr as unknown as { _orchestrator?: { - stopWatcher(): void - cancelIndexing(): void + shutdown(): Promise } } data._orchestrator = { - stopWatcher() { - stop += 1 - }, - cancelIndexing() { - cancel += 1 + async shutdown() { + shutdown += 1 }, } - mgr.dispose() + await mgr.dispose() - expect(cancel).toBe(1) - expect(stop).toBe(0) + expect(shutdown).toBe(1) }) test("dispose during service recreation cancels the recreated orchestrator", async () => { @@ -386,7 +528,7 @@ describe("CodeIndexManager", () => { const init = mgr.initialize(createInput({ openAiKey: "sk-test" })) await new Promise((resolve) => setTimeout(resolve, 0)) - mgr.dispose() + await mgr.dispose() gate.resolve() await init @@ -414,7 +556,7 @@ describe("CodeIndexManager", () => { const task = data.handleTelemetry(createStartError()) await new Promise((resolve) => setTimeout(resolve, 0)) - mgr.dispose() + await mgr.dispose() gate.resolve() await data._retryTask diff --git a/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts b/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts index 7ce180ff553..cac76639981 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts @@ -17,7 +17,10 @@ import { Emitter } from "../../../src/indexing/runtime" class Store { public clearCount = 0 + public closeCount = 0 + public completeCount = 0 public deleteCount = 0 + public incompleteCount = 0 constructor( private readonly existing: boolean, @@ -47,14 +50,21 @@ class Store { async deleteCollection(): Promise { this.deleteCount += 1 } + async close(): Promise { + this.closeCount += 1 + } async collectionExists(): Promise { return true } async hasIndexedData(): Promise { return this.existing } - async markIndexingComplete(): Promise {} - async markIndexingIncomplete(): Promise {} + async markIndexingComplete(): Promise { + this.completeCount += 1 + } + async markIndexingIncomplete(): Promise { + this.incompleteCount += 1 + } } class Scanner { @@ -117,6 +127,27 @@ class Watcher { } } +class BlockingScanner { + public isCancelled = false + public finished = false + private readonly gate = Promise.withResolvers() + readonly started = Promise.withResolvers() + + async scanDirectory(): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { + this.started.resolve() + await this.gate.promise + this.finished = true + return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } + } + + cancel(): void { + this.isCancelled = true + this.gate.resolve() + } + + updateBatchSegmentThreshold(_newThreshold: number): void {} +} + class FailScanner { public readonly isCancelled = false @@ -225,6 +256,136 @@ describe("CodeIndexOrchestrator telemetry", () => { expect(orchestrator.state).not.toBe("Indexing") }) + test("shutdown waits for an active scan before closing the store", async () => { + const scanner = new BlockingScanner() + const store = new Store(false) + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + { async clearCacheFile() {}, async flush() {} } as unknown as CacheManager, + store as unknown as IVectorStore, + scanner as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + ) + + const active = orchestrator.startIndexing("background") + await scanner.started.promise + await orchestrator.shutdown() + await active + + expect(scanner.finished).toBe(true) + expect(store.closeCount).toBe(1) + expect(store.incompleteCount).toBe(1) + expect(store.completeCount).toBe(0) + }) + + test("preserves an unchanged index when an incremental scan is interrupted", async () => { + const scanner = new BlockingScanner() + const store = new Store(true) + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + { async clearCacheFile() {}, async flush() {} } as unknown as CacheManager, + store as unknown as IVectorStore, + scanner as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + ) + + const active = orchestrator.startIndexing("background") + await scanner.started.promise + await orchestrator.shutdown() + await active + + expect(store.incompleteCount).toBe(1) + expect(store.completeCount).toBe(1) + expect(store.clearCount).toBe(0) + }) + + test("clears stale vectors and hashes before rebuilding an incomplete store", async () => { + const cache = { + clears: 0, + async clearCacheFile() { + this.clears += 1 + }, + async flush() {}, + } + const store = new Store(false, false) + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + cache as unknown as CacheManager, + store as unknown as IVectorStore, + new Scanner(1, 1, 1) as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + ) + + await orchestrator.startIndexing("background") + + expect(store.clearCount).toBe(1) + expect(cache.clears).toBe(1) + expect(orchestrator.state).toBe("Indexed") + }) + + test("does not clear data when index completeness cannot be read", async () => { + const cache = { + clears: 0, + async clearCacheFile() { + this.clears += 1 + }, + } + const store = new Store(true, false) + store.hasIndexedData = async () => { + throw new Error("metadata unavailable") + } + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + cache as unknown as CacheManager, + store as unknown as IVectorStore, + new Scanner(1, 1, 1) as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + ) + + await orchestrator.startIndexing("background") + + expect(store.clearCount).toBe(0) + expect(cache.clears).toBe(0) + expect(orchestrator.state).toBe("Error") + }) + + test("rebuilds a complete independent index when the shared baseline is unavailable", async () => { + const cache = { + clears: 0, + async clearCacheFile() { + this.clears += 1 + }, + async flush() {}, + } + const store = new Store(true, false) + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + cache as unknown as CacheManager, + store as unknown as IVectorStore, + new Scanner(1, 1, 1) as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + undefined, + undefined, + true, + ) + + await orchestrator.startIndexing("background") + + expect(store.clearCount).toBe(1) + expect(cache.clears).toBe(1) + expect(orchestrator.state).toBe("Indexed") + }) + test("preserves cache and collection data on retryable start failures", async () => { const events: IndexingTelemetryEvent[] = [] const cache = { diff --git a/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts b/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts index 2a66a389abe..688fbb37a2d 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test" import { mkdtemp, mkdir, writeFile } from "fs/promises" import { tmpdir } from "os" import path from "path" +import { createHash } from "crypto" import { v5 as uuidv5 } from "uuid" import { CacheManager } from "../../../../src/indexing/cache-manager" import { QDRANT_CODE_BLOCK_NAMESPACE } from "../../../../src/indexing/constants" @@ -14,6 +15,7 @@ import type { } from "../../../../src/indexing/interfaces" import { FileWatcher } from "../../../../src/indexing/processors/file-watcher" import { loadIgnore } from "../../../../src/indexing/shared/load-ignore" +import { WorktreeOverlay } from "../../../../src/indexing/worktree-overlay" function createEmbedder(): IEmbedder { return { @@ -32,6 +34,8 @@ function createEmbedder(): IEmbedder { } class RetryStore implements IVectorStore { + public readonly points: PointStruct[] = [] + constructor(private readonly fail: number) {} private calls = 0 @@ -40,11 +44,12 @@ class RetryStore implements IVectorStore { return false } - async upsertPoints(_points: PointStruct[]): Promise { + async upsertPoints(points: PointStruct[]): Promise { this.calls += 1 if (this.calls <= this.fail) { throw new Error("watcher upsert failure for /tmp/watcher/path.ts") } + this.points.push(...points) } async search( @@ -214,6 +219,86 @@ describe("FileWatcher", () => { expect(error?.error).toContain("[REDACTED_PATH]") }) + test("updates worktree shadows when a baseline file changes and reverts", async () => { + const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) + const cacheDir = path.join(root, ".cache") + const file = path.join(root, "file.ts") + const baseline = "export const baseline = '" + "x".repeat(100) + "'\n" + const changed = "export const changed = '" + "y".repeat(100) + "'\n" + const baselineHash = createHash("sha256").update(baseline).digest("hex") + + await mkdir(cacheDir, { recursive: true }) + await writeFile(file, changed) + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + cache.seedHashes({ [file]: baselineHash }) + const overlay = new WorktreeOverlay(root, path.join(root, "baseline"), new Map([["file.ts", baselineHash]])) + const store = new RetryStore(0) + const watcher = new FileWatcher(root, cache, createEmbedder(), store) + watcher.setOverlay(overlay) + const data = watcher as unknown as { + processBatch(events: Map): Promise + } + + overlay.block(file) + await data.processBatch(new Map([[file, { path: file, type: "change" }]])) + + expect(overlay.shadows.has("file.ts")).toBe(true) + expect(overlay.blocked.has("file.ts")).toBe(false) + expect(cache.getHash(file)).toBe(createHash("sha256").update(changed).digest("hex")) + + await writeFile(file, baseline) + overlay.block(file) + await data.processBatch(new Map([[file, { path: file, type: "change" }]])) + + expect(overlay.shadows.has("file.ts")).toBe(false) + expect(overlay.blocked.has("file.ts")).toBe(false) + expect(cache.getHash(file)).toBe(baselineHash) + expect(store.points.length).toBeGreaterThan(0) + const count = store.points.length + + await writeFile(file, changed) + overlay.block(file) + await data.processBatch(new Map([[file, { path: file, type: "change" }]])) + await writeFile(file, baseline) + overlay.block(file) + await data.processBatch(new Map([[file, { path: file, type: "change" }]])) + + expect(store.points).toHaveLength(count * 2) + }) + + test("reports unexpected drain failures for recovery", async () => { + const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) + const cacheDir = path.join(root, ".cache") + const file = path.join(root, "file.ts") + await mkdir(cacheDir, { recursive: true }) + await writeFile(file, "export const value = '" + "x".repeat(100) + "'\n") + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + cache.flush = async () => { + throw new Error("cache flush failed") + } + const watcher = new FileWatcher(root, cache, createEmbedder(), new RetryStore(0)) + const summary = new Promise<{ batchError?: Error }>((resolve) => { + watcher.onDidFinishBatchProcessing.on(resolve) + }) + const data = watcher as unknown as { + handleFileEvent(filePath: string, type: "create" | "change" | "delete"): void + } + + watcher.setCollecting(true) + data.handleFileEvent(file, "create") + const result = await Promise.race([ + summary, + new Promise((_, reject) => setTimeout(() => reject(new Error("watcher did not report failure")), 2000)), + ]) + + expect(result.batchError?.message).toBe("cache flush failed") + await watcher.shutdown() + }) + test("processFile skips files matched by .kilocodeignore during incremental updates", async () => { const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) const cacheDir = path.join(root, ".cache") diff --git a/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts b/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts index 2b3a1a0ffe2..65293df016b 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts @@ -172,6 +172,40 @@ class RetryStore extends Store { } describe("DirectoryScanner", () => { + test("uses seeded baseline hashes to index only worktree changes", async () => { + const root = await mkdtemp(join(tmpdir(), "scanner-test-")) + const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) + const same = join(root, "same.ts") + const changed = join(root, "changed.ts") + const added = join(root, "added.ts") + const deleted = join(root, "deleted.ts") + const sameContent = "export const same = 1\n" + const changedContent = "export const changed = 2\n" + + await Bun.write(same, sameContent) + await Bun.write(changed, changedContent) + await Bun.write(added, "export const added = 1\n") + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + cache.seedHashes({ + [same]: createHash("sha256").update(sameContent).digest("hex"), + [changed]: "baseline-changed", + [deleted]: "baseline-deleted", + }) + + const store = new Store() + const scan = new DirectoryScanner(new Emb(), store, new Parser(), cache, ignore(), 1, 1) + const result = await scan.scanDirectory(root) + + expect(result.stats.processed).toBe(2) + expect(store.points).toBe(2) + expect(cache.getHash(same)).toBe(createHash("sha256").update(sameContent).digest("hex")) + expect(cache.getHash(changed)).toBe(createHash("sha256").update(changedContent).digest("hex")) + expect(cache.getHash(added)).toBeDefined() + expect(cache.getHash(deleted)).toBeUndefined() + }) + test("keeps file metadata when threshold flush is triggered by that file", async () => { const root = await mkdtemp(join(tmpdir(), "scanner-test-")) const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) diff --git a/packages/kilo-indexing/test/kilocode/indexing/search-service.test.ts b/packages/kilo-indexing/test/kilocode/indexing/search-service.test.ts new file mode 100644 index 00000000000..9447aa688fa --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/search-service.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test" +import { createHash } from "crypto" +import { mkdir, mkdtemp, writeFile } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { CodeIndexConfigManager } from "../../../src/indexing/config-manager" +import type { IEmbedder, IVectorStore, VectorStoreSearchResult } from "../../../src/indexing/interfaces" +import { CodeIndexSearchService } from "../../../src/indexing/search-service" +import { CodeIndexStateManager } from "../../../src/indexing/state-manager" +import { WorktreeOverlay } from "../../../src/indexing/worktree-overlay" + +const result = (filePath: string, score: number, codeChunk = filePath, fileHash?: string): VectorStoreSearchResult => ({ + id: `${filePath}:${score}`, + score, + payload: { filePath, fileHash, codeChunk, startLine: 1, endLine: 1 }, +}) + +const store = (results: VectorStoreSearchResult[], limits: number[]): IVectorStore => + ({ + async search(_vector, _prefix, _score, limit) { + limits.push(limit ?? results.length) + return results.slice(0, limit) + }, + }) as unknown as IVectorStore + +const embedder = (calls: string[][]): IEmbedder => ({ + async createEmbeddings(texts) { + calls.push(texts) + return { embeddings: [[1, 2, 3]] } + }, + async validateConfiguration() { + return { valid: true } + }, + get embedderInfo() { + return { name: "openai" } + }, +}) + +const config = () => + new CodeIndexConfigManager({ + enabled: true, + embedderProvider: "openai", + openAiKey: "test", + vectorStoreProvider: "lancedb", + searchMaxResults: 2, + }) + +describe("CodeIndexSearchService worktree search", () => { + test("embeds once, hides baseline paths, and merges the current delta", async () => { + const root = await mkdtemp(path.join(tmpdir(), "search-worktree-")) + const main = await mkdtemp(path.join(tmpdir(), "search-main-")) + const content = "src/base.ts" + const hash = createHash("sha256").update(content).digest("hex") + await mkdir(path.join(root, "src"), { recursive: true }) + await mkdir(path.join(main, "src"), { recursive: true }) + await writeFile(path.join(root, "src/base.ts"), content) + await writeFile(path.join(main, "src/base.ts"), content) + const overlay = new WorktreeOverlay( + root, + main, + new Map([ + ["src/changed.ts", "base"], + ["src/base.ts", hash], + ]), + ) + overlay.reconcile({ + [path.join(root, "src/changed.ts")]: "changed", + [path.join(root, "src/base.ts")]: hash, + }) + + const baseLimits: number[] = [] + const deltaLimits: number[] = [] + const calls: string[][] = [] + const state = new CodeIndexStateManager() + state.setSystemState("Indexed") + const service = new CodeIndexSearchService( + config(), + state, + embedder(calls), + store([result("src/changed.ts", 0.9, "worktree")], deltaLimits), + { + store: store([result("src/changed.ts", 0.99), result("src/base.ts", 0.8, "src/base.ts", hash)], baseLimits), + overlay, + }, + ) + + const results = await service.searchIndex("query") + + expect(calls).toEqual([["query"]]) + expect(results.map((item) => item.payload?.codeChunk)).toEqual(["worktree", "src/base.ts"]) + expect(baseLimits).toEqual([2, 4]) + expect(deltaLimits).toEqual([2]) + }) + + test("rejects live baseline results after the primary checkout changes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "search-worktree-")) + const main = await mkdtemp(path.join(tmpdir(), "search-main-")) + const content = "export const value = 'worktree'" + const hash = createHash("sha256").update(content).digest("hex") + await writeFile(path.join(root, "file.ts"), content) + await writeFile(path.join(main, "file.ts"), "export const value = 'primary-only'") + + const overlay = new WorktreeOverlay(root, main, new Map([["file.ts", hash]])) + overlay.reconcile({ [path.join(root, "file.ts")]: hash }) + const state = new CodeIndexStateManager() + state.setSystemState("Indexed") + const service = new CodeIndexSearchService(config(), state, embedder([]), store([], []), { + store: store( + [ + result( + "file.ts", + 0.99, + "export const value = 'primary-only'", + createHash("sha256").update("export const value = 'primary-only'").digest("hex"), + ), + ], + [], + ), + overlay, + }) + + expect(await service.searchIndex("query")).toEqual([]) + }) + + test("over-fetches when shadowed baseline results consume the first page", async () => { + const root = await mkdtemp(path.join(tmpdir(), "search-worktree-")) + const main = await mkdtemp(path.join(tmpdir(), "search-main-")) + const hash = (content: string) => createHash("sha256").update(content).digest("hex") + await mkdir(path.join(root, "src"), { recursive: true }) + await mkdir(path.join(main, "src"), { recursive: true }) + await Promise.all( + ["c", "d"].flatMap((name) => [ + writeFile(path.join(root, `src/${name}.ts`), `src/${name}.ts`), + writeFile(path.join(main, `src/${name}.ts`), `src/${name}.ts`), + ]), + ) + const overlay = new WorktreeOverlay( + root, + main, + new Map([ + ["src/a.ts", "a"], + ["src/b.ts", "b"], + ["src/c.ts", hash("src/c.ts")], + ["src/d.ts", hash("src/d.ts")], + ]), + ) + overlay.reconcile({ + [path.join(root, "src/a.ts")]: "changed-a", + [path.join(root, "src/b.ts")]: "changed-b", + [path.join(root, "src/c.ts")]: hash("src/c.ts"), + [path.join(root, "src/d.ts")]: hash("src/d.ts"), + }) + + const limits: number[] = [] + const state = new CodeIndexStateManager() + state.setSystemState("Indexed") + const service = new CodeIndexSearchService(config(), state, embedder([]), store([], []), { + store: store( + [ + result("src/a.ts", 0.99), + result("src/b.ts", 0.98), + result("src/c.ts", 0.8, "src/c.ts", hash("src/c.ts")), + result("src/d.ts", 0.7, "src/d.ts", hash("src/d.ts")), + ], + limits, + ), + overlay, + }) + + const results = await service.searchIndex("query") + + expect(limits).toEqual([2, 4]) + expect(results.map((item) => item.payload?.filePath)).toEqual(["src/c.ts", "src/d.ts"]) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts index 86d50ccc4a1..569c3a7eb38 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -29,6 +29,16 @@ describe("CodeIndexServiceFactory", () => { setOpenAIConstructorHook(undefined) }) + test("creates an OpenAI-compatible embedder without an API key", () => { + const factory = createFactory({ + embedderProvider: "openai-compatible", + openAiKey: undefined, + openAiCompatibleBaseUrl: "http://localhost:1234/v1", + }) + + expect(factory.createEmbedder().embedderInfo).toEqual({ name: "openai-compatible" }) + }) + test("uses default LanceDB directory when config is unset", () => { const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: undefined }) diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts index 0b47cd6830a..2ac7fdeeab6 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts @@ -173,9 +173,50 @@ describe("LocalVectorStore", () => { expect(mockLoadLanceDB).toHaveBeenCalledTimes(1) expect(mockLanceDBModule.connect).not.toHaveBeenCalled() }) + + test("serializes native connections across stores", async () => { + const other = new LanceDBVectorStore(path.join("mock", "other"), vectorSize, dbDirectory) + store["db"] = null + other["lancedbModule"] = mockLanceDBModule + let active = 0 + let maximum = 0 + mockLanceDBModule.connect.mockImplementation(async () => { + active += 1 + maximum = Math.max(maximum, active) + await Bun.sleep(10) + active -= 1 + return mockDb + }) + + try { + await Promise.all([store.collectionExists(), other.collectionExists()]) + } finally { + await other.close() + } + + expect(maximum).toBe(1) + }) }) describe("initialize", () => { + test("opens a complete compatible baseline without mutating it", async () => { + spyOn(fs, "existsSync").mockReturnValue(true as any) + store["_getStoredEmbeddingProfile"] = mock().mockResolvedValue({ + provider: "openai", + modelId: "", + dimension: vectorSize, + }) + store["_getMetadataValue"] = mock((_: unknown, key: string) => + Promise.resolve(key === "index_schema" ? "2" : "true"), + ) + + await store.openExisting() + + expect(mockDb.createTable).not.toHaveBeenCalled() + expect(mockDb.dropTable).not.toHaveBeenCalled() + expect(mockTable.delete).not.toHaveBeenCalled() + }) + test("should create tables if not exist", async () => { mockDb.tableNames.mockResolvedValue([]) mockDb.createTable.mockResolvedValue(mockTable) @@ -195,19 +236,49 @@ describe("LocalVectorStore", () => { expect(mockDb.dropTable).toHaveBeenCalled() }) - test("should not recreate if vector size matches", async () => { + test("should not recreate if vector size and schema match", async () => { mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) mockDb.openTable.mockResolvedValue(mockTable) store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize) + store["_getMetadataValue"] = mock().mockResolvedValue("2") const result = await store.initialize() expect(result).toBe(false) }) + test("recreates an index using the legacy payload schema", async () => { + mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) + mockDb.openTable.mockResolvedValue(mockTable) + store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize) + store["_getMetadataValue"] = mock().mockResolvedValue("1") + + expect(await store.initialize()).toBe(true) + expect(mockDb.dropTable).toHaveBeenCalledTimes(2) + }) + test("should throw error on LanceDB failure", async () => { mockDb.tableNames.mockRejectedValue(new Error("fail")) await expect(store.initialize()).rejects.toThrow() }) + test("does not recreate when vector metadata cannot be read", async () => { + store["_getStoredVectorSize"] = mock().mockRejectedValue(new Error("metadata unavailable")) + + await expect(store.initialize()).rejects.toThrow("metadata unavailable") + expect(mockDb.dropTable).not.toHaveBeenCalled() + expect(mockDb.createTable).not.toHaveBeenCalled() + }) + + test("does not recreate when profile metadata cannot be read", async () => { + mockTable.countRows.mockResolvedValue(1) + store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize) + store["_getMetadataValue"] = mock().mockResolvedValue("2") + store["_getStoredEmbeddingProfile"] = mock().mockRejectedValue(new Error("profile unavailable")) + + await expect(store.initialize()).rejects.toThrow("profile unavailable") + expect(mockDb.dropTable).not.toHaveBeenCalled() + expect(mockDb.createTable).not.toHaveBeenCalled() + }) + test("should recreate tables when stored embedding identity differs", async () => { const identity = { provider: "openai", @@ -316,7 +387,7 @@ describe("LocalVectorStore", () => { { id: "123e4567-e89b-12d3-a456-426614174000", vector: [1, 2, 3], - payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }, + payload: { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 }, }, ] mockTable.delete.mockResolvedValue(undefined) @@ -331,7 +402,7 @@ describe("LocalVectorStore", () => { { id: "123e4567-e89b-12d3-a456-426614174000", vector: [1, 2, 3], - payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }, + payload: { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 }, }, ] mockTable.delete.mockResolvedValue(undefined) @@ -349,7 +420,7 @@ describe("LocalVectorStore", () => { distanceRange: distanceRangeSpy, limit: mock().mockReturnThis(), toArray: mock().mockResolvedValue([ - { id: "2", _distance: 0.2, filePath: "a", codeChunk: "c", startLine: 3, endLine: 4 }, + { id: "2", _distance: 0.2, filePath: "a", fileHash: "hash-a", codeChunk: "c", startLine: 3, endLine: 4 }, ]), }) const results = await store.search([1, 2, 3], "a", 0.7, 1) @@ -373,7 +444,7 @@ describe("LocalVectorStore", () => { distanceRange: distanceRangeSpy, limit: mock().mockReturnThis(), toArray: mock().mockResolvedValue([ - { id: "2", _distance: 0.2, filePath: "a", codeChunk: "c", startLine: 3, endLine: 4 }, + { id: "2", _distance: 0.2, filePath: "a", fileHash: "hash-a", codeChunk: "c", startLine: 3, endLine: 4 }, ]), }) const results = await store.search([1, 2, 3], "a", 0.1, 2) @@ -490,7 +561,7 @@ describe("LocalVectorStore", () => { }) test("should return true for valid payload", () => { - const payload: Payload = { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 } + const payload: Payload = { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 } expect(store["isPayloadValid"](payload)).toBe(true) }) }) @@ -590,6 +661,7 @@ describe("LocalVectorStore", () => { vector: [1, 2, 3], payload: { filePath: "test.ts", + fileHash: "hash-test", codeChunk: "code", startLine: 1, endLine: 2, @@ -609,12 +681,12 @@ describe("LocalVectorStore", () => { { id: "123e4567-e89b-12d3-a456-426614174000", vector: [1, 2, 3], - payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }, + payload: { filePath: "a", fileHash: "hash-a", codeChunk: "b", startLine: 1, endLine: 2 }, }, { id: "' OR '1'='1", vector: [4, 5, 6], - payload: { filePath: "c", codeChunk: "d", startLine: 3, endLine: 4 }, + payload: { filePath: "c", fileHash: "hash-c", codeChunk: "d", startLine: 3, endLine: 4 }, }, ] diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts index 8a580bcde83..9ecb36221b2 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import { createHash } from "crypto" import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../../../src/indexing/constants" @@ -29,14 +30,6 @@ mock.module("@qdrant/js-client-rest", () => ({ QdrantClient: MockQdrantClientConstructor, })) -const mockDigest = mock() -const mockUpdate = mock(() => ({ update: mockUpdate, digest: mockDigest })) -const mockCreateHash = mock(() => ({ update: mockUpdate, digest: mockDigest })) - -mock.module("crypto", () => ({ - createHash: mockCreateHash, -})) - // Now import the module under test import { QdrantVectorStore } from "../../../../src/indexing/vector-store/qdrant-client" @@ -46,8 +39,7 @@ describe("QdrantVectorStore", () => { const mockQdrantUrl = "http://mock-qdrant:6333" const mockApiKey = "test-api-key" const mockVectorSize = 1536 - const mockHashedPath = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" - const expectedCollectionName = `ws-${mockHashedPath.substring(0, 16)}` + const expectedCollectionName = `ws-${createHash("sha256").update(mockWorkspacePath).digest("hex").substring(0, 16)}` beforeEach(() => { // Reset all mocks @@ -61,14 +53,6 @@ describe("QdrantVectorStore", () => { mockQuery.mockReset() mockDelete.mockReset() mockRetrieve.mockReset() - mockCreateHash.mockReset() - mockUpdate.mockReset() - mockDigest.mockReset() - - // Mock crypto.createHash chain - mockCreateHash.mockReturnValue({ update: mockUpdate, digest: mockDigest }) - mockUpdate.mockReturnValue({ update: mockUpdate, digest: mockDigest }) - mockDigest.mockReturnValue(mockHashedPath) vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey) }) @@ -83,9 +67,6 @@ describe("QdrantVectorStore", () => { mockQuery.mockReset() mockDelete.mockReset() mockRetrieve.mockReset() - mockCreateHash.mockReset() - mockUpdate.mockReset() - mockDigest.mockReset() }) test("should correctly initialize QdrantClient and collectionName in constructor", () => { @@ -99,9 +80,6 @@ describe("QdrantVectorStore", () => { "User-Agent": "Kilo-Code", }, }) - expect(mockCreateHash).toHaveBeenCalledWith("sha256") - expect(mockUpdate).toHaveBeenCalledWith(mockWorkspacePath) - expect(mockDigest).toHaveBeenCalledWith("hex") expect((vectorStore as any).collectionName).toBe(expectedCollectionName) expect((vectorStore as any).vectorSize).toBe(mockVectorSize) }) @@ -524,6 +502,31 @@ describe("QdrantVectorStore", () => { }) describe("initialize", () => { + test("opens a complete compatible baseline without mutating it", async () => { + mockGetCollection.mockResolvedValue({ + points_count: 3, + config: { params: { vectors: { size: mockVectorSize } } }, + }) + mockRetrieve.mockResolvedValue([ + { + payload: { + index_schema: 2, + indexing_complete: true, + embedding_provider: "openai", + embedding_model_id: "", + embedding_dimension: mockVectorSize, + }, + }, + ]) + + await vectorStore.openExisting() + + expect(mockCreateCollection).not.toHaveBeenCalled() + expect(mockDeleteCollection).not.toHaveBeenCalled() + expect(mockCreatePayloadIndex).not.toHaveBeenCalled() + expect(mockUpsert).not.toHaveBeenCalled() + }) + test("should create a new collection if none exists and return true", async () => { mockGetCollection.mockRejectedValue({ response: { status: 404 }, @@ -598,6 +601,33 @@ describe("QdrantVectorStore", () => { expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) }) + test("recreates a populated collection using the legacy payload schema", async () => { + mockGetCollection + .mockResolvedValueOnce({ + points_count: 7, + config: { params: { vectors: { size: mockVectorSize } } }, + } as any) + .mockRejectedValueOnce({ response: { status: 404 }, message: "Not found" }) + mockRetrieve.mockResolvedValue([ + { + payload: { + index_schema: 1, + indexing_complete: true, + embedding_provider: "openai", + embedding_model_id: "", + embedding_dimension: mockVectorSize, + }, + }, + ] as any) + mockDeleteCollection.mockResolvedValue(true as any) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + expect(await vectorStore.initialize()).toBe(true) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + }) + test("should recreate collection when stored embedding identity mismatches", async () => { const identity = { provider: "openai", @@ -1277,6 +1307,7 @@ describe("QdrantVectorStore", () => { score: 0.85, payload: { filePath: "src/test.ts", + fileHash: "test-hash", codeChunk: "test code", startLine: 1, endLine: 5, @@ -1288,6 +1319,7 @@ describe("QdrantVectorStore", () => { score: 0.75, payload: { filePath: "src/utils.ts", + fileHash: "test-hash", codeChunk: "utility code", startLine: 10, endLine: 15, @@ -1312,7 +1344,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs.filter).toEqual({ @@ -1332,6 +1364,7 @@ describe("QdrantVectorStore", () => { score: 0.85, payload: { filePath: "src/components/Button.tsx", + fileHash: "test-hash", codeChunk: "button code", startLine: 1, endLine: 5, @@ -1351,7 +1384,7 @@ describe("QdrantVectorStore", () => { score_threshold: DEFAULT_SEARCH_MIN_SCORE, limit: DEFAULT_MAX_SEARCH_RESULTS, params: { hnsw_ef: 128, exact: false }, - with_payload: { include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"] }, + with_payload: { include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"] }, }) expect(callArgs2.filter).toEqual({ must: [ @@ -1383,7 +1416,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs3.filter).toEqual({ @@ -1410,7 +1443,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs4.filter).toEqual({ @@ -1427,6 +1460,7 @@ describe("QdrantVectorStore", () => { score: 0.85, payload: { filePath: "src/test.ts", + fileHash: "test-hash", codeChunk: "test code", startLine: 1, endLine: 5, @@ -1444,6 +1478,7 @@ describe("QdrantVectorStore", () => { score: 0.55, payload: { filePath: "src/test2.ts", + fileHash: "test-hash", codeChunk: "test code 2", startLine: 10, endLine: 15, @@ -1470,6 +1505,7 @@ describe("QdrantVectorStore", () => { score: 0.85, payload: { filePath: "src/test.ts", + fileHash: "test-hash", codeChunk: "test code", startLine: 1, endLine: 5, @@ -1490,6 +1526,7 @@ describe("QdrantVectorStore", () => { score: 0.55, payload: { filePath: "src/test2.ts", + fileHash: "test-hash", codeChunk: "test code 2", startLine: 10, endLine: 15, @@ -1538,7 +1575,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs5.filter).toEqual({ @@ -1586,6 +1623,7 @@ describe("QdrantVectorStore", () => { score: 0.85, payload: { filePath: "src/test.ts", + fileHash: "test-hash", codeChunk: "test code", startLine: 1, endLine: 5, @@ -1609,7 +1647,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs7.filter).toEqual({ @@ -1638,7 +1676,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs6.filter).toEqual({ @@ -1665,7 +1703,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs8.filter).toEqual({ @@ -1692,7 +1730,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs9.filter).toEqual({ @@ -1719,7 +1757,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs10.filter).toEqual({ @@ -1746,7 +1784,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs11.filter).toEqual({ @@ -1779,7 +1817,7 @@ describe("QdrantVectorStore", () => { exact: false, }, with_payload: { - include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + include: ["filePath", "fileHash", "codeChunk", "startLine", "endLine", "pathSegments"], }, }) expect(callArgs12.filter).toEqual({ diff --git a/packages/kilo-indexing/test/kilocode/indexing/worktree-overlay.test.ts b/packages/kilo-indexing/test/kilocode/indexing/worktree-overlay.test.ts new file mode 100644 index 00000000000..9cc5920a813 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/worktree-overlay.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { WorktreeOverlay } from "../../../src/indexing/worktree-overlay" + +describe("WorktreeOverlay", () => { + test("seeds baseline hashes and shadows changed or deleted files", () => { + const root = path.resolve("/tmp/worktree") + const overlay = new WorktreeOverlay( + root, + path.resolve("/tmp/main"), + new Map([ + ["src/same.ts", "same"], + ["src/changed.ts", "base"], + ["src/deleted.ts", "deleted"], + ]), + ) + + expect(overlay.seed()).toEqual({ + [path.join(root, "src/same.ts")]: "same", + [path.join(root, "src/changed.ts")]: "base", + [path.join(root, "src/deleted.ts")]: "deleted", + }) + + overlay.reconcile({ + [path.join(root, "src/same.ts")]: "same", + [path.join(root, "src/changed.ts")]: "worktree", + }) + + expect(overlay.ready).toBe(true) + expect([...overlay.shadows].sort()).toEqual(["src/changed.ts", "src/deleted.ts"]) + }) + + test("blocks updates and restores baseline visibility after a revert", () => { + const root = path.resolve("/tmp/worktree") + const file = path.join(root, "src/file.ts") + const overlay = new WorktreeOverlay(root, path.resolve("/tmp/main"), new Map([["src/file.ts", "base"]])) + + overlay.block(file) + expect(overlay.blocked.has("src/file.ts")).toBe(true) + + overlay.settle(file, "changed") + expect(overlay.blocked.has("src/file.ts")).toBe(false) + expect(overlay.shadows.has("src/file.ts")).toBe(true) + + overlay.block(file) + overlay.settle(file, "base") + expect(overlay.blocked.has("src/file.ts")).toBe(false) + expect(overlay.shadows.has("src/file.ts")).toBe(false) + }) +}) diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml index 34b198f4e0a..9613fd9791f 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -25,4 +25,4 @@ false - \ No newline at end of file + diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 8afdd0ecac3..9d026428b88 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -20,6 +20,8 @@ When looking for IntelliJ Platform API usage, implementation examples, extension points, services, actions, inspections, PSI/VFS/editor behavior, or plugin patterns, prefer real IntelliJ source code over Gradle caches, downloaded jars, generated parser artifacts, or decompiled classes. +Do not use IntelliJ Platform APIs marked as internal in the IntelliJ source repository. Find a public API alternative or keep the integration behind supported extension points. Experimental APIs are acceptable when needed, but warn the user that the integration relies on an experimental IntelliJ API. + Use this priority order: 1. Check whether `$INTELLIJ_REPO` is set and points to a readable IntelliJ Community checkout. @@ -130,6 +132,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - Any code path that modifies UI state or depends on EDT threading must have tests that exercise the actual implementation. - Extend `BasePlatformTestCase` to get a real IntelliJ Application and EDT in tests. The session package already uses `SessionControllerTestBase` which wraps this. - Do not mock the EDT or threading assertions — test against the real threading model. +- Do not add production methods whose only purpose is test access. Prefer exercising the public API and inspecting the real Swing component tree in tests. - For state-driven updates, assert that the component state matches after flushing coroutines and draining the EDT. - For retained Swing components, assert that expand/collapse, update, and no-op paths work correctly without rebuilding the component tree. @@ -154,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - The plugin spawns `kilo serve --port 0` (OS assigns random port) and reads stdout for `listening on http://...:(\d+)` to discover the port. - A random 32-byte hex password is passed via `KILO_SERVER_PASSWORD` env var for Basic Auth. - Fixed env vars set on every spawn: `KILO_CLIENT=jetbrains`, `KILO_PLATFORM=jetbrains`, `KILO_APP_NAME=kilo-code`, `KILO_ENABLE_QUESTION_TOOL=true`, `KILO_DISABLE_CLAUDE_CODE=true`, `KILOCODE_FEATURE=jetbrains-plugin`. +- Unless already provided by the base environment, the backend sets `KILO_CONFIG_CONTENT` to make `edit` and `bash` permissions ask by default for JetBrains-launched CLI processes. - This is the same protocol used by the VS Code extension (`packages/kilo-vscode/src/services/cli-backend/server-manager.ts`). ### Dev Storage Isolation @@ -164,16 +168,33 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - The `.kilo-dev/` directory is gitignored and created automatically on first run. - The implementation lives in `KiloBackendCliManager.buildEnv()` / `devStorageEnv()`. Tests: `KiloBackendCliManagerEnvTest`. +### Debugging Session Event Logs + +- Use `script/dev/part-update.sh client ` from `packages/kilo-jetbrains/` to print frontend `message.part.delta` text by part id. +- Use `script/dev/part-update.sh backend ` from `packages/kilo-jetbrains/` for backend sandbox events. +- Append with `>> file.txt` when you need to keep the output. +- For full chat payload previews in JetBrains dev runs, pass `-Pkilo.dev.log.chat.content=` where `` is `off` (default, no content), `preview` (cleaned/truncated content), or `full` (cleaned full content). +- `-Pkilo.dev.log.chat.preview.max=` controls preview length, clamped from 1 to 2000. + ## 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. +- **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). +- **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 in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. - **Run split backend**: `./gradlew runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. - **Test split mode**: `./gradlew generateSplitModeRunConfigurations` creates a "Run IDE (Split Mode)" config that starts both frontend and backend processes locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). +### 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. + ## UI Guidelines ### Technology Choices @@ -222,6 +243,7 @@ Rules: ### Primary UI Rules - Use IntelliJ platform components instead of raw Swing where an equivalent exists (see [Platform Components](#platform-components-and-utilities) table below). +- When implementing a new user-facing action, consider adding metrics so usage can be tracked. - Do not set default Swing properties explicitly. Avoid `isOpaque = false` unless the component default differs or there is a documented rendering reason. - Avoid hardcoded dimensions, colors, and font sizes — use the platform style APIs described in [Theme-Derived Colors](#theme-derived-colors), [Theme-Derived Fonts](#theme-derived-fonts), and [Borders, Insets, and Spacing](#borders-insets-and-spacing). - Put user-visible strings in `*.properties` files. @@ -253,6 +275,22 @@ Tests for retained Swing components should assert: - `update(model)` changes existing labels/body text without duplicating components. - Updates while collapsed do not eagerly create lazy bodies. - No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. +- Streaming/rebuilding surfaces additionally require stress + leak tests (see below). + +### Stress and Leak Tests for Streaming UI + +Session/transcript UI that streams updates or rebuilds its component tree (markdown +views, code blocks, transcript parts, collapsible cards) must ship stress + leak tests in +addition to behavior tests. These tests must: + +- Drive many updates (hundreds of streamed deltas or `set` cycles) through the public API. +- Assert that retained component instances stay identical across updates (`assertSame`). +- Assert the component count stays bounded — no growth per update. +- Assert disposable-backed resources return to baseline after churn + clear/dispose. + For code editors, compare `EditorFactory.getInstance().allEditors.size` against a + baseline captured before the loop. + +See `MdViewHybridStressTest` for the reference pattern. ### Platform Components and Utilities @@ -364,10 +402,59 @@ For common spacing lookups, prefer `JBUI.CurrentTheme` area-specific insets (e.g | Side separators | `JBUI.Borders.customLineTop(...)`, `customLineBottom(...)` | | Composed borders | `JBUI.Borders.compound(...)`, `JBUI.Borders.merge(...)` | | Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` | -| Simple vertical custom Swing groups | `VerticalLayout` | +| One-dimensional multi-component rows/columns | `ai.kilocode.client.ui.layout.Stack` — see section below | | Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` | | Single-component alignment wrapper | `ai.kilocode.client.ui.layout.Align` — see section below | +### Stack — One-Dimensional Multi-Component Layout + +Use `Stack` (`ai.kilocode.client.ui.layout.Stack`) when multiple Swing components should be laid out as one vertical column or one horizontal row without visual chrome. It is a transparent, no-border, no-color `JPanel(null)` that lays out visible children in insertion order. + +**Behavior:** + +| Mode | Layout behavior | Size contribution | +|---|---|---| +| `Stack.vertical(gap)` | Children are placed top-to-bottom; each child fills the available container width; each child keeps its bounded preferred height | Width is max child width; height is summed child heights plus gaps | +| `Stack.horizontal(gap)` | Children are placed left-to-right; each child fills the available container height; each child keeps its bounded preferred width | Width is summed child widths plus gaps; height is max child height | + +"Bounded preferred" means the child's preferred size on the stack axis is coerced into the effective `[min, max]` range. On the cross axis, layout tracks the container size even if that ignores an individual child's preferred/minimum/maximum size. + +**Factories and fluent additions:** + +```kotlin +Stack.vertical() + .next(header) + .next(body) + +Stack.horizontal(gap = UiStyle.Gap.md()) + .next(icon) + .next(label) + +Stack.vertical(gap = UiStyle.Gap.sm()) + .next(summary) + .gap(UiStyle.Gap.lg()) + .next(details) + +Stack.vertical() + .next(header) + .fill(UiStyle.Gap.pad()) + .next(body) + +Stack.horizontal() + .next(icon) + .fill(UiStyle.Gap.sm()) + .next(label) +``` + +**Rules:** + +- Prefer `Stack.vertical(...)` or `Stack.horizontal(...)` over one-off `JPanel` + `BoxLayout` or simple single-line `FlowLayout` rows/columns. +- Use the constructor `gap` for the normal spacing between adjacent visible children. +- Use `gap(size)` for an explicit one-off gap only when the next added child is the next visible child. It is ignored when it is trailing or when a hidden component appears before the next visible child. +- Use `fill(size)`, `Stack.verticalFiller(size)`, or `Stack.horizontalFiller(size)` for persistent leading, trailing, or interstitial whitespace. Do not use `Box` or `gap(size)` for persistent spacing. +- Use `Stack` for simple retained Swing rows/columns where children should track the cross-axis size. Use `Align` for positioning one child inside available space. +- Do not use `Stack` for padding, borders, colors, wrapping rows, flexible glue, or transcript components that need width-aware HTML reflow. Use `JBUI.Borders.empty(...)`, `UiStyle.Gap`, purpose-built layouts, or `SessionLayout` for those concerns. + ### Align — Single-Component Alignment Wrapper Use `Align` (`ai.kilocode.client.ui.layout.Align`) when a single Swing component must be positioned inside available space without adding visual chrome. It is a transparent, no-border, no-color `JPanel(null)` that lays out its one child according to independent horizontal (`HAlign`) and vertical (`VAlign`) modes. `CenterShrinkPanel` has been removed; use `child.align(HAlign.CENTER, VAlign.CENTER)` as a direct replacement. diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md new file mode 100644 index 00000000000..bad3c6fd29f --- /dev/null +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -0,0 +1,202 @@ +# Changelog + +## 7.3.47 + +### Patch Changes + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`ef7aa7f`](https://github.com/Kilo-Org/kilocode/commit/ef7aa7fdf854f8a50681b56cb56377fa7763b18d) - Fix JetBrains provider settings after OAuth/connect actions by waiting through transient backend reloads and allowing longer OAuth exchanges. + +- [#11095](https://github.com/Kilo-Org/kilocode/pull/11095) [`9eddaf1`](https://github.com/Kilo-Org/kilocode/commit/9eddaf17126a63822307e9a52d9a32794eca5176) - Highlight shell tool commands in JetBrains chat transcripts. + +- [#11095](https://github.com/Kilo-Org/kilocode/pull/11095) [`5058050`](https://github.com/Kilo-Org/kilocode/commit/50580501679ea0900c2102c0509575e89f15a48e) - Improve markdown readability in JetBrains chat transcripts. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`52dfa54`](https://github.com/Kilo-Org/kilocode/commit/52dfa5453ed6080270f9e07cf6e9cafd8df75cd7) - Hide generated read-tool payload lines from JetBrains prompt bubbles while keeping attachments and assistant tool output visible. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`9f8f698`](https://github.com/Kilo-Org/kilocode/commit/9f8f698e38f8317b0f19d1f7406b0d6cc9777ad9) - Show JetBrains prompt attachments in one horizontal scrolling row in session history. + +- [#11324](https://github.com/Kilo-Org/kilocode/pull/11324) [`bc7f9b0`](https://github.com/Kilo-Org/kilocode/commit/bc7f9b05e8ca61ebf59d5a069923eef59c900a15) - Show a hover copy button for JetBrains session code and tool output blocks. + +- [#11324](https://github.com/Kilo-Org/kilocode/pull/11324) [`358135f`](https://github.com/Kilo-Org/kilocode/commit/358135f69ef6a4985cdd7aab0f1a2a8a0b631c27) - Add copy buttons below JetBrains session prompts and assistant responses. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`db96e31`](https://github.com/Kilo-Org/kilocode/commit/db96e31e655d77ef54ade03c32688218ca2e0e58) - Show provider names on JetBrains model picker buttons for non-Kilo Gateway models. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`49339a2`](https://github.com/Kilo-Org/kilocode/commit/49339a2583f6c51db9d1bfdc3f37ee5a4185b8a9) - Support pasting files and images into JetBrains chat prompts as attachments. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`9f8f698`](https://github.com/Kilo-Org/kilocode/commit/9f8f698e38f8317b0f19d1f7406b0d6cc9777ad9) - Show JetBrains prompt attachments inside the prompt bubble with previews and open embedded attachments in editor tabs. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`a8b127e`](https://github.com/Kilo-Org/kilocode/commit/a8b127e0ca8a7f29a11d03e548e78d084ccc3aa6) - Support sending file and image attachments from the JetBrains chat prompt. + +- [#11275](https://github.com/Kilo-Org/kilocode/pull/11275) [`3c319a5`](https://github.com/Kilo-Org/kilocode/commit/3c319a59a24a7fbf4a1d65eb88d1572ec178694b) - Limit JetBrains prompt input growth to the session while preserving scrolling for long prompts. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`b6bbb83`](https://github.com/Kilo-Org/kilocode/commit/b6bbb839e613a82e65ff445498e128a90851f8a5) - Show Connect for available JetBrains providers without explicit auth metadata, keep only actually configured providers disconnectable, and reduce provider settings diagnostics to debug logs. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`b0183f9`](https://github.com/Kilo-Org/kilocode/commit/b0183f984611df1b09145aa9716e948ee6bb4780) - Prefer remote-safe provider OAuth methods in JetBrains and show device-code authorization details when available. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`d8b6efd`](https://github.com/Kilo-Org/kilocode/commit/d8b6efd58c0ee66b1f71e484282993aad27fc08e) - Show cancellable OAuth progress in JetBrains provider settings and prevent starting another provider action while one is running. + +- [#11278](https://github.com/Kilo-Org/kilocode/pull/11278) [`62e42c1`](https://github.com/Kilo-Org/kilocode/commit/62e42c1efbeecd243c473e3cdde8d8a6ac55efc5) - Stop Kilo backend processes and clear JetBrains UI resources during restartless plugin unloads. + +- [#11324](https://github.com/Kilo-Org/kilocode/pull/11324) [`b4864eb`](https://github.com/Kilo-Org/kilocode/commit/b4864ebd43f211fe3f594edb29798f2f5d48b599) - Fix copying selected text from JetBrains session views. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`793cf93`](https://github.com/Kilo-Org/kilocode/commit/793cf934b72e7f9d71be3f23af058d88be67a9d3) - Support dropping files anywhere in a JetBrains chat session to add them to the prompt. + +- [#11095](https://github.com/Kilo-Org/kilocode/pull/11095) [`bb31723`](https://github.com/Kilo-Org/kilocode/commit/bb31723e7353c0649b9854812f9f803e04d92156) - Polish session header controls and align session view icons. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`709a53c`](https://github.com/Kilo-Org/kilocode/commit/709a53cda17da96a74cbd5a8bb88a9c6a78bd28e) - Open embedded JetBrains message attachments in frontend-managed Kilo editor tabs with loading and connection retry states. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`ad3be6c`](https://github.com/Kilo-Org/kilocode/commit/ad3be6cae999b440277fd8c660ba1b5eead07020) - Organize JetBrains provider settings into connected, popular, and all-provider sections, hide custom-provider creation, and prevent Kilo Gateway disconnects from provider settings. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`a8b127e`](https://github.com/Kilo-Org/kilocode/commit/a8b127e0ca8a7f29a11d03e548e78d084ccc3aa6) - Render file attachments as attachment cards in JetBrains prompts and session history. + +- [#11095](https://github.com/Kilo-Org/kilocode/pull/11095) [`a73ee53`](https://github.com/Kilo-Org/kilocode/commit/a73ee5329cf4455d33d8c8fd363ccf83b46a3cdb) - Render JetBrains shell tool output with markdown code blocks. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`1c7d5ca`](https://github.com/Kilo-Org/kilocode/commit/1c7d5ca7d373770e8d731177e0b851253d9c7d57) - Restore popular provider suggestions in JetBrains provider settings when provider metadata is unavailable. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`987da27`](https://github.com/Kilo-Org/kilocode/commit/987da2728731e1da1c974996b5bcddafe745cea7) - Show shared provider descriptions and provider icons in JetBrains and VS Code provider settings. + +- [#11077](https://github.com/Kilo-Org/kilocode/pull/11077) [`2f9c6ec`](https://github.com/Kilo-Org/kilocode/commit/2f9c6ecdd00c49b9c53a8e72bf8971cabf51821a) - Open embedded transcript attachments in stable Kilo editor tabs. + +## 7.4.0 + +### Minor Changes + +- [#11165](https://github.com/Kilo-Org/kilocode/pull/11165) [`bf67155`](https://github.com/Kilo-Org/kilocode/commit/bf6715594bae4a1160abb7cfdfdedaba4b8358ec) - Enhance draft prompts from the JetBrains chat composer using the configured small model. + +## 7.3.42 + +### Patch Changes + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`c90846a`](https://github.com/Kilo-Org/kilocode/commit/c90846a98938d3cdd666c46294ed4bb4871f7fcd) - Fix JetBrains session scrolling so mouse wheel and keyboard scrolling no longer snap back or bounce near the transcript bottom. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`d505677`](https://github.com/Kilo-Org/kilocode/commit/d505677d88816cf528b64392e23b7ccdddf98a4a) - Prevent the JetBrains session scrollbar from covering transcript content. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`5736a39`](https://github.com/Kilo-Org/kilocode/commit/5736a394597f250f64cf8c684d2426b56ca273ce) - Render glob search results in the JetBrains chat as collapsible tool output with separate directory and pattern rows. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`d1fa450`](https://github.com/Kilo-Org/kilocode/commit/d1fa4506c8b8e65b21cd08e0c6600598366aed0f) - Use matching VS Code-style icons for JetBrains session views. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`952241e`](https://github.com/Kilo-Org/kilocode/commit/952241ee07eebd22717bdf54ce07b3a6c66228af) - Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`b9bff3b`](https://github.com/Kilo-Org/kilocode/commit/b9bff3b69cf27fc7e0d88d411eaa368616fc32d6) - Reset stale hover styling when moving between JetBrains session cards and draw card outlines only while expanded. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`5736a39`](https://github.com/Kilo-Org/kilocode/commit/5736a394597f250f64cf8c684d2426b56ca273ce) - Render grep searches in the JetBrains chat with a dedicated search header that shows stacked, clipped targets. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`01f2886`](https://github.com/Kilo-Org/kilocode/commit/01f28861900d4794d6329821f0c9f5c9efdedae3) - Improve mouse wheel scrolling speed in the JetBrains session view. + +## 7.3.29 + +### Patch Changes + +## [Unreleased] + +## [7.0.1-rc.12] - 2026-06-18 + +### Added + +- Provider settings management, including searchable provider lists, API-key configuration, OAuth provider login, provider enable/disable controls, disconnect actions, and shared provider metadata. +- Add copy controls to session messages so prompts and assistant responses can be copied directly from the transcript. +- Share codebase indexes across worktrees so Agent Manager and worktree sessions can use semantic search without duplicating the full index. + +### Fixed + +- Keep long JetBrains prompt input usable by capping growth, preserving scrolling, and hiding soft-wrap glyphs. +- Copy actions correctly in session. + +### Changed + +- Update the bundled CLI runtime to OpenCode 1.15.9 + +## [7.0.1-rc.11] - 2026-06-17 + +### Added + +- Provider settings management, including provider catalog sections, provider descriptions, provider settings actions, disconnect flows, provider auth handling, and provider/model picker improvements. +- Session copy controls for chat messages. + +### Fixed + +- Cap JetBrains prompt input growth and hide soft wrap glyphs in the prompt field. +- Keep JetBrains provider toolbars and authentication overlays fixed, and improve provider API key dialog sizing. +- Clean up restartless unload behavior. +- Silence interrupted session notifications across clients. +- Always deny tool calls for system agents. + +## [7.0.1-rc.10] - 2026-06-17 + +### Added + +- Provider settings management, including provider catalog sections, provider descriptions, provider settings actions, disconnect flows, provider auth handling, and provider/model picker improvements. +- Session copy controls for chat messages. + +### Fixed + +- Cap JetBrains prompt input growth and hide soft wrap glyphs in the prompt field. +- Keep JetBrains provider toolbars and authentication overlays fixed, and improve provider API key dialog sizing. +- Clean up restartless unload behavior. +- Silence interrupted session notifications across clients. +- Always deny tool calls for system agents. + +## [7.0.1-rc.9] - 2026-06-15 + +### Added + +- Add prompt enhancement support. +- Support prompt and transcript attachments, including paste, drop, preview, and editor tab opening flows. + +### Fixed + +- Improve shell and markdown rendering, including code block spacing, terminal block retention, shell command highlighting, and session layout polish. + +## [7.0.1-rc.8] - 2026-06-09 + +### Added + +- Display search results and tool output in clearer, more readable JetBrains session cards. + +### Fixed + +- Improve session transcript scrolling so streaming updates, expanded cards, reasoning blocks, and mouse wheel scrolling preserve the user's position more reliably. +- Make session transcripts easier to scan with tighter spacing, aligned icons, cleaner card outlines, relative search paths, and less visual noise. +- Keep completed reasoning blocks expanded after a response finishes. +- Improve session stability during long-running or cancelled prompts. +- Restore automatic session titles, project skill discovery, and subagent isolation in forked sessions. +- Restore imported cloud session diffs. +- Compact sessions before the configured context limit is exceeded. + +### Changed + +- Update the bundled Kilo CLI runtime with the latest fixes used by the JetBrains plugin. + +## [7.0.1-rc.7] - 2026-06-04 + +### Fixed + +- Fixed JetBrains release notes rendering so notes from multiple releases display correctly. + +## [7.0.1-rc.6] - 2026-06-03 + +### Fixed + +- Model picker now highlights models that can be used for training. + +## [7.0.1-rc.5] - 2026-06-03 + +### Added + +- Added Feedback & Support entry points to the empty session screen +- Model and configuration settings, including config file shortcuts and separate CLI restart and reinstall actions. + +### Fixed + +- Prevented stale backend events from affecting sessions after a restart. +- Improved chat code blocks and made long or streaming session transcripts faster and more stable. + +## [7.0.1-rc.4] - 2026-05-29 + +### Added + +- Initial JetBrains plugin release with a native Kilo Code tool window. +- Chat sessions with streamed responses, tool output, reasoning, markdown, todos, and plan follow-ups. +- Native mode/model selection, account sign-in, permission prompts, and question flows. +- Local and cloud session history with search, reopen, rename/delete local sessions, and repository filtering. +- Migration wizard for legacy JetBrains plugin settings and chat history. +- Bundled Kilo CLI runtime for macOS, Linux, and Windows. diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 146feaba28e..2d704f9fee5 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -2,6 +2,8 @@ AI coding agent plugin for JetBrains IDEs. +To try the v7 Early Access Program plugin, follow the [JetBrains EAP installation guide](https://kilo.ai/docs/code-with-ai/platforms/jetbrains#jetbrains-early-access). + --- ## Set up your environment diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md index 1f8efae536f..b74103894b7 100644 --- a/packages/kilo-jetbrains/RELEASE_TODO.md +++ b/packages/kilo-jetbrains/RELEASE_TODO.md @@ -13,7 +13,8 @@ - Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`. - Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment. -- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases from `jetbrains/v*` tags. +- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags. +- Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags. - Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers. - If using an environment, move the Marketplace and signing secrets there and set the workflow job environment. @@ -29,16 +30,23 @@ ## Per-RC Release - Choose an RC version in the form `x.y.z-rc.n`. -- Push tag `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. +- Run the `prepare-jetbrains-release` workflow with `kind=rc` and version `x.y.z-rc.n`. +- Confirm the workflow created `jetbrains/vx.y.z-rc.n` immediately at the intended source commit. +- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR. +- Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. - Watch the `publish-jetbrains` workflow. - Download and retain the workflow artifact if needed. - Confirm the update appears on the JetBrains Marketplace `eap` channel. - Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset. - Share `https://plugins.jetbrains.com/plugins/eap/list` with testers. -## Stable Release Guard +## Per-Stable Release -- Stable tags like `jetbrains/vx.y.z` are intentionally rejected for now. -- Before enabling stable releases, remove the workflow stable guard. -- Verify `kilo.channel=default` publishes to the default Marketplace channel. -- Update this checklist before stable releases are enabled. +- Choose a stable version in the form `x.y.z`. +- Run the `prepare-jetbrains-release` workflow with `kind=stable` and version `x.y.z`. +- Confirm the workflow created `jetbrains/vx.y.z` immediately at the intended source commit. +- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR. +- Merge the release PR to trigger publish from `jetbrains/vx.y.z`. +- Watch the `publish-jetbrains` workflow. +- Confirm the update appears on the default JetBrains Marketplace channel. +- Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset. diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md index a5d223cb501..47d65e0f5ce 100644 --- a/packages/kilo-jetbrains/RELEASING.md +++ b/packages/kilo-jetbrains/RELEASING.md @@ -1,62 +1,138 @@ # Releasing the JetBrains Plugin -## RC releases (currently the only supported flow) +JetBrains releases are locked by an immediate `jetbrains/v` tag, then gated by a reviewed release PR. The tag fixes the exact source code that will be published; the PR is where maintainers review and edit the version and changelog before publishing starts. -Stable release tags (`jetbrains/vx.y.z`) are recognized by the workflow but intentionally rejected. Only RC tags are accepted right now. +The published code comes from `jetbrains/v`. Marketplace and GitHub release notes come from the reviewed changelog merged in the release PR. -### 1. Create and push a tag +## Skill-Assisted Release -Tag format: `jetbrains/v..-rc.` +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. +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 + +1. Open the GitHub Actions workflow: + +[https://github.com/Kilo-Org/kilocode/actions/workflows/prepare-jetbrains-release.yml](https://github.com/Kilo-Org/kilocode/actions/workflows/prepare-jetbrains-release.yml) + +2. Click **Run workflow**. This immediately creates `jetbrains/v` at the current `origin/main` commit, then creates or updates the release PR. + +3. Fill the inputs: + +| Input | Value | +|---|---| +| `kind` | `rc` for an EAP release, `stable` for a default Marketplace release. | +| `version` | `x.y.z-rc.n` for RCs, `x.y.z` for stable releases. | +| `from_tag` | Optional previous tag for the changelog range. Leave empty unless the default range is wrong. | + +Examples: + +```text +kind=rc +version=7.3.13-rc.1 ``` -git tag jetbrains/v7.0.1-rc.1 -git push origin jetbrains/v7.0.1-rc.1 + +```text +kind=stable +version=7.3.13 ``` -### 2. Watch the workflow +## Changelog Range Defaults -The `publish-jetbrains` workflow starts automatically on tag push. Follow progress at: +The workflow chooses a changelog base automatically and generates notes against the locked release commit: -[https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml](https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml) +| Release | Default `from_tag` | +|---|---| +| First RC for a version, e.g. `7.3.13-rc.1` | Latest stable JetBrains tag. | +| Later RC, e.g. `7.3.13-rc.2` | Previous RC for the same base version. | +| Stable, e.g. `7.3.13` | Latest stable JetBrains tag, ignoring RCs. | + +Use `from_tag` only to override this comparison range. It does not change the release target commit. + +For the first stable JetBrains release, there may be no previous stable tag yet. In that case, pass the last RC or another reviewed JetBrains tag as `from_tag`. + +## Review the PR + +The workflow creates or updates a branch like: + +```text +jetbrains/release/v7.3.13-rc.1 +``` -The workflow: -1. Validates the tag format and required secrets. -2. Downloads CLI binaries for all 6 platforms from the matching GitHub Release. -3. Verifies and signs the plugin with `./gradlew verifyPlugin publishPlugin -Pproduction=true`. -4. Publishes the signed ZIP to the JetBrains Marketplace `eap` channel. -5. Uploads the signed ZIP to a GitHub prerelease for the tag. +The PR updates: -### 3. Verify on the Marketplace +| File | Purpose | +|---|---| +| `packages/kilo-jetbrains/gradle.properties` | JetBrains plugin version in `kilo.jetbrains.version`. | +| `packages/kilo-jetbrains/CHANGELOG.md` | Release notes packaged into the plugin. | + +Review `packages/kilo-jetbrains/gradle.properties` and edit `packages/kilo-jetbrains/CHANGELOG.md` before merging. This changelog entry is rendered into JetBrains ``, so it appears on the Marketplace and inside IntelliJ plugin UI. + +The PR can change release metadata such as `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md`, but it does not change the tagged source code that will be built. + +## Merge and Publish + +When the release PR is merged, the `publish-jetbrains` workflow validates the existing tag and release PR markers: + +```text +jetbrains/v +``` + +Then it publishes from that tag: + +[https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml](https://github.com/Kilo-Org/kilocode/actions/workflows/publish-jetbrains.yml) -Once the workflow succeeds, the new version should appear in the plugin's version list: +Publishing behavior: -[https://plugins.jetbrains.com/plugin/28350-kilo-code/edit/versions](https://plugins.jetbrains.com/plugin/28350-kilo-code/edit/versions) +| Version | Marketplace channel | GitHub release | +|---|---|---| +| `x.y.z-rc.n` | `eap` | Prerelease | +| `x.y.z` | default | Stable release | ---- +The workflow checks out `jetbrains/v` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata. -## Installing RC builds via the custom plugin repository +## Installing RC Builds -RC builds are published to the `eap` channel, not the default channel. To get them in IntelliJ IDEA: +RC builds are published to the `eap` channel. To get them in IntelliJ IDEA: 1. Open **Settings > Plugins**. 2. Click the gear icon and choose **Manage Plugin Repositories**. 3. Add the following URL: -``` +```text https://plugins.jetbrains.com/plugins/list?channel=eap&pluginId=28350 ``` -4. Search for **Kilo Code** in the Marketplace tab — the latest RC version will appear and update automatically. +4. Search for **Kilo Code** in the Marketplace tab. + +## Manual Recovery + +If the prepare workflow created the tag but failed before creating or updating the PR, rerun the workflow for the same version. It reuses the tag if it still points to the same locked commit. + +If publish validation says the tag points to the wrong SHA, stop and inspect manually. Do not move, delete, or recreate release tags casually. ---- +If publish failed after merge, rerun the failed `publish-jetbrains` workflow if the failure happened before Marketplace accepted the version. Marketplace may reject a duplicate version after a successful publish. + +If Marketplace publishing succeeded but GitHub Release upload failed, manually create or edit the GitHub Release for the existing tag. Use the reviewed release notes from `packages/kilo-jetbrains/CHANGELOG.md` in the merged release PR. + +If the immediate tag must be created manually because the prepare workflow could not push it, create it at the intended locked `origin/main` commit before merging the release PR: + +```bash +git fetch origin main +git tag jetbrains/v7.3.13 +git push origin jetbrains/v7.3.13 +``` -## Required GitHub Actions secrets +## Required GitHub Actions Secrets | Secret | Purpose | |---|---| -| `JETBRAINS_MARKETPLACE_TOKEN` | Marketplace API token for publishing | -| `JETBRAINS_CERTIFICATE_CHAIN` | PEM certificate chain for plugin signing | -| `JETBRAINS_PRIVATE_KEY` | PEM private key for plugin signing | -| `JETBRAINS_PRIVATE_KEY_PASSWORD` | Password for the private key | +| `KILO_MAINTAINER_APP_ID` | GitHub App ID used to create/update release PRs and immediate release tags. | +| `KILO_MAINTAINER_APP_SECRET` | GitHub App private key used to create/update release PRs and immediate release tags. | +| `JETBRAINS_MARKETPLACE_TOKEN` | Marketplace API token for publishing. | +| `JETBRAINS_CERTIFICATE_CHAIN` | PEM certificate chain for plugin signing. | +| `JETBRAINS_PRIVATE_KEY` | PEM private key for plugin signing. | +| `JETBRAINS_PRIVATE_KEY_PASSWORD` | Password for the private key. | Before the first publish, complete `RELEASE_TODO.md` to set up these secrets and the Marketplace plugin entry. diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 224eb3e5a57..8139adcb753 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -84,10 +84,12 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { tasks.named("compileKotlin") { dependsOn(fixGeneratedApi) + inputs.dir(generatedApi) } tasks.named("compileTestKotlin") { dependsOn(fixGeneratedApi) + inputs.dir(generatedApi) } val cliDir = layout.buildDirectory.dir("generated/cli/cli") 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 7bb9c069e10..257284d81a3 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 @@ -3,6 +3,7 @@ package ai.kilocode.backend.app import ai.kilocode.jetbrains.api.model.Config import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response +import ai.kilocode.backend.migration.LegacyMigrationDetection /** * Full application lifecycle state, combining CLI transport connection @@ -15,6 +16,7 @@ sealed class KiloAppState { data object Disconnected : KiloAppState() data object Connecting : KiloAppState() data class Loading(val progress: LoadProgress) : KiloAppState() + data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState() data class Ready(val data: AppData) : KiloAppState() data class Error(val message: String, val errors: List = emptyList()) : 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 fb0f6d8430e..4534ec376c5 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 @@ -2,6 +2,10 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService +import ai.kilocode.backend.migration.LegacyMigrationDetection +import ai.kilocode.backend.telemetry.KiloBackendTelemetry import ai.kilocode.log.KiloLog import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.jetbrains.api.client.DefaultApi @@ -16,11 +20,14 @@ import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest import ai.kilocode.rpc.dto.DeviceAuthDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.HealthDto import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -34,7 +41,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -68,6 +78,7 @@ class KiloBackendAppService private constructor( private val cs: CoroutineScope, private val server: CliServer, private val log: KiloLog, + private val loadTimeoutMs: Long, ) : Disposable { /** IntelliJ service injection entry point. */ @@ -75,28 +86,33 @@ class KiloBackendAppService private constructor( cs, KiloBackendCliManager(), KiloLog.create(KiloBackendAppService::class.java), + APP_LOAD_TIMEOUT_MS, ) companion object { 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 /** Test factory — no IntelliJ deps needed. */ internal fun create( cs: CoroutineScope, server: CliServer, log: KiloLog, - ) = KiloBackendAppService(cs, server, log) + loadTimeoutMs: Long = APP_LOAD_TIMEOUT_MS, + ) = KiloBackendAppService(cs, server, log, loadTimeoutMs) } private val mutex = Mutex() private val connection = KiloConnectionService(cs, server, onReconnect = { cs.launch { reconnect() } - }, log = log) + }, appLoadTimeoutMs = loadTimeoutMs, log = log) private var watcher: Job? = null private var eventWatcher: Job? = null private var loader: Job? = null + private var closed = false private val loadLock = Any() private val _appState = MutableStateFlow(KiloAppState.Disconnected) @@ -111,7 +127,6 @@ class KiloBackendAppService private constructor( val chat = KiloBackendChatManager(cs, log) val models = KiloBackendModelStateManager(log) val workspaces = KiloBackendWorkspaceManager(cs, sessions, log) - @Volatile var profile: KiloProfile200Response? = null private set @@ -127,7 +142,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) return + if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) return ensureWatcher() connection.connect() } @@ -147,6 +162,12 @@ class KiloBackendAppService private constructor( } } + suspend fun shutdownForUnload() { + mutex.withLock { + shutdown() + } + } + suspend fun retry() { mutex.withLock { when (val current = _appState.value) { @@ -156,6 +177,10 @@ class KiloBackendAppService private constructor( } KiloAppState.Connecting, is KiloAppState.Loading -> Unit + is KiloAppState.MigrationRequired -> { + log.info("retry: rerunning migration detection") + load() + } is KiloAppState.Ready -> { if (current.data.warnings.isEmpty()) return log.info("retry: refreshing config warnings") @@ -190,10 +215,70 @@ class KiloBackendAppService private constructor( return HealthDto(healthy = response.healthy, version = response.version) } + fun requireReady() { + when (_appState.value) { + is KiloAppState.Ready -> return + is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required") + else -> throw IllegalStateException("Kilo backend is not ready") + } + } + + suspend fun awaitReady(timeoutMs: Long = READY_TIMEOUT_MS) { + when (_appState.value) { + is KiloAppState.Ready -> return + is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required") + is KiloAppState.Loading, + KiloAppState.Connecting -> { + val state = withTimeoutOrNull(timeoutMs) { + appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting } + } + when (state) { + is KiloAppState.Ready -> return + is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required") + else -> throw IllegalStateException("Kilo backend is not ready") + } + } + else -> throw IllegalStateException("Kilo backend is not ready") + } + } + + suspend fun updateConfig(patch: ConfigPatchDto): KiloAppState { + val http = connection.apiClient ?: throw IllegalStateException("Not connected") + val current = _appState.value as? KiloAppState.Ready ?: throw IllegalStateException("Kilo backend is not ready") + val body = KiloCliDataParser.buildConfigPatch(patch) + val summary = summary(patch) + log.info("Global config patch: started $summary") + val request = Request.Builder() + .url("http://127.0.0.1:$port/global/config") + .header("Accept", "application/json") + .patch(body.toRequestBody("application/json".toMediaType())) + .build() + withContext(Dispatchers.IO) { + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val text = response.body?.string() + log.warn("Global config patch failed: HTTP ${response.code} ${response.message} $summary ${text.orEmpty()}") + throw IllegalStateException("Global config patch failed: HTTP ${response.code} ${response.message}") + } + } + } + log.info("Global config patch: saved $summary") + refreshConfigState() + log.info("Global config patch: state refreshed $summary") + return (_appState.value as? KiloAppState.Ready) ?: current + } + + internal suspend fun resumeAfterMigration() { + mutex.withLock { + if (_appState.value !is KiloAppState.MigrationRequired) return + load() + } + } + private suspend fun reconnect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) { + if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { log.info("reconnect: already ${current::class.simpleName} — skipping") return } @@ -210,7 +295,6 @@ class KiloBackendAppService private constructor( ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> { - models.start(connection.apiClient ?: return@collect, next.port) load() } is ConnectionState.Error -> setAppError( @@ -239,10 +323,24 @@ class KiloBackendAppService private constructor( loader?.cancel() eventWatcher?.cancel() loader = cs.launch { + val start = System.currentTimeMillis() log.info("Application starting — loading config, profile, notifications") val progress = AtomicReference(LoadProgress()) _appState.value = KiloAppState.Loading(progress.get()) + val migration = detectMigration() + if (migration != null) { + captureLoad("Backend Migration Required", start, mapOf("migrationRequired" to "true")) + stopRuntime() + profile = null + config = null + notifications = emptyList() + warnings = emptyList() + _appState.value = KiloAppState.MigrationRequired(migration) + log.info("Application paused — legacy migration required") + return@launch + } + val errors = CopyOnWriteArrayList() var cfg: Config? = null var prof: KiloProfile200Response? = null @@ -250,7 +348,8 @@ class KiloBackendAppService private constructor( var warns: List = emptyList() try { - coroutineScope { + withTimeout(loadTimeoutMs) { + coroutineScope { launch { val result = fetchProfile() val status = when { @@ -291,18 +390,27 @@ class KiloBackendAppService private constructor( throw LoadFailure(err) } } - launch { - warns = fetchWarnings() } } + warns = fetchWarnings() + ensureActive() profile = prof config = cfg notifications = notifs + models.start(connection.apiClient!!, connection.port) sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + startWatchingGlobalSseEvents() + setTelemetry(true) + captureBackend("Backend Connected", mapOf("portKnown" to "true")) + captureLoad("Backend Load Completed", start, mapOf( + "profileStatus" to if (prof != null) "loaded" else "not_logged_in", + "warningCount" to warns.size.toString(), + "migrationRequired" to "false", + )) setAppReady( AppData( profile = prof, @@ -312,11 +420,30 @@ class KiloBackendAppService private constructor( ) ) log.info("Application started — config, profile, notifications loaded") - startWatchingGlobalSseEvents() + } catch (e: TimeoutCancellationException) { + val err = LoadError( + resource = "app", + detail = "Timed out loading app data after ${loadTimeoutMs}ms", + ) + log.warn("Application start timed out after ${loadTimeoutMs}ms") + captureLoad("Backend Load Failed", start, mapOf( + "errorCount" to (errors.size + 1).toString(), + "resources" to (errors.map { it.resource } + err.resource).distinct().joinToString(","), + "reason" to "timeout", + )) + setAppError( + message = "Failed to load required data", + errors = errors.toList() + err, + ) } catch (e: CancellationException) { throw e } catch (e: Exception) { log.warn("Application start failed: ${e.message}") + captureLoad("Backend Load Failed", start, mapOf( + "errorCount" to errors.size.toString(), + "resources" to errors.map { it.resource }.distinct().joinToString(","), + "reason" to e::class.java.name, + )) setAppError( message = "Failed to load required data", errors = errors.toList(), @@ -326,6 +453,64 @@ class KiloBackendAppService private constructor( } } + private fun captureLoad(event: String, start: Long, props: Map) { + val http = connection.apiClient + val port = connection.port + cs.launch { + runCatching { + service().capture( + http, + port, + event, + props + mapOf("durationMs" to (System.currentTimeMillis() - start).toString()), + ) + }.onFailure { log.info("Skipping backend load telemetry: ${it.message}") } + } + } + + private fun setTelemetry(enabled: Boolean) { + val http = connection.apiClient + val port = connection.port + cs.launch { + runCatching { + service().setEnabled(http, port, enabled) + }.onFailure { log.info("Skipping telemetry setEnabled: ${it.message}") } + } + } + + private fun captureBackend(event: String, props: Map) { + val http = connection.apiClient + val port = connection.port + cs.launch { + runCatching { + service().capture(http, port, event, props) + }.onFailure { log.info("Skipping backend telemetry: ${it.message}") } + } + } + + private suspend fun detectMigration(): LegacyMigrationDetection? = withContext(Dispatchers.IO) { + val http = connection.apiClient ?: run { + log.info("Migration check: skipped because CLI HTTP client is not connected") + return@withContext null + } + log.info("Migration check: started") + val store = KiloBackendLegacyMigrationStoreService.store(log) + val status = store.status() + if (status != null) { + log.info("Migration check: skipped because status=$status") + return@withContext null + } + val detection = KiloBackendMigrationManager(http, connection.port).detect(store) + log.info("Migration check: completed hasData=${detection.hasData} ${migrationSummary(detection)}") + if (detection.hasData) detection else null + } + + private fun migrationSummary(detection: LegacyMigrationDetection): String { + val providers = detection.providers.count { it.supported } + val unsupported = detection.providers.size - providers + return "providers=$providers unsupportedProviders=$unsupported mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size} model=${detection.defaultModel != null} settings=${detection.settings != null}" + } + /** * Fetch the user profile. Returns [FetchResult.ok] with the response * on success, [FetchResult.ok] with `null` when not logged in or when @@ -336,7 +521,7 @@ class KiloBackendAppService private constructor( * as failures. */ private suspend fun fetchProfile(): FetchResult { - val client = connection.api + val client = connection.appLoadApi ?: return FetchResult.ok(null) return try { val response = client.kiloProfile() @@ -364,7 +549,7 @@ class KiloBackendAppService private constructor( } private suspend fun fetchConfig(): FetchResult { - val client = connection.api + val client = connection.appLoadApi ?: return FetchResult.fail("config", detail = "Not connected") return try { FetchResult.ok(client.globalConfigGet()) @@ -376,7 +561,7 @@ class KiloBackendAppService private constructor( } private suspend fun fetchNotifications(): FetchResult> { - val client = connection.api + val client = connection.appLoadApi ?: return FetchResult.fail("notifications", detail = "Not connected") return try { FetchResult.ok(client.kiloNotifications()) @@ -388,7 +573,7 @@ class KiloBackendAppService private constructor( } private suspend fun fetchWarnings(): List { - val client = connection.api ?: return emptyList() + val client = connection.appLoadApi ?: return emptyList() return try { client.configWarnings().map(::warning) } catch (e: Exception) { @@ -422,14 +607,14 @@ class KiloBackendAppService private constructor( private fun setAppReady(data: AppData) { warnings = data.warnings - _appState.value = KiloAppState.Ready(data) if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings) + _appState.value = KiloAppState.Ready(data) } private fun setAppError(message: String, errors: List) { val state = KiloAppState.Error(message, errors) - _appState.value = state warnAppError(state) + _appState.value = state } private fun warnAppError(state: KiloAppState.Error) { @@ -519,7 +704,7 @@ class KiloBackendAppService private constructor( synchronized(loadLock) { if (eventWatcher?.isActive == true) return log.info("Started watching global SSE events (config.updated, disposed)") - eventWatcher = cs.launch { + eventWatcher = cs.launch(start = CoroutineStart.UNDISPATCHED) { connection.events.collect { event -> when (event.type) { "global.config.updated" -> { @@ -550,10 +735,7 @@ class KiloBackendAppService private constructor( loader?.cancel() eventWatcher?.cancel() } - workspaces.stop() - models.stop() - chat.stop() - sessions.stop() + stopRuntime() profile = null config = null notifications = emptyList() @@ -561,6 +743,13 @@ class KiloBackendAppService private constructor( _appState.value = KiloAppState.Disconnected } + private fun stopRuntime() { + workspaces.stop() + models.stop() + chat.stop() + sessions.stop() + } + /** * Refresh the user profile from the CLI backend. * Returns the latest profile data, or null when not logged in. @@ -645,6 +834,12 @@ class KiloBackendAppService private constructor( } override fun dispose() { + shutdown() + } + + private fun shutdown() { + if (closed) return + closed = true watcher?.cancel() watcher = null clear() @@ -653,6 +848,11 @@ class KiloBackendAppService private constructor( } } +private fun summary(patch: ConfigPatchDto): String { + val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } + return "values=$values agents=${patch.agents.size}" +} + /** * Result of a data fetch — either a value or an error with details. */ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 3a73539b72e..19a399cdca5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -19,10 +20,18 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * Chat orchestrator that handles message sending, history loading, @@ -39,6 +48,7 @@ class KiloBackendChatManager( ) { companion object { private val JSON_TYPE = "application/json".toMediaType() + private const val ENHANCE_TIMEOUT_MINUTES = 2L private val CHAT_EVENTS = setOf( "message.updated", @@ -48,6 +58,7 @@ class KiloBackendChatManager( "message.part.removed", "session.turn.open", "session.turn.close", + "session.created", "session.error", "session.status", "session.updated", @@ -69,6 +80,7 @@ class KiloBackendChatManager( private var client: OkHttpClient? = null private var base: String? = null private var watcher: Job? = null + private var normalizer = KiloCliDataParser.ChatEventNormalizer() fun start(http: OkHttpClient, port: Int, sse: SharedFlow) { client = http @@ -77,10 +89,18 @@ class KiloBackendChatManager( watcher = cs.launch { sse.collect { event -> if (event.type in CHAT_EVENTS) { - val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data) - if (parsed != null) { - log.debug { ChatLogSummary.event(parsed) } - _events.emit(parsed) + val events = normalizer.parse(event.type, event.data) + if (events != null) { + for (parsed in events) { + log.debug { ChatLogSummary.event(parsed) } + if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") { + log.info( + "${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " + + "${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}", + ) + } + _events.emit(parsed) + } } else { log.warn("SSE parse returned null for type=${event.type} bytes=${event.data.length}") } @@ -95,11 +115,34 @@ class KiloBackendChatManager( watcher = null client = null base = null + normalizer = KiloCliDataParser.ChatEventNormalizer() log.info("Chat manager stopped") } // ------ prompt ------ + suspend fun enhancePrompt(dir: String, text: String): String { + val http = requireClient() + val url = requireBase() + val body = KiloCliDataParser.buildEnhancePromptJson(text) + val request = Request.Builder() + .url("$url/enhance-prompt?directory=${encode(dir)}") + .post(body.toRequestBody(JSON_TYPE)) + .build() + val call = http.newCall(request) + call.timeout().timeout(ENHANCE_TIMEOUT_MINUTES, TimeUnit.MINUTES) + + return call.await().use { response -> + val raw = response.body?.string().orEmpty() + if (!response.isSuccessful) { + log.warn("enhance prompt failed: HTTP ${response.code}") + raw.takeIf { it.isNotBlank() }?.let { log.debug { "kind=enhancePrompt error=${ChatLogSummary.body(it)}" } } + throw RuntimeException("Enhance prompt failed: HTTP ${response.code}") + } + KiloCliDataParser.parseEnhancedPrompt(raw) + } + } + fun prompt(id: String, dir: String, prompt: PromptDto) { val meta = if (log.isDebugEnabled) { "${ChatLogSummary.dir(dir)} ${ChatLogSummary.prompt(prompt)}" @@ -125,7 +168,8 @@ class KiloBackendChatManager( val raw = response.body?.string() log.warn("prompt_async failed: HTTP $code") raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async error=${ChatLogSummary.body(it)}" } } - throw RuntimeException("prompt_async failed: HTTP $code") + val detail = raw?.takeIf { it.isNotBlank() }?.let { ": ${ChatLogSummary.body(it)}" }.orEmpty() + throw RuntimeException("prompt_async failed: HTTP $code$detail") } log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async ok=true code=$code" } } @@ -137,6 +181,12 @@ class KiloBackendChatManager( } } + fun command(id: String, dir: String, command: String, args: String, prompt: PromptDto) { + log.info("${ChatLogSummary.sid(id)} kind=command command=$command args=${args.length} parts=${prompt.parts.size}") + val body = KiloCliDataParser.buildCommandJson(command, args, prompt) + post("/session/$id/command?directory=${encode(dir)}", body, "command", "${ChatLogSummary.sid(id)} kind=command command=$command") + } + // ------ abort ------ fun abort(id: String, dir: String) { @@ -213,6 +263,17 @@ class KiloBackendChatManager( } } + fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? { + return messages(id, dir) + .firstOrNull { it.info.id == message } + ?.parts + ?.firstOrNull { + if (it.type != "file") return@firstOrNull false + if (!key.isNullOrBlank()) attachmentKey(it.id, it.filename.orEmpty(), it.url.orEmpty()) == key + else it.id == part + } + } + // ------ config update ------ fun updateConfig(dir: String, update: ConfigUpdateDto) { @@ -317,6 +378,25 @@ class KiloBackendChatManager( private fun requireBase(): String = base ?: throw IllegalStateException("Chat manager not started") + private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (!cont.isCancelled) cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) { _, value, _ -> value.close() } + } + }) + } + private fun encode(value: String): String = java.net.URLEncoder.encode(value, "UTF-8") + + private fun attachmentKey(part: String, name: String, url: String): String { + val value = listOf(part, name, url).joinToString("\u0000") + val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return bytes.take(16).joinToString("") { "%02x".format(it) } + } } 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 d9a891cbfce..69c5dc1824a 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 @@ -10,6 +10,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -44,6 +45,7 @@ data class SseEvent(val type: String, val data: String) * * Uses two separate OkHttp clients mirroring the VS Code architecture: * - [apiClient]: no call/read timeout — used for the generated API client and SSE + * - app-load client: bounded timeout — used for startup REST calls * - [healthClient]: 3 s timeout — used only for `/global/health` polling * * The generated [DefaultApi] is configured with [apiClient] and exposed via [api] @@ -59,9 +61,23 @@ class KiloConnectionService( private val cs: CoroutineScope, private val server: CliServer, private val onReconnect: () -> Unit, - private val log: KiloLog = KiloLog.create(KiloConnectionService::class.java), + private val log: KiloLog, + private val appLoadTimeoutMs: Long, ) { + constructor( + cs: CoroutineScope, + server: CliServer, + onReconnect: () -> Unit, + ) : this(cs, server, onReconnect, KiloLog.create(KiloConnectionService::class.java), 30_000L) + + constructor( + cs: CoroutineScope, + server: CliServer, + onReconnect: () -> Unit, + log: KiloLog, + ) : this(cs, server, onReconnect, log, 30_000L) + companion object { private const val HEARTBEAT_TIMEOUT_MS = 15_000L private const val HEALTH_POLL_INTERVAL_MS = 10_000L @@ -73,6 +89,11 @@ class KiloConnectionService( private val _events = MutableSharedFlow(extraBufferCapacity = 64) val events: SharedFlow = _events.asSharedFlow() + private val queue = Channel(Channel.UNLIMITED) + private val lock = Any() + private val eventJob = cs.launch { + for (event in queue) _events.emit(event) + } /** Generated API client — null when disconnected. */ var api: DefaultApi? = null @@ -81,6 +102,9 @@ class KiloConnectionService( /** OkHttp client used for API calls — no call/read timeout. Null when disconnected. */ var apiClient: OkHttpClient? = null private set + var appLoadApi: DefaultApi? = null + private set + private var appLoadClient: OkHttpClient? = null private var healthClient: OkHttpClient? = null /** Port the CLI server is listening on. Zero when disconnected. */ var port = 0 @@ -175,12 +199,15 @@ class KiloConnectionService( // Create dual OkHttp clients (bundled — no IntelliJ platform deps) val ac = KiloBackendHttpClients.api(password) + val lc = KiloBackendHttpClients.appLoad(password, appLoadTimeoutMs) val hc = KiloBackendHttpClients.health(password) apiClient = ac + appLoadClient = lc healthClient = hc // Configure generated API client with the no-timeout api client api = DefaultApi(basePath = "http://127.0.0.1:$port", client = ac) + appLoadApi = DefaultApi(basePath = "http://127.0.0.1:$port", client = lc) startSse() startHeartbeatWatcher() @@ -212,24 +239,34 @@ class KiloConnectionService( private val listener = object : EventSourceListener() { override fun onOpen(src: EventSource, response: Response) { + if (source.get() !== src) return log.info("SSE: connected") setState(ConnectionState.Connected(port, password)) lastEvent.set(System.currentTimeMillis()) } override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { - lastEvent.set(System.currentTimeMillis()) - val kind = type ?: KiloCliDataParser.extractEventType(data) - log.debug { "evt=$kind bytes=${data.length} hasId=${id != null} ${ChatLogSummary.body(data)}" } - cs.launch { _events.emit(SseEvent(type = kind, data = data)) } + if (source.get() !== src) return + synchronized(lock) { + if (disposed) return@synchronized + lastEvent.set(System.currentTimeMillis()) + val kind = type ?: KiloCliDataParser.extractEventType(data) + log.debug { "evt=$kind bytes=${data.length} hasId=${id != null} ${ChatLogSummary.body(data)}" } + val result = queue.trySend(SseEvent(type = kind, data = data)) + if (result.isFailure && !disposed) { + log.warn("SSE: event queue rejected type=$kind", result.exceptionOrNull()) + } + } } override fun onClosed(src: EventSource) { + if (source.get() !== src) return log.info("SSE: stream closed — scheduling reconnect") scheduleReconnect() } override fun onFailure(src: EventSource, t: Throwable?, response: Response?) { + if (source.get() !== src) return val detail = when { t != null -> t.stackTraceToString() response != null -> response.body?.string() @@ -327,8 +364,11 @@ class KiloConnectionService( private fun close() { api = null + appLoadApi = null apiClient?.let { KiloBackendHttpClients.shutdown(it) } apiClient = null + appLoadClient?.let { KiloBackendHttpClients.shutdown(it) } + appLoadClient = null healthClient?.let { KiloBackendHttpClients.shutdown(it) } healthClient = null } @@ -345,6 +385,8 @@ class KiloConnectionService( healthJob?.cancel() processJob?.cancel() reconnectJob?.cancel() + eventJob.cancel() + queue.close() close() _state.value = ConnectionState.Disconnected log.info("KiloConnectionService disposed") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt new file mode 100644 index 00000000000..7174290f0b5 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendMigrationManager.kt @@ -0,0 +1,48 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.migration.LegacyCleanupTargets +import ai.kilocode.backend.migration.LegacyCleanupReport +import ai.kilocode.backend.migration.LegacyMigrationBackend +import ai.kilocode.backend.migration.LegacyMigrationDetection +import ai.kilocode.backend.migration.LegacyMigrationEngine +import ai.kilocode.backend.migration.LegacyMigrationHttpBackend +import ai.kilocode.backend.migration.LegacyMigrationReport +import ai.kilocode.backend.migration.LegacyMigrationSelections +import ai.kilocode.backend.migration.LegacyMigrationSink +import ai.kilocode.backend.migration.LegacyMigrationStatus +import ai.kilocode.backend.migration.LegacyMigrationStore +import okhttp3.OkHttpClient + +/** + * Thin factory/wrapper that creates [LegacyMigrationEngine] instances using the active + * CLI connection. Does not auto-run migration and does not touch any UI. + * + * Instantiate when the CLI connection is ready (port + authenticated client available). + * The [store] is caller-supplied, allowing test and UI flows to provide different adapters. + */ +class KiloBackendMigrationManager( + private val client: OkHttpClient, + private val port: Int, +) { + private fun base() = "http://127.0.0.1:$port" + private fun httpBackend(): LegacyMigrationBackend = LegacyMigrationHttpBackend(client, base()) + + fun status(store: LegacyMigrationStore): LegacyMigrationStatus? = + LegacyMigrationEngine(store, httpBackend()).status() + + fun mark(store: LegacyMigrationStore, status: LegacyMigrationStatus) = + LegacyMigrationEngine(store, httpBackend()).mark(status) + + fun detect(store: LegacyMigrationStore): LegacyMigrationDetection = + LegacyMigrationEngine(store, httpBackend()).detect() + + fun migrate( + store: LegacyMigrationStore, + selections: LegacyMigrationSelections, + sink: LegacyMigrationSink = LegacyMigrationSink.None, + ): LegacyMigrationReport = + LegacyMigrationEngine(store, httpBackend()).migrate(selections, sink) + + fun cleanup(store: LegacyMigrationStore, targets: LegacyCleanupTargets): LegacyCleanupReport = + LegacyMigrationEngine(store, httpBackend()).cleanup(targets) +} 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 dd649bfd1ec..e124accafc4 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 @@ -67,8 +67,16 @@ class KiloBackendSessionManager( if (event.type == "session.status") { val pair = KiloCliDataParser.parseSessionStatus(event.data) if (pair != null) { + val prev = _statuses.value[pair.first] _statuses.update { it + pair } + val total = _statuses.value.size log.debug { "${ChatLogSummary.sid(pair.first)} evt=session.status ${ChatLogSummary.status(pair.second)}" } + if (pair.second.type != "busy") { + log.info( + "${ChatLogSummary.sid(pair.first)} kind=status route=session-map " + + "${ChatLogSummary.status(pair.second)} prev=${prev?.type ?: "none"} total=$total bytes=${event.data.length}", + ) + } } } } @@ -321,4 +329,5 @@ class KiloBackendSessionManager( } private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + private fun Double.safeInt() = toLong().safeInt() } 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 ab25a7edf4d..fffa73461d0 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 @@ -1,10 +1,10 @@ package ai.kilocode.backend.cli +import ai.kilocode.KiloPlugin +import ai.kilocode.backend.dev.KiloDevMode import ai.kilocode.log.KiloLog -import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager -import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch import kotlinx.coroutines.Dispatchers @@ -40,7 +40,10 @@ class KiloBackendCliManager( @Volatile private var process: Process? = null + @Volatile + private var closing: Process? = null private var hook: Thread? = null + private var stderr: Thread? = null @Volatile override var forceExtract = false @@ -59,8 +62,7 @@ class KiloBackendCliManager( process?.let { proc -> log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})") process = null - uninstall() - kill(proc, "startup failure cleanup") + cleanup(proc, "startup failure cleanup") } CliServer.State.Error( message = e.message ?: "Unknown error", @@ -73,39 +75,45 @@ class KiloBackendCliManager( if (process != proc) return process = null uninstall() + stderr = null } override fun stop() { val proc = process ?: return process = null - uninstall() - kill(proc, "stop()") + cleanup(proc, "stop()") } private fun extractCli(): File { val platform = platform() val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo" - val resource = "cli/$platform/$exe" - val loader = javaClass.classLoader - val target = File(PathManager.getSystemPath(), "kilo/bin/$exe") - if (forceExtract && target.exists()) { - log.info("Force re-extracting CLI binary — deleting ${target.absolutePath}") - target.delete() + if (forceExtract) { + log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}") + if (target.exists()) target.delete() forceExtract = false } + extractResource("cli/$platform/$exe", target, executable = true) + return target + } + + private fun extractResource(resource: String, target: File, executable: Boolean) { + val loader = javaClass.classLoader val url = loader.getResource(resource) - ?: throw IllegalStateException("CLI binary not found in JAR resources at $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 binary up-to-date at ${target.absolutePath}") - return target + log.info("CLI resource up-to-date at ${target.absolutePath}") + if (executable && !SystemInfo.isWindows) { + target.setExecutable(true) + } + return } - log.info("Extracting CLI binary to ${target.absolutePath}") + log.info("Extracting CLI resource to ${target.absolutePath}") target.parentFile.mkdirs() url.openStream().use { input -> @@ -114,53 +122,14 @@ class KiloBackendCliManager( } } - if (!SystemInfo.isWindows) { + if (executable && !SystemInfo.isWindows) { target.setExecutable(true) } - - return target } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). - internal fun buildEnv(pwd: String, base: Map = System.getenv()): Map = buildMap { - putAll(base) - put("KILO_SERVER_PASSWORD", pwd) - put("KILO_CLIENT", "jetbrains") - put("KILO_ENABLE_QUESTION_TOOL", "true") - put("KILO_PLATFORM", "jetbrains") - put("KILO_APP_NAME", "kilo-code") - put("KILO_DISABLE_CLAUDE_CODE", "true") - put("KILOCODE_FEATURE", "jetbrains-plugin") - ideEnv().forEach { (k, v) -> put(k, v) } - devStorageEnv()?.forEach { (k, v) -> put(k, v) } - } - - private fun devStorageEnv(): Map? { - val enabled = System.getProperty("kilo.dev.storage.isolated", "false").toBoolean() - if (!enabled) return null - val root = System.getProperty("kilo.dev.worktree.root") ?: run { - log.warn("kilo.dev.storage.isolated=true but kilo.dev.worktree.root is not set; skipping dev storage isolation") - return null - } - val dev = File(root, ".kilo-dev") - val data = File(dev, "data") - val config = File(dev, "config") - val state = File(dev, "state") - val cache = File(dev, "cache") - for (dir in listOf(data, config, state, cache)) { - if (!dir.mkdirs() && !dir.isDirectory) { - log.warn("Failed to create dev storage dir ${dir.absolutePath}; skipping dev storage isolation") - return null - } - } - log.info("Dev storage isolation enabled under ${dev.absolutePath}") - return mapOf( - "XDG_DATA_HOME" to data.absolutePath, - "XDG_CONFIG_HOME" to config.absolutePath, - "XDG_STATE_HOME" to state.absolutePath, - "XDG_CACHE_HOME" to cache.absolutePath, - ) - } + internal fun buildEnv(pwd: String, base: Map = System.getenv()): Map = + buildKiloCliEnv(pwd, base, log) private suspend fun spawn(cli: File): CliServer.State = withContext(Dispatchers.IO) { @@ -176,21 +145,31 @@ class KiloBackendCliManager( log.info("Starting CLI: ${cmd.joinToString(" ")}") log.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code") - val proc = builder.start() + val proc = try { + builder.start() + } catch (e: Exception) { + log.warn("CLI process failed to start: ${e.message}", e) + throw e + } log.info("CLI process started (pid=${proc.pid()})") process = proc install(proc) val stderr = StringBuilder() - Thread({ - BufferedReader(InputStreamReader(proc.errorStream)).use { reader -> - reader.lineSequence().forEach { line -> - log.warn("CLI stderr: $line") - synchronized(stderr) { stderr.appendLine(line) } + val err = Thread({ + runCatching { + BufferedReader(InputStreamReader(proc.errorStream)).use { reader -> + reader.lineSequence().forEach { line -> + log.warn("CLI stderr: $line") + synchronized(stderr) { stderr.appendLine(line) } + } } + }.onFailure { err -> + if (proc.isAlive && closing !== proc) log.warn("CLI stderr reader failed", err) } }, "kilo-cli-stderr").apply { isDaemon = true; start() } + this@KiloBackendCliManager.stderr = err BufferedReader(InputStreamReader(proc.inputStream)).use { reader -> for (line in reader.lineSequence()) { @@ -210,6 +189,8 @@ class KiloBackendCliManager( val details = synchronized(stderr) { stderr.toString().trim() } process = null uninstall() + this@KiloBackendCliManager.stderr = null + log.warn("CLI process exited with code $code before announcing a port: $details") CliServer.State.Error( message = "CLI process exited with code $code before announcing a port", details = details.ifEmpty { null }, @@ -219,8 +200,23 @@ class KiloBackendCliManager( override fun dispose() { val proc = process ?: return process = null - uninstall() - kill(proc, "Disposing") + cleanup(proc, "Disposing") + } + + private fun cleanup(proc: Process, source: String) { + closing = proc + try { + uninstall() + close(proc) + kill(proc, source) + val thread = stderr + stderr = null + if (thread != null && thread != Thread.currentThread()) { + thread.join(TimeUnit.SECONDS.toMillis(1)) + } + } finally { + closing = null + } } private fun install(proc: Process) { @@ -261,6 +257,12 @@ class KiloBackendCliManager( private fun children(proc: Process): List = proc.toHandle().descendants().toList().asReversed() + private fun close(proc: Process) { + runCatching { proc.errorStream.close() }.onFailure { log.info("CLI stderr stream close skipped: ${it.message}") } + runCatching { proc.inputStream.close() }.onFailure { log.info("CLI stdout stream close skipped: ${it.message}") } + runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") } + } + private fun platform(): String { val os = when { SystemInfo.isMac -> "darwin" @@ -276,38 +278,86 @@ class KiloBackendCliManager( return "$os-$arch" } - private fun ideEnv(): Map = buildMap { - runCatching { - val info = ApplicationInfo.getInstance() - val name = info.fullApplicationName - val build = info.build.asString() - put("KILO_EDITOR_NAME", name) - put("KILOCODE_EDITOR_NAME", "$name $build") - }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } - - runCatching { - val version = PluginManagerCore - .getPlugin(PluginId.getId("ai.kilocode"))?.version - if (version != null) put("KILO_APP_VERSION", version) - }.onFailure { log.info("Could not read plugin version: ${it.message}") } - - runCatching { - put("KILO_MACHINE_ID", machineId()) - }.onFailure { log.info("Could not read machine ID: ${it.message}") } - } - - private fun machineId(): String { - val file = File(PathManager.getSystemPath(), "kilo/machine-id") - if (file.exists()) return file.readText().trim() - val id = UUID.randomUUID().toString() - file.parentFile.mkdirs() - file.writeText(id) - return id - } - private fun generatePassword(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) return bytes.joinToString("") { "%02x".format(it) } } } + +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(), + log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java), +): Map = buildMap { + putAll(base) + put("KILO_SERVER_PASSWORD", pwd) + put("KILO_CLIENT", "jetbrains") + put("KILO_ENABLE_QUESTION_TOOL", "true") + put("KILO_PLATFORM", "jetbrains") + put("KILO_APP_NAME", "kilo-code") + put("KILO_TELEMETRY_LEVEL", if (KiloDevMode.enabled()) "off" else "all") + put("KILO_DISABLE_CLAUDE_CODE", "true") + put("KILOCODE_FEATURE", "jetbrains-plugin") + putIfAbsent("KILO_CONFIG_CONTENT", DEFAULT_CONFIG) + ideEnv(log).forEach { entry -> put(entry.key, entry.value) } + devStorageEnv(log)?.forEach { entry -> put(entry.key, entry.value) } +} + +private fun ideEnv(log: KiloLog): Map = buildMap { + runCatching { + val info = ApplicationInfo.getInstance() + val name = info.fullApplicationName + val build = info.build.asString() + put("KILO_EDITOR_NAME", name) + put("KILOCODE_EDITOR_NAME", "$name $build") + }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } + + runCatching { + val version = KiloPlugin.version() + if (version != null) put("KILO_APP_VERSION", version) + }.onFailure { log.info("Could not read plugin version: ${it.message}") } + + runCatching { + put("KILO_MACHINE_ID", machineId()) + }.onFailure { log.info("Could not read machine ID: ${it.message}") } +} + +private fun machineId(): String { + val file = File(PathManager.getSystemPath(), "kilo/machine-id") + if (file.exists()) return file.readText().trim() + val id = UUID.randomUUID().toString() + file.parentFile.mkdirs() + file.writeText(id) + return id +} + +private fun devStorageEnv(log: KiloLog): Map? { + val enabled = System.getProperty("kilo.dev.storage.isolated", "false").toBoolean() + if (!enabled) return null + val root = System.getProperty("kilo.dev.worktree.root") ?: run { + log.warn("kilo.dev.storage.isolated=true but kilo.dev.worktree.root is not set; skipping dev storage isolation") + return null + } + val dev = File(root, ".kilo-dev") + val data = File(dev, "data") + val config = File(dev, "config") + val state = File(dev, "state") + val cache = File(dev, "cache") + for (dir in listOf(data, config, state, cache)) { + if (!dir.mkdirs() && !dir.isDirectory) { + log.warn("Failed to create dev storage dir ${dir.absolutePath}; skipping dev storage isolation") + return null + } + } + log.info("Dev storage isolation enabled under ${dev.absolutePath}") + return mapOf( + "XDG_DATA_HOME" to data.absolutePath, + "XDG_CONFIG_HOME" to config.absolutePath, + "XDG_STATE_HOME" to state.absolutePath, + "XDG_CACHE_HOME" to cache.absolutePath, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt index 3e71cf2bf33..b305c8f4a27 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt @@ -7,10 +7,11 @@ import java.util.Base64 import java.util.concurrent.TimeUnit /** - * Factory for the two OkHttp clients used by the plugin. + * Factory for the OkHttp clients used by the plugin. * * Mirrors the VS Code architecture: * - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long) + * - [appLoad] client has a bounded timeout for startup REST calls * - [health] client has a short 3 s timeout and a small dedicated connection pool * * Both clients bundle Basic Auth via an interceptor and are fully independent @@ -30,6 +31,18 @@ object KiloBackendHttpClients { .readTimeout(0, TimeUnit.MILLISECONDS) .build() + /** App-load client — bounded timeout for required startup REST calls. */ + fun appLoad(password: String, timeoutMs: Long): OkHttpClient { + val timeout = timeoutMs.coerceAtLeast(1L) + return OkHttpClient.Builder() + .addInterceptor(auth(password)) + .connectTimeout(CONNECT_TIMEOUT_MS.coerceAtMost(timeout), TimeUnit.MILLISECONDS) + .callTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .connectionPool(ConnectionPool(2, 30, TimeUnit.SECONDS)) + .build() + } + /** Health client — short timeout, dedicated connection pool. */ fun health(password: String): OkHttpClient = OkHttpClient.Builder() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt new file mode 100644 index 00000000000..69e7cd137aa --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliConfigPath.kt @@ -0,0 +1,23 @@ +package ai.kilocode.backend.cli + +import com.intellij.openapi.util.SystemInfo +import java.io.File + +internal object KiloCliConfigPath { + fun resolve(env: Map): File { + env["KILO_CONFIG_DIR"]?.takeIf { it.isNotBlank() }?.let { return File(it) } + env["XDG_CONFIG_HOME"]?.takeIf { it.isNotBlank() }?.let { return File(it, "kilo") } + return File(defaultRoot(), "kilo") + } + + fun legacySettingsFile(env: Map): File = File(resolve(env), "legacy-settings.json") + + private fun defaultRoot(): File { + if (SystemInfo.isWindows) { + val app = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + if (app != null) return File(app) + } + if (SystemInfo.isMac) return File(System.getProperty("user.home"), "Library/Application Support") + return File(System.getProperty("user.home"), ".config") + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index c4a920cc04a..f6140172aba 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -8,21 +8,35 @@ import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.CustomModelDto +import ai.kilocode.rpc.dto.CustomProviderConfigDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionFileDiffDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.ProviderAuthMethodDto +import ai.kilocode.rpc.dto.ProviderAuthOptionDto +import ai.kilocode.rpc.dto.ProviderAuthPromptDto +import ai.kilocode.rpc.dto.ProviderMetadataDto +import ai.kilocode.rpc.dto.ProviderSettingsProviderDto import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionReplyDto @@ -32,6 +46,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionSummaryDto import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import ai.kilocode.rpc.dto.ToolRefDto import kotlinx.serialization.json.Json @@ -41,12 +56,15 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put import java.util.concurrent.ConcurrentHashMap /** @@ -66,6 +84,8 @@ object KiloCliDataParser { private val json = Json { ignoreUnknownKeys = true } private val pretty = Json { ignoreUnknownKeys = true; prettyPrint = true } private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""") + private val READ_TOOL_LINE = Regex("^\\s*Called\\s+the\\s+Read\\s+tool\\s+with\\s+the\\s+following\\s+input:", RegexOption.IGNORE_CASE) + private val READ_TOOL_PATH = Regex("\"(?:filePath|path)\"\\s*:") private val FIELD_RE = ConcurrentHashMap() // ================================================================ @@ -132,6 +152,13 @@ object KiloCliDataParser { ChatEventDto.TurnClose(sid, reason) } + "session.created" -> { + val info = props["info"]?.jsonObject ?: return null + val dto = parseSessionObject(info) + val sid = props.str("sessionID") ?: dto.id.takeIf { it.isNotBlank() } ?: return null + ChatEventDto.SessionCreated(sid, dto) + } + "session.error" -> { val sid = props.str("sessionID") val err = props["error"]?.jsonObject?.let { parseError(it) } @@ -216,14 +243,7 @@ object KiloCliDataParser { "todo.updated" -> { val sid = props.str("sessionID") ?: return null - val todos = props["todos"]?.jsonArray?.map { elem -> - val t = elem.jsonObject - TodoDto( - content = t.str("content") ?: "", - status = t.str("status") ?: "pending", - priority = t.str("priority") ?: "medium", - ) - } ?: emptyList() + val todos = parseTodos(props["todos"]) ChatEventDto.TodoUpdated(sid, todos) } @@ -231,6 +251,88 @@ object KiloCliDataParser { } } + class ChatEventNormalizer { + private val roles = mutableMapOf() + private val raw = mutableMapOf() + private val text = mutableMapOf() + + fun parse(type: String, data: String): List? { + val event = parseChatEvent(type, data) ?: return null + return when (event) { + is ChatEventDto.MessageUpdated -> { + roles[event.info.id] = event.info.role + listOf(event) + } + + is ChatEventDto.MessageRemoved -> { + roles.remove(event.messageID) + clear(event.messageID) + listOf(event) + } + + is ChatEventDto.PartUpdated -> listOf(update(event)) + + is ChatEventDto.PartDelta -> delta(event) + + is ChatEventDto.PartRemoved -> { + val key = Key(event.messageID, event.partID) + raw.remove(key) + text.remove(key) + listOf(event) + } + + else -> listOf(event) + } + } + + private fun update(event: ChatEventDto.PartUpdated): ChatEventDto { + val part = event.part + val key = Key(part.messageID, part.id) + if (roles[part.messageID] != "user" || part.type != "text") { + raw.remove(key) + text.remove(key) + return event + } + + val value = part.text.orEmpty() + val clean = sanitizeUserPromptText(value) + raw[key] = value + text[key] = clean + return event.copy(part = part.copy(text = clean)) + } + + private fun delta(event: ChatEventDto.PartDelta): List { + if (event.field != "text" || roles[event.messageID] != "user") return listOf(event) + + val key = Key(event.messageID, event.partID) + val prev = text[key].orEmpty() + val next = raw[key].orEmpty() + event.delta + val clean = sanitizeUserPromptText(next) + raw[key] = next + text[key] = clean + + if (clean == prev) return emptyList() + if (clean.startsWith(prev)) return listOf(event.copy(delta = clean.removePrefix(prev))) + return listOf(ChatEventDto.PartUpdated( + sessionID = event.sessionID, + part = PartDto( + id = event.partID, + sessionID = event.sessionID, + messageID = event.messageID, + type = "text", + text = clean, + ), + )) + } + + private fun clear(id: String) { + raw.keys.filter { it.messageID == id }.forEach(raw::remove) + text.keys.filter { it.messageID == id }.forEach(text::remove) + } + + private data class Key(val messageID: String, val partID: String) + } + /** * Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair. * Returns null if the required fields are missing. @@ -265,14 +367,36 @@ object KiloCliDataParser { return arr.mapNotNull { elem -> val obj = elem.jsonObject val info = obj["info"]?.jsonObject ?: return@mapNotNull null + val msg = parseMessage(info) val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList()) MessageWithPartsDto( - info = parseMessage(info), - parts = parts.map { parsePart(it.jsonObject) }, + info = msg, + parts = parts.map { sanitizePart(parsePart(it.jsonObject), msg.role) }, ) } } + internal fun sanitizeUserPromptText(text: String): String { + val lines = text.lines() + if (lines.none(::readPayload)) return text + + val out = mutableListOf() + var gap = false + for (line in lines) { + if (readPayload(line)) { + gap = true + continue + } + if (line.isBlank() && out.lastOrNull()?.isBlank() == true && gap) { + gap = false + continue + } + out.add(line) + if (line.isNotBlank()) gap = false + } + return out.joinToString("\n") + } + fun parseCloudSessions(raw: String): CloudSessionListDto { val obj = tryParseObject(raw) ?: return CloudSessionListDto(emptyList()) val items = obj["cliSessions"]?.jsonArray ?: JsonArray(emptyList()) @@ -306,6 +430,60 @@ object KiloCliDataParser { ) } + fun parseProviderSettingsProviders(raw: String): Triple, List, Map> { + val obj = json.parseToJsonElement(raw).jsonObject + val all = obj["all"]?.jsonArray?.map { elem -> + val item = elem.jsonObject + ProviderSettingsProviderDto( + id = item.str("id") ?: "", + name = item.str("name") ?: item.str("id") ?: "", + description = item.str("description"), + source = item.str("source"), + key = item.str("key"), + metadata = parseProviderMetadata(item["metadata"].obj()), + models = item["models"]?.jsonObject?.mapValues { (id, v) -> parseModelDto(id, v.jsonObject) } ?: emptyMap(), + ) + } ?: emptyList() + val connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val defaults = obj["default"]?.jsonObject?.mapValues { (_, v) -> v.jsonPrimitive.content } ?: emptyMap() + return Triple(all, connected, defaults) + } + + fun parseProviderAuth(raw: String): Map> { + val root = tryParseObject(raw) ?: return emptyMap() + return root.entries.associate { (id, elem) -> + val arr = runCatching { elem.jsonArray }.getOrNull() + val methods = arr?.mapNotNull { parseAuthMethod(runCatching { it.jsonObject }.getOrNull()) } ?: emptyList() + id to methods + } + } + + fun parseProviderConfig(raw: String): Pair, Pair, List>> { + val obj = tryParseObject(raw) ?: return emptyMap() to (emptyList() to emptyList()) + val cfg = obj["provider"]?.jsonObject?.entries?.mapNotNull { (id, elem) -> + val item = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + id to CustomProviderConfigDto( + id = id, + name = item.str("name"), + npm = item.str("npm"), + env = item["env"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + options = item["options"].obj()?.entries?.mapNotNull { (key, value) -> value.scalar()?.let { key to it } }?.toMap() ?: emptyMap(), + headers = item["headers"].obj()?.entries?.mapNotNull { (key, value) -> value.scalar()?.let { key to it } }?.toMap() ?: emptyMap(), + models = item["models"].obj()?.entries?.mapNotNull { (mid, value) -> + val model = runCatching { value.jsonObject }.getOrNull() ?: return@mapNotNull null + mid to CustomModelDto( + id = model.str("id") ?: mid, + name = model.str("name") ?: mid, + reasoning = model["capabilities"].obj()?.bool("reasoning") ?: false, + ) + }?.toMap() ?: emptyMap(), + ) + }?.toMap() ?: emptyMap() + val disabled = obj["disabled_providers"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val enabled = obj["enabled_providers"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + return cfg to (disabled to enabled) + } + /** * Parse a command list response (`GET /command`) into a list of [CommandInfo]. * The `template` field is intentionally ignored — CLI commands can return lazy @@ -363,12 +541,19 @@ object KiloCliDataParser { // JSON serialization (DTO → JSON for outgoing requests) // ================================================================ + fun parseEnhancedPrompt(raw: String): String = + tryParseObject(raw)?.str("text") + ?: throw IllegalArgumentException("Enhance prompt response is missing text") + + fun buildEnhancePromptJson(text: String): String = + """{"text":${escape(text)}}""" + /** * Build the JSON body for `POST /session/{id}/prompt_async`. */ fun buildPromptJson(prompt: PromptDto): String { val parts = prompt.parts.joinToString(",") { part -> - """{"type":"${part.type}","text":${escape(part.text)}}""" + buildPromptPartJson(part) } val sb = StringBuilder() sb.append("""{"parts":[$parts]""") @@ -397,12 +582,43 @@ object KiloCliDataParser { return sb.toString() } + private fun buildPromptPartJson(part: PromptPartDto): String { + val fields = mutableListOf("\"type\":${escape(part.type)}") + if (part.type == "file") { + part.mime?.let { fields += "\"mime\":${escape(it)}" } + part.url?.let { fields += "\"url\":${escape(it)}" } + part.filename?.let { fields += "\"filename\":${escape(it)}" } + part.source?.let { fields += "\"source\":${sourceJson(it)}" } + return "{${fields.joinToString(",")}}" + } + fields += "\"text\":${escape(part.text.orEmpty())}" + return "{${fields.joinToString(",")}}" + } + /** * Build the JSON body for `POST /session/{id}/summarize`. */ fun buildSummarizeJson(model: ModelSelectionDto): String = """{"providerID":${escape(model.providerID)},"modelID":${escape(model.modelID)}}""" + fun buildCommandJson(command: String, args: String, prompt: PromptDto): String { + val fields = mutableListOf( + "\"command\":${escape(command)}", + "\"arguments\":${escape(args)}", + ) + prompt.agent?.let { fields += "\"agent\":${escape(it)}" } + prompt.variant?.let { fields += "\"variant\":${escape(it)}" } + val pid = prompt.providerID + val mid = prompt.modelID + if (pid != null && mid != null) { + val model = "$pid/$mid" + fields += "\"model\":${escape(model)}" + } + val parts = prompt.parts.filter { it.type == "file" }.joinToString(",") { buildPromptPartJson(it) } + if (parts.isNotEmpty()) fields += "\"parts\":[$parts]" + return "{${fields.joinToString(",")}}" + } + /** * Build the partial JSON body for `PATCH /global/config`. */ @@ -428,6 +644,105 @@ object KiloCliDataParser { return sb.toString() } + fun buildConfigPatch(patch: ConfigPatchDto): String { + val allowed = setOf("model", "small_model", "subagent_model", "subagent_variant") + val sb = StringBuilder("{") + var first = true + fun sep() { if (!first) sb.append(","); first = false } + fun value(value: String?) = value?.let(::escape) ?: "null" + + for ((key, value) in patch.values) { + if (key !in allowed) continue + sep(); sb.append("\"$key\":${value(value)}") + } + + if (patch.agents.isNotEmpty()) { + sep(); sb.append("\"agent\":{") + patch.agents.entries.forEachIndexed { idx, (name, agent) -> + if (idx > 0) sb.append(",") + sb.append("${escape(name)}:{\"model\":${value(agent.model)}}") + } + sb.append("}") + } + + sb.append("}") + return sb.toString() + } + + fun buildProviderAuthJson(key: String, metadata: Map): String { + val obj = buildJsonObject { + put("type", "api") + put("key", key) + if (metadata.isNotEmpty()) { + put("metadata", buildJsonObject { metadata.forEach { (k, v) -> put(k, v) } }) + } + } + return json.encodeToString(JsonObject.serializer(), obj) + } + + fun buildProviderOAuthJson(method: String, inputs: Map = emptyMap(), code: String? = null): String { + val obj = buildJsonObject { + val index = method.toLongOrNull() + if (index != null) { + put("method", index) + } else { + put("method", method) + } + if (inputs.isNotEmpty()) put("inputs", buildJsonObject { inputs.forEach { (k, v) -> put(k, v) } }) + if (code != null) put("code", code) + } + return json.encodeToString(JsonObject.serializer(), obj) + } + + fun buildDisabledProviderPatch(ids: List): String { + val arr = JsonArray(ids.distinct().sorted().map { JsonPrimitive(it) }) + return json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("disabled_providers" to arr))) + } + + fun buildCustomProviderPatch(input: CustomProviderSaveDto): String { + val id = input.id.trim() + val env = input.envVar?.trim()?.takeIf { it.isNotBlank() } + val models = input.models.associate { model -> + model.id to buildJsonObject { + put("id", model.id) + put("name", model.name.ifBlank { model.id }) + put("capabilities", buildJsonObject { put("reasoning", model.reasoning) }) + } + } + val provider = buildJsonObject { + put("name", input.name.trim().ifBlank { id }) + put("npm", "@ai-sdk/openai-compatible") + put("options", buildJsonObject { put("baseURL", input.baseUrl.trim()) }) + if (env != null) put("env", buildJsonArray { add(JsonPrimitive(env)) }) + if (input.headers.isNotEmpty()) put("headers", buildJsonObject { input.headers.forEach { (k, v) -> put(k, v) } }) + if (models.isNotEmpty()) put("models", JsonObject(models)) + } + val root = buildJsonObject { + put("provider", buildJsonObject { put(id, provider) }) + } + return json.encodeToString(JsonObject.serializer(), root) + } + + fun buildCustomProviderDeletePatch(id: String): String { + val root = buildJsonObject { + put("provider", buildJsonObject { put(id, JsonNull) }) + } + return json.encodeToString(JsonObject.serializer(), root) + } + + fun parseOAuthReady(raw: String): Triple { + val obj = tryParseObject(raw) ?: return Triple(null, "auto", null) + return Triple(obj.str("url"), obj.str("method") ?: "auto", obj.str("instructions")) + } + + fun parseModelIds(raw: String): List { + val obj = tryParseObject(raw) ?: return emptyList() + return obj["data"]?.jsonArray?.mapNotNull { elem -> + val item = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + item.str("id") + }?.distinct()?.sorted() ?: emptyList() + } + // ================================================================ // Internal — message/part/session parsing // ================================================================ @@ -460,12 +775,26 @@ object KiloCliDataParser { val tokens = obj["tokens"]?.jsonObject val top = obj.map("metadata") val meta = state.map("metadata") + top + val input = state?.get("input").obj() + val stateMeta = state?.get("metadata").obj() + val topMeta = obj["metadata"].obj() + val todos = sequenceOf(topMeta?.get("todos"), stateMeta?.get("todos"), input?.get("todos")) + .firstNotNullOfOrNull(::parseTodosOrNull) + ?: emptyList() + val view = sequenceOf(topMeta?.get("view"), stateMeta?.get("view")) + .mapNotNull(::parseTodoView) + .firstOrNull() return PartDto( id = obj.str("id") ?: "", sessionID = obj.str("sessionID") ?: "", messageID = obj.str("messageID") ?: "", type = obj.str("type") ?: "unknown", text = obj.str("text"), + mime = obj.str("mime"), + url = obj.str("url"), + filename = obj.str("filename"), + synthetic = obj.flagOrNull("synthetic"), + source = parseSource(obj["source"]), tool = obj.str("tool"), callID = obj.str("callID"), state = state?.str("status"), @@ -475,12 +804,87 @@ object KiloCliDataParser { output = state?.str("output"), error = state?.str("error"), time = obj.time("time") ?: state.time("time"), + todos = todos, + todoView = view, reason = obj.str("reason"), cost = obj.num("cost"), tokens = tokens?.let(::parseTokens), ) } + private fun sanitizePart(part: PartDto, role: String): PartDto { + if (role != "user" || part.type != "text") return part + return part.copy(text = part.text?.let(::sanitizeUserPromptText)) + } + + private fun readPayload(line: String): Boolean { + if (!READ_TOOL_LINE.containsMatchIn(line)) return false + return READ_TOOL_PATH.containsMatchIn(line) + } + + private fun parseSource(raw: JsonElement?): PartSourceDto? { + val obj = raw.obj() ?: return null + val type = obj.str("type") ?: return null + val text = obj["text"].obj() ?: return null + val value = text.str("value") ?: return null + val start = text.num("start") ?: return null + val end = text.num("end") ?: return null + return PartSourceDto( + type = type, + text = PartSourceTextDto(value = value, start = start, end = end), + path = obj.str("path"), + clientName = obj.str("clientName"), + uri = obj.str("uri"), + name = obj.str("name"), + kind = obj.long("kind")?.safeInt(), + ) + } + + private fun sourceJson(source: PartSourceDto): String { + val fields = mutableListOf( + "\"type\":${escape(source.type)}", + "\"text\":{\"value\":${escape(source.text.value)},\"start\":${source.text.start},\"end\":${source.text.end}}", + ) + source.path?.let { fields += "\"path\":${escape(it)}" } + source.clientName?.let { fields += "\"clientName\":${escape(it)}" } + source.uri?.let { fields += "\"uri\":${escape(it)}" } + source.name?.let { fields += "\"name\":${escape(it)}" } + source.kind?.let { fields += "\"kind\":$it" } + return "{${fields.joinToString(",")}}" + } + + internal fun parseTodos(raw: JsonElement?): List { + return parseTodosOrNull(raw) ?: emptyList() + } + + private fun parseTodosOrNull(raw: JsonElement?): List? { + val arr = runCatching { raw?.jsonArray }.getOrNull() ?: return null + return arr.mapNotNull { elem -> + val obj = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + parseTodo(obj) + } + } + + private fun parseTodo(obj: JsonObject) = TodoDto( + content = obj.str("content") ?: "", + status = obj.str("status") ?: "pending", + priority = obj.str("priority") ?: "medium", + changed = obj.flag("changed", false), + ) + + internal fun parseTodoView(raw: JsonElement?): TodoViewDto? { + val obj = runCatching { raw?.jsonObject }.getOrNull() ?: return null + val rawTodos = runCatching { obj["todos"]?.jsonArray }.getOrNull() ?: return null + val todos = parseTodos(rawTodos) + return TodoViewDto( + mode = obj.str("mode") ?: "full", + todos = todos, + hiddenBefore = obj.long("hiddenBefore")?.safeInt() ?: 0, + hiddenAfter = obj.long("hiddenAfter")?.safeInt() ?: 0, + changed = obj.long("changed")?.safeInt() ?: 0, + ) + } + private fun parseTokens(obj: JsonObject): TokensDto { val cache = obj["cache"]?.jsonObject return TokensDto( @@ -542,18 +946,26 @@ object KiloCliDataParser { val qo = q.jsonObject val options = qo["options"]?.jsonArray?.map { o -> val oo = o.jsonObject - QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "") + QuestionOptionDto( + label = oo.str("label") ?: "", + description = oo.str("description") ?: "", + labelKey = oo.str("labelKey"), + descriptionKey = oo.str("descriptionKey"), + mode = oo.str("mode"), + ) } ?: emptyList() QuestionInfoDto( question = qo.str("question") ?: "", header = qo.str("header") ?: "", options = options, - multiple = qo.str("multiple") == "true", - custom = qo.str("custom") != "false", + multiple = qo.flag("multiple", false), + custom = qo.flag("custom", true), + questionKey = qo.str("questionKey"), + headerKey = qo.str("headerKey"), ) } ?: emptyList() val ref = toolRef(obj) - return QuestionRequestDto(id, sid, questions, ref) + return QuestionRequestDto(id, sid, questions, ref, blocking = obj.flag("blocking", false)) } internal fun parseModelFavorites(raw: JsonElement?): List { @@ -609,6 +1021,36 @@ object KiloCliDataParser { models = obj["models"]?.jsonObject?.mapValues { (id, v) -> parseModel(id, v.jsonObject) } ?: emptyMap(), ) + private fun parseProviderMetadata(obj: JsonObject?): ProviderMetadataDto? { + if (obj == null) return null + val dto = ProviderMetadataDto( + noteKey = obj.str("noteKey"), + icon = obj.str("icon"), + priority = obj.num("priority")?.toInt(), + ) + if (dto.noteKey == null && dto.icon == null && dto.priority == null) return null + return dto + } + + private fun parseModelDto(id: String, obj: JsonObject): ModelDto { + val model = parseModel(id, obj) + return ModelDto( + id = model.id, + name = model.name, + attachment = model.attachment, + reasoning = model.reasoning, + temperature = model.temperature, + toolCall = model.toolCall, + free = model.free, + byok = model.byok, + status = model.status, + recommendedIndex = model.recommendedIndex, + variants = model.variants, + limit = model.limit?.let { ModelLimitDto(it.context, it.input, it.output) }, + mayTrainOnYourPrompts = model.mayTrainOnYourPrompts, + ) + } + private fun parseModel(id: String, obj: JsonObject): ModelInfo { val cap = obj["capabilities"]?.jsonObject val limit = obj["limit"]?.jsonObject @@ -620,6 +1062,7 @@ object KiloCliDataParser { temperature = cap.bool("temperature"), toolCall = cap.bool("toolcall"), free = obj.bool("isFree"), + byok = obj.bool("hasUserByokAvailable"), status = obj.str("status"), recommendedIndex = obj.num("recommendedIndex"), variants = parseVariants(obj), @@ -630,6 +1073,7 @@ object KiloCliDataParser { output = it.long("output") ?: 0, ) }, + mayTrainOnYourPrompts = obj.bool("mayTrainOnYourPrompts"), ) } @@ -638,6 +1082,35 @@ object KiloCliDataParser { return keys.sortedWith(compareBy { EFFORT_ORDER[it] ?: Int.MAX_VALUE }.thenBy { it }) } + private fun parseAuthMethod(obj: JsonObject?): ProviderAuthMethodDto? { + if (obj == null) return null + val type = obj.str("type") ?: return null + val prompts = obj["prompts"]?.jsonArray?.mapNotNull { elem -> + val prompt = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + val cond = prompt["when"].obj() + ProviderAuthPromptDto( + key = prompt.str("key") ?: return@mapNotNull null, + label = prompt.str("message") ?: prompt.str("label") ?: prompt.str("key") ?: "", + type = prompt.str("type") ?: "text", + options = prompt["options"]?.jsonArray?.mapNotNull { parseAuthOption(it) } ?: emptyList(), + whenKey = cond?.str("key"), + whenOp = cond?.str("op"), + whenValue = cond?.str("value"), + ) + } ?: emptyList() + return ProviderAuthMethodDto(type, obj.str("label") ?: type, prompts) + } + + private fun parseAuthOption(elem: JsonElement): ProviderAuthOptionDto? { + val item = runCatching { elem.jsonObject }.getOrNull() + if (item != null) { + val label = item.str("label") ?: item.str("value") ?: return null + return ProviderAuthOptionDto(label = label, value = item.str("value") ?: label) + } + val text = runCatching { elem.jsonPrimitive.contentOrNull }.getOrNull() ?: return null + return ProviderAuthOptionDto(label = text, value = text) + } + private fun parseSessionObject(obj: JsonObject): SessionDto { val time = obj["time"]?.jsonObject val summary = obj["summary"]?.jsonObject @@ -869,6 +1342,16 @@ private fun JsonObject.long(key: String): Long? = private fun JsonObject?.bool(key: String): Boolean = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false +private fun JsonObject.flag(key: String, default: Boolean): Boolean { + val prim = this[key]?.jsonPrimitive ?: return default + return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() ?: default +} + +private fun JsonObject.flagOrNull(key: String): Boolean? { + val prim = this[key]?.jsonPrimitive ?: return null + return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() +} + private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() private fun JsonObject?.map(key: String): Map { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/dev/KiloDevMode.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/dev/KiloDevMode.kt new file mode 100644 index 00000000000..1118cb96dd8 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/dev/KiloDevMode.kt @@ -0,0 +1,7 @@ +package ai.kilocode.backend.dev + +import ai.kilocode.log.KiloLog + +object KiloDevMode { + fun enabled(): Boolean = KiloLog.sandbox() +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt new file mode 100644 index 00000000000..8952b416cdc --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/KiloBackendLegacyMigrationStoreService.kt @@ -0,0 +1,113 @@ +package ai.kilocode.backend.migration + +import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.cli.KiloCliConfigPath +import ai.kilocode.log.KiloLog +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** Provides the production [LegacyMigrationStore] backed by the CLI Kilo config directory. */ +@Service(Service.Level.APP) +class KiloBackendLegacyMigrationStoreService { + + companion object { + fun getInstance(): KiloBackendLegacyMigrationStoreService = service() + + internal fun store(log: KiloLog): LegacyMigrationStore { + val env = KiloBackendCliManager(log).buildEnv("migration") + val file = KiloCliConfigPath.legacySettingsFile(env) + log.info("Migration store: file=${file.absolutePath}") + return LegacySettingsFileMigrationStore(file) { msg, err -> + if (err == null) log.warn(msg) else log.warn(msg, err) + } + } + } + + private val log = KiloLog.create(KiloBackendLegacyMigrationStoreService::class.java) + + fun store(): LegacyMigrationStore = store(log) +} + +class LegacySettingsFileMigrationStore( + private val file: File, + private val warn: (String, Throwable?) -> Unit = { _, _ -> }, +) : LegacyMigrationStore { + companion object { + private val json = Json { prettyPrint = true } + private const val STATUS = "migrationStatus" + } + + override fun status(): LegacyMigrationStatus? { + val raw = read()?.get(STATUS)?.jsonPrimitive?.content ?: return null + return runCatching { LegacyMigrationStatus.valueOf(raw) }.getOrNull() + } + + override fun mark(status: LegacyMigrationStatus) { + val root = read().orEmpty().toMutableMap() + root[STATUS] = JsonPrimitive(status.name) + write(JsonObject(root)) + } + + override fun providerProfilesRaw(): String? = string("providerProfiles") + override fun oauthRaw(key: String): String? = (read()?.get("oauth") as? JsonObject)?.get(key)?.jsonPrimitive?.content + override fun mcpSettingsRaw(): String? = string("mcpSettings") + override fun customModesRaw(): String? = string("customModes") + override fun customModePromptsRaw(): String? = string("customModePrompts") + override fun autocompleteRaw(): String? = string("autocomplete") + override fun globalStateValue(key: String): JsonElement? = (read()?.get("globalState") as? JsonObject)?.get(key) + override fun taskHistoryRaw(): String? = string("taskHistory") + override fun taskConversationRaw(id: String): String? = (read()?.get("conversations") as? JsonObject)?.get(id)?.jsonPrimitive?.content + + override fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport { + val root = read()?.toMutableMap() ?: return LegacyCleanupReport(cleaned = emptyList(), errors = emptyList()) + if (targets.legacySettingsFile) { + val err = runCatching { + if (file.delete()) null else "Failed to delete ${file.absolutePath}" + }.getOrElse { it.message ?: "Failed to delete ${file.absolutePath}" } + return LegacyCleanupReport( + cleaned = if (err == null) listOf("legacySettingsFile") else emptyList(), + errors = listOfNotNull(err), + ) + } + val cleaned = mutableListOf() + if (targets.providerProfiles && root.remove("providerProfiles") != null) cleaned.add("providerProfiles") + if (targets.mcpSettings && root.remove("mcpSettings") != null) cleaned.add("mcpSettings") + if (targets.customModes && root.remove("customModes") != null) cleaned.add("customModes") + if (targets.globalState && root.remove("globalState") != null) cleaned.add("globalState") + if (targets.taskHistory) { + val history = root.remove("taskHistory") != null + val conv = root.remove("conversations") != null + if (history || conv) cleaned.add("taskHistory") + } + val err = runCatching { write(JsonObject(root)) }.exceptionOrNull()?.message + return LegacyCleanupReport(cleaned = if (err == null) cleaned else emptyList(), errors = listOfNotNull(err)) + } + + private fun string(key: String): String? = read()?.get(key)?.jsonPrimitive?.content + + private fun read(): JsonObject? { + if (!file.isFile) return null + return try { + json.parseToJsonElement(file.readText()).jsonObject + } catch (e: SerializationException) { + warn("Malformed legacy migration settings at ${file.absolutePath}", e) + null + } catch (e: IllegalArgumentException) { + warn("Malformed legacy migration settings at ${file.absolutePath}", e) + null + } + } + + private fun write(root: JsonObject) { + file.parentFile?.mkdirs() + file.writeText(json.encodeToString(JsonObject.serializer(), root)) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationBackend.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationBackend.kt new file mode 100644 index 00000000000..f02157b6943 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationBackend.kt @@ -0,0 +1,70 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonObject + +/** + * Backend adapter for writing migrated data to the CLI (kilo serve). + * + * Implementations use raw OkHttp or the generated Kotlin client. + * No threading or synchronization is assumed; callers own sequencing. + */ +interface LegacyMigrationBackend { + /** PUT /auth/{providerID} */ + fun setAuth(provider: String, auth: JsonObject) + + /** PATCH /global/config */ + fun updateGlobalConfig(config: JsonObject) + + /** GET /session/{sessionID} — returns true if the session already exists */ + fun sessionExists(id: String): Boolean + + /** POST /kilocode/session-import/project — returns the project ID */ + fun importProject(project: JsonObject): String + + /** POST /kilocode/session-import/session */ + fun importSession(session: JsonObject): LegacyImportResult + + /** POST /kilocode/session-import/message */ + fun importMessage(message: JsonObject) + + /** POST /kilocode/session-import/part */ + fun importPart(part: JsonObject) +} + +/** + * No-op backend for testing detection without a live server. + */ +class NoopLegacyMigrationBackend : LegacyMigrationBackend { + val authCalls = mutableListOf>() + val configCalls = mutableListOf() + val projectCalls = mutableListOf() + val sessionCalls = mutableListOf() + val messageCalls = mutableListOf() + val partCalls = mutableListOf() + + var existingSessionIds: Set = emptySet() + var sessionImportSkipped = false + var messageError: RuntimeException? = null + var partError: RuntimeException? = null + + override fun setAuth(provider: String, auth: JsonObject) { authCalls.add(provider to auth) } + override fun updateGlobalConfig(config: JsonObject) { configCalls.add(config) } + override fun sessionExists(id: String) = id in existingSessionIds + override fun importProject(project: JsonObject): String { + projectCalls.add(project) + return project["id"]?.toString()?.trim('"') ?: "prj_test" + } + override fun importSession(session: JsonObject): LegacyImportResult { + sessionCalls.add(session) + val id = session["id"]?.toString()?.trim('"') ?: "ses_test" + return LegacyImportResult(id = id, skipped = sessionImportSkipped) + } + override fun importMessage(message: JsonObject) { + messageError?.let { throw it } + messageCalls.add(message) + } + override fun importPart(part: JsonObject) { + partError?.let { throw it } + partCalls.add(part) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt new file mode 100644 index 00000000000..a29e7ffdad7 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationConverters.kt @@ -0,0 +1,799 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import ai.kilocode.backend.migration.LegacyMigrationJson.json +import ai.kilocode.backend.migration.LegacyMigrationJson.obj +import ai.kilocode.backend.migration.LegacyMigrationJson.str +import ai.kilocode.backend.migration.LegacyMigrationJson.arr + +/** + * Pure conversion functions: parse legacy source data → migration result models. + * + * No I/O, no backend calls, no side effects. Everything returns data structures. + */ +object LegacyMigrationConverters { + + // --------------------------------------------------------------------------- + // Parse raw legacy data from store + // --------------------------------------------------------------------------- + + fun parseProviderProfiles(raw: String?): LegacyProviderProfiles? { + raw ?: return null + val obj = LegacyMigrationJson.parseObject(raw) ?: return null + val name = obj["currentApiConfigName"]?.jsonPrimitive?.content ?: return null + val configs = obj["apiConfigs"]?.let { + runCatching { it.jsonObject }.getOrNull() + } ?: return null + val apiConfigs = configs.entries.associate { (k, v) -> + k to (runCatching { v.jsonObject }.getOrNull() ?: JsonObject(emptyMap())) + } + val modeConfigs = obj["modeApiConfigs"]?.let { + runCatching { it.jsonObject }.getOrNull() + }?.entries?.associate { (k, v) -> + k to (runCatching { v.jsonPrimitive.content }.getOrNull() ?: "") + } + return LegacyProviderProfiles( + currentApiConfigName = name, + apiConfigs = apiConfigs, + modeApiConfigs = modeConfigs, + ) + } + + fun parseMcpSettings(raw: String?): Map? { + raw ?: return null + val obj = LegacyMigrationJson.parseObject(raw) ?: return null + val servers = obj["mcpServers"]?.let { runCatching { it.jsonObject }.getOrNull() } ?: return null + return servers.entries.associate { (name, v) -> + val s = runCatching { v.jsonObject }.getOrNull() ?: JsonObject(emptyMap()) + name to LegacyMcpServer( + type = s["type"]?.jsonPrimitive?.content, + command = s["command"]?.jsonPrimitive?.content, + args = s["args"]?.let { a -> + runCatching { (a as? JsonArray)?.map { it.jsonPrimitive.content } }.getOrNull() + }, + url = s["url"]?.jsonPrimitive?.content, + env = s["env"]?.let { runCatching { it.jsonObject }.getOrNull() } + ?.entries?.associate { (k, ev) -> k to (runCatching { ev.jsonPrimitive.content }.getOrNull() ?: "") }, + headers = s["headers"]?.let { runCatching { it.jsonObject }.getOrNull() } + ?.entries?.associate { (k, hv) -> k to (runCatching { hv.jsonPrimitive.content }.getOrNull() ?: "") }, + disabled = s["disabled"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + timeout = s["timeout"]?.jsonPrimitive?.content?.toIntOrNull(), + ) + } + } + + fun parseCustomModes(raw: String?): List? { + raw ?: return null + // Try JSON first + val jsonModes = runCatching { + val obj = LegacyMigrationJson.parseObject(raw) ?: return@runCatching null + val arr = obj["customModes"]?.let { runCatching { it as JsonArray }.getOrNull() } ?: return@runCatching null + arr.mapNotNull { parseCustomModeFromJson(it) } + }.getOrNull() + if (jsonModes != null) return jsonModes.takeIf { it.isNotEmpty() } + return parseCustomModesYaml(raw).takeIf { it.isNotEmpty() } + } + + private fun parseCustomModeFromJson(elem: JsonElement): LegacyCustomMode? { + val obj = runCatching { elem.jsonObject }.getOrNull() ?: return null + val slug = obj["slug"]?.jsonPrimitive?.content ?: return null + val name = obj["name"]?.jsonPrimitive?.content ?: return null + val role = obj["roleDefinition"]?.jsonPrimitive?.content ?: "" + val groups: List = obj["groups"]?.let { g -> + runCatching { g as JsonArray }.getOrNull()?.mapNotNull { elem -> + parseGroupElem(elem) + } + } ?: emptyList() + return LegacyCustomMode( + slug = slug, + name = name, + roleDefinition = role, + customInstructions = obj["customInstructions"]?.jsonPrimitive?.content, + whenToUse = obj["whenToUse"]?.jsonPrimitive?.content, + description = obj["description"]?.jsonPrimitive?.content, + groups = groups, + ) + } + + private fun parseGroupElem(elem: JsonElement): Any? { + // String group: "read" + runCatching { elem.jsonPrimitive.content } + .getOrNull()?.let { return it } + // Array group: ["edit", {"fileRegex": "..."}] + val arr = runCatching { elem as JsonArray }.getOrNull() ?: return null + if (arr.size < 1) return null + val name = runCatching { arr[0].jsonPrimitive.content }.getOrNull() ?: return null + val opts = if (arr.size >= 2) runCatching { arr[1].jsonObject }.getOrNull() else null + return if (opts != null) Pair(name, opts.entries.associate { (k, v) -> + k to (runCatching { v.jsonPrimitive.content }.getOrNull() ?: "") + }) else name + } + + fun parseCustomModePrompts(raw: String?): Map? { + raw ?: return null + val obj = LegacyMigrationJson.parseObject(raw) ?: return null + return obj.entries.mapNotNull { (k, v) -> + runCatching { k to v.jsonObject }.getOrNull() + }.toMap().takeIf { it.isNotEmpty() } + } + + fun parseHistoryItems(raw: String?): List { + raw ?: return emptyList() + val arr = LegacyMigrationJson.parseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + val obj = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + val id = obj["id"]?.jsonPrimitive?.content ?: return@mapNotNull null + LegacyHistoryItem( + id = id, + task = obj["task"]?.jsonPrimitive?.content, + workspace = obj["workspace"]?.jsonPrimitive?.content, + ts = obj["ts"]?.jsonPrimitive?.content?.toLongOrNull(), + mode = obj["mode"]?.jsonPrimitive?.content, + rootTaskId = obj["rootTaskId"]?.jsonPrimitive?.content, + parentTaskId = obj["parentTaskId"]?.jsonPrimitive?.content, + ) + } + } + + fun parseSettings(globalState: (String) -> JsonElement?): LegacySettings { + val autocompleteRaw = globalState("ghostServiceSettings") + val autocomplete: LegacyAutocompleteSettings? = autocompleteRaw?.let { + runCatching { it.jsonObject }.getOrNull() + }?.let { obj -> + LegacyAutocompleteSettings( + enableAutoTrigger = obj["enableAutoTrigger"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + enableSmartInlineTaskKeybinding = obj["enableSmartInlineTaskKeybinding"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + enableChatAutocomplete = obj["enableChatAutocomplete"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + ).takeIf { it.enableAutoTrigger != null || it.enableSmartInlineTaskKeybinding != null || it.enableChatAutocomplete != null } + } + return LegacySettings( + autoApprovalEnabled = globalState("kilo-code.autoApprovalEnabled")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + allowedCommands = globalState("kilo-code.allowedCommands")?.let { parseBoolOrList(it) }, + deniedCommands = globalState("kilo-code.deniedCommands")?.let { parseBoolOrList(it) }, + alwaysAllowReadOnly = globalState("alwaysAllowReadOnly")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + alwaysAllowReadOnlyOutsideWorkspace = globalState("alwaysAllowReadOnlyOutsideWorkspace")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + alwaysAllowWrite = globalState("alwaysAllowWrite")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + alwaysAllowExecute = globalState("alwaysAllowExecute")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + alwaysAllowMcp = globalState("alwaysAllowMcp")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + alwaysAllowModeSwitch = globalState("alwaysAllowModeSwitch")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + alwaysAllowSubtasks = globalState("alwaysAllowSubtasks")?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + language = globalState("kilo-code.language")?.jsonPrimitive?.content, + autocomplete = autocomplete, + ) + } + + private fun parseBoolOrList(elem: JsonElement): List? { + return runCatching { + (elem as? JsonArray)?.map { it.jsonPrimitive.content } + }.getOrNull() + } + + // --------------------------------------------------------------------------- + // Provider conversion + // --------------------------------------------------------------------------- + + data class ProviderConversionResult( + val auth: JsonObject?, + val config: JsonObject?, + val status: MigrationItemStatus, + val message: String?, + ) + + fun convertProvider( + profileName: String, + settings: JsonObject, + oauthRaw: (String) -> String?, + ): ProviderConversionResult { + val provider = settings["apiProvider"]?.jsonPrimitive?.content + ?: return ProviderConversionResult(null, null, MigrationItemStatus.error, "No provider type found") + + if (provider in UNSUPPORTED_PROVIDERS) { + return ProviderConversionResult(null, null, MigrationItemStatus.warning, "Provider \"$provider\" is not supported in the new version") + } + + val mapping = PROVIDER_MAP[provider] + ?: return ProviderConversionResult(null, null, MigrationItemStatus.warning, "Unknown provider \"$provider\"") + + // OAuth providers (e.g. openai-codex) stored separately + if (mapping.oauthSecretKey != null) { + val raw = oauthRaw(mapping.oauthSecretKey) + ?: return ProviderConversionResult(null, null, MigrationItemStatus.warning, "No OAuth credentials found") + val creds = parseOAuthCredentials(raw) + ?: return ProviderConversionResult(null, null, MigrationItemStatus.warning, "Invalid OAuth credentials") + val auth = buildJsonObject { + put("type", "oauth") + put("access", creds.access) + put("refresh", creds.refresh) + put("expires", creds.expires) + creds.accountId?.let { put("accountId", it) } + } + return ProviderConversionResult(auth, null, MigrationItemStatus.success, null) + } + + // Vertex AI — skip auth, write config fields only + if (mapping.skipAuth) { + val config = buildVertexConfig(mapping, settings) + val hadCredentials = settings["vertexJsonCredentials"]?.jsonPrimitive?.content?.isNotBlank() == true || + settings["vertexKeyFile"]?.jsonPrimitive?.content?.isNotBlank() == true + val status = if (hadCredentials) MigrationItemStatus.warning else MigrationItemStatus.success + val msg = if (hadCredentials) "Project and location migrated. The new CLI uses Application Default Credentials — set GOOGLE_APPLICATION_CREDENTIALS or run 'gcloud auth application-default login'" else null + return ProviderConversionResult(null, config, status, msg) + } + + val apiKey = settings[mapping.key]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } + ?: return ProviderConversionResult(null, null, MigrationItemStatus.warning, "No API key found in profile") + + // Kilo gateway — write OAuth-shaped auth with 1-year expiry + val auth = if (mapping.id == "kilo") { + val org = mapping.organizationIdField?.let { settings[it]?.jsonPrimitive?.content } + buildJsonObject { + put("type", "oauth") + put("access", apiKey) + put("refresh", apiKey) + put("expires", System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000) + org?.let { put("accountId", it) } + } + } else { + val orgId = mapping.organizationIdField?.let { settings[it]?.jsonPrimitive?.content } + if (orgId != null) { + buildJsonObject { + put("type", "oauth") + put("access", apiKey) + put("refresh", "") + put("expires", 0) + put("accountId", orgId) + } + } else { + buildJsonObject { + put("type", "api") + put("key", apiKey) + } + } + } + + // Custom base URL config + val urlConfig = mapping.urlField?.let { settings[it]?.jsonPrimitive?.content?.takeIf { u -> u.isNotBlank() } }?.let { url -> + buildJsonObject { + put("provider", buildJsonObject { + put(mapping.id, buildJsonObject { + put("options", buildJsonObject { + put("apiKey", apiKey) + put("baseURL", url) + }) + }) + }) + } + } + + val configFields = buildConfigFieldsPatch(mapping, settings) + val combined = listOfNotNull(urlConfig, configFields).reduceOrNull { a, b -> LegacyMigrationJson.merge(a, b) } + + return ProviderConversionResult(auth, combined, MigrationItemStatus.success, null) + } + + private fun buildVertexConfig(mapping: ProviderMapping, settings: JsonObject): JsonObject? { + val fields = mapping.configFields ?: return null + val opts = mutableMapOf() + for (field in fields) { + val v = settings[field.from]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: continue + opts[field.option] = JsonPrimitive(v) + } + if (opts.isEmpty()) return null + return buildJsonObject { + put("provider", buildJsonObject { + put(mapping.id, buildJsonObject { + put("options", JsonObject(opts)) + }) + }) + } + } + + private fun buildConfigFieldsPatch(mapping: ProviderMapping, settings: JsonObject): JsonObject? { + val fields = mapping.configFields ?: return null + val opts = mutableMapOf() + for (field in fields) { + val v = settings[field.from]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: continue + opts[field.option] = JsonPrimitive(v) + } + if (opts.isEmpty()) return null + return buildJsonObject { + put("provider", buildJsonObject { + put(mapping.id, buildJsonObject { + put("options", JsonObject(opts)) + }) + }) + } + } + + data class OAuthCreds( + val access: String, + val refresh: String, + val expires: Long, + val accountId: String?, + ) + + fun parseOAuthCredentials(raw: String): OAuthCreds? { + val obj = LegacyMigrationJson.parseObject(raw) ?: return null + val access = obj["access_token"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return null + val refresh = obj["refresh_token"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return null + val expires = obj["expires"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null + val accountId = obj["accountId"]?.jsonPrimitive?.content + return OAuthCreds(access, refresh, expires, accountId) + } + + fun convertDefaultModel(settings: JsonObject): JsonObject? { + val provider = settings["apiProvider"]?.jsonPrimitive?.content ?: return null + val mapping = PROVIDER_MAP[provider] ?: return null + val modelField = mapping.modelField ?: "apiModelId" + val modelId = settings[modelField]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return null + return buildJsonObject { + put("model", "${mapping.id}/$modelId") + } + } + + // --------------------------------------------------------------------------- + // MCP conversion + // --------------------------------------------------------------------------- + + fun convertMcpServer(name: String, server: LegacyMcpServer): JsonObject? { + val enabled = if (server.disabled == true) false else null + val timeout = server.timeout?.let { it * 1000L } + + return when (server.type) { + "sse", "streamable-http" -> { + val url = server.url ?: return null + buildJsonObject { + put("type", "remote") + put("url", url) + if (enabled != null) put("enabled", enabled) + if (timeout != null) put("timeout", timeout) + server.headers?.let { h -> + put("headers", JsonObject(h.mapValues { JsonPrimitive(it.value) })) + } + } + } + else -> { + val command = server.command ?: return null + val cmd = if (server.args != null) listOf(command) + server.args else listOf(command) + buildJsonObject { + put("type", "local") + put("command", JsonArray(cmd.map { JsonPrimitive(it) })) + if (enabled != null) put("enabled", enabled) + if (timeout != null) put("timeout", timeout) + server.env?.let { e -> + put("environment", JsonObject(e.mapValues { JsonPrimitive(it.value) })) + } + } + } + } + } + + // --------------------------------------------------------------------------- + // Custom mode / agent conversion + // --------------------------------------------------------------------------- + + private val GROUP_TO_PERMISSION = mapOf( + "read" to "read", + "edit" to "edit", + "browser" to "bash", + "command" to "bash", + "mcp" to "skill", + ) + private val ALL_MODE_PERMISSIONS = listOf("read", "edit", "bash", "skill") + + fun convertCustomModePermissions(groups: List): JsonObject { + val permission = mutableMapOf() + val allowed = mutableSetOf() + + for (group in groups) { + val groupName: String + val groupConfig: Map? + when (group) { + is String -> { groupName = group; groupConfig = null } + is Pair<*, *> -> { + @Suppress("UNCHECKED_CAST") + groupName = group.first as? String ?: continue + @Suppress("UNCHECKED_CAST") + groupConfig = group.second as? Map + } + else -> continue + } + val permKey = GROUP_TO_PERMISSION[groupName] ?: groupName + allowed.add(permKey) + + val fileRegex = groupConfig?.get("fileRegex") + val newValue: JsonElement = if (fileRegex != null) { + JsonObject(mapOf(fileRegex to JsonPrimitive("allow"), "*" to JsonPrimitive("deny"))) + } else { + JsonPrimitive("allow") + } + + val existing = permission[permKey] + permission[permKey] = when { + existing == null -> newValue + existing == JsonPrimitive("allow") || newValue == JsonPrimitive("allow") -> JsonPrimitive("allow") + existing is JsonObject && newValue is JsonObject -> JsonObject(existing + newValue) + else -> newValue + } + } + + for (perm in ALL_MODE_PERMISSIONS) { + if (perm !in allowed) permission[perm] = JsonPrimitive("deny") + } + + return JsonObject(permission) + } + + fun convertCustomMode(mode: LegacyCustomMode): JsonObject { + val parts = mutableListOf(mode.roleDefinition) + val instructions = mode.customInstructions?.trim() + if (!instructions.isNullOrEmpty()) { + parts.add( + listOf( + "USER'S CUSTOM INSTRUCTIONS", + "", + "The following additional instructions are provided by the user, and should be followed to the best of your ability.", + "", + "Mode-specific Instructions:\n$instructions", + ).joinToString("\n") + ) + } + val prompt = parts.filter { it.isNotBlank() }.joinToString("\n\n") + val description = mode.description ?: mode.whenToUse ?: mode.roleDefinition.take(120) + val permission = convertCustomModePermissions(mode.groups) + + return buildJsonObject { + put("mode", "primary") + put("description", description) + put("prompt", prompt) + put("permission", permission) + } + } + + // --------------------------------------------------------------------------- + // Auto-approval / permissions conversion + // --------------------------------------------------------------------------- + + data class PermissionConversion( + /** config patch to write, null if nothing to write */ + val config: JsonObject?, + val results: List>, + ) + + fun convertAutoApproval(settings: LegacySettings, sel: MigrationAutoApprovalSelections): PermissionConversion { + val fallback = if (settings.autoApprovalEnabled == true) "allow" else "ask" + val results = mutableListOf>() + val permission = mutableMapOf() + var globalAllowApplied = false + + if (sel.commandRules) { + val hasCommandLists = (settings.allowedCommands?.isNotEmpty() == true) || (settings.deniedCommands?.isNotEmpty() == true) + if (settings.autoApprovalEnabled == true && !hasCommandLists) { + // Scalar "allow" — caller should write this as top-level permission:"allow" + globalAllowApplied = true + } else if (hasCommandLists) { + val bashRules = mutableMapOf() + for (cmd in settings.allowedCommands ?: emptyList()) { + bashRules["${cmd.trimEnd()} *"] = JsonPrimitive("allow") + } + for (cmd in settings.deniedCommands ?: emptyList()) { + bashRules["${cmd.trimEnd()} *"] = JsonPrimitive("deny") + } + bashRules["*"] = JsonPrimitive( + when (settings.alwaysAllowExecute) { + true -> "allow" + false -> "ask" + null -> fallback + } + ) + permission["bash"] = JsonObject(bashRules) + } + results.add("Command rules" to MigrationItemStatus.success) + } + + if (sel.readPermission) { + if (settings.alwaysAllowReadOnly == true) { + permission["read"] = JsonPrimitive("allow") + permission["glob"] = JsonPrimitive("allow") + permission["grep"] = JsonPrimitive("allow") + permission["list"] = JsonPrimitive("allow") + } else if (settings.alwaysAllowReadOnly == false) { + permission["read"] = JsonPrimitive("ask") + } + if (settings.alwaysAllowReadOnlyOutsideWorkspace == true) { + permission["external_directory"] = JsonPrimitive("allow") + } else if (settings.alwaysAllowReadOnlyOutsideWorkspace == false) { + permission["external_directory"] = JsonPrimitive("ask") + } + results.add("Read permission" to MigrationItemStatus.success) + } + + if (sel.writePermission) { + if (settings.alwaysAllowWrite == true) { + permission["edit"] = JsonPrimitive("allow") + } else if (settings.alwaysAllowWrite == false) { + permission["edit"] = JsonPrimitive("ask") + } + results.add("Write permission" to MigrationItemStatus.success) + } + + if (sel.executePermission && !sel.commandRules) { + if (settings.alwaysAllowExecute == true) { + permission["bash"] = JsonPrimitive("allow") + } else if (settings.alwaysAllowExecute == false) { + permission["bash"] = JsonPrimitive("ask") + } + results.add("Execute permission" to MigrationItemStatus.success) + } else if (sel.executePermission) { + results.add("Execute permission" to MigrationItemStatus.success) + } + + if (sel.mcpPermission) { + if (settings.alwaysAllowMcp == true) { + permission["skill"] = JsonPrimitive("allow") + } else if (settings.alwaysAllowMcp == false) { + permission["skill"] = JsonPrimitive("ask") + } + results.add("MCP permission" to MigrationItemStatus.success) + } + + if (sel.taskPermission) { + if (settings.alwaysAllowModeSwitch == true || settings.alwaysAllowSubtasks == true) { + permission["task"] = JsonPrimitive("allow") + } else if (settings.alwaysAllowModeSwitch == false && settings.alwaysAllowSubtasks == false) { + permission["task"] = JsonPrimitive("ask") + } + results.add("Task permission" to MigrationItemStatus.success) + } + + val config = when { + globalAllowApplied -> buildJsonObject { put("permission", "allow") } + permission.isNotEmpty() -> buildJsonObject { put("permission", JsonObject(permission)) } + else -> null + } + + return PermissionConversion(config = config, results = results) + } + + // --------------------------------------------------------------------------- + // Language mapping + // --------------------------------------------------------------------------- + + private val LEGACY_LOCALE_MAP = mapOf( + "en" to "en", "de" to "de", "es" to "es", "fr" to "fr", + "ja" to "ja", "ko" to "ko", "pl" to "pl", "ru" to "ru", + "ar" to "ar", "th" to "th", "da" to "da", "no" to "no", + "bs" to "bs", + "zh-CN" to "zh", "zh-TW" to "zht", "pt-BR" to "br", + ) + + data class LanguageConversion( + val mapped: String?, + val status: MigrationItemStatus, + val message: String?, + ) + + fun convertLanguage(language: String): LanguageConversion { + val mapped = LEGACY_LOCALE_MAP[language] + return if (mapped != null) { + LanguageConversion(mapped, MigrationItemStatus.success, null) + } else { + LanguageConversion(null, MigrationItemStatus.warning, "Language \"$language\" is not supported in the new version") + } + } + + // --------------------------------------------------------------------------- + // Native mode defaults comparison + // --------------------------------------------------------------------------- + + data class NativeModeDefaults( + val name: String, + val roleDefinition: String, + val customInstructions: String? = null, + val whenToUse: String? = null, + val description: String? = null, + val groups: List, + ) + + val NATIVE_MODE_DEFAULTS: Map = mapOf( + "architect" to NativeModeDefaults( + name = "Architect", + roleDefinition = "You are Kilo Code, an experienced technical leader who is inquisitive and an excellent planner. Your goal is to gather information and get context to create a detailed plan for accomplishing the user's task, which the user will review and approve before they switch into another mode to implement the solution.", + whenToUse = "Use this mode when you need to plan, design, or strategize before implementation.", + description = "Plan and design before implementation", + groups = listOf("read", Pair("edit", mapOf("fileRegex" to "\\.md$", "description" to "Markdown files only")), "browser", "mcp"), + ), + "code" to NativeModeDefaults( + name = "Code", + roleDefinition = "You are Kilo Code, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.", + whenToUse = "Use this mode when you need to write, modify, or refactor code.", + description = "Write, modify, and refactor code", + groups = listOf("read", "edit", "browser", "command", "mcp"), + ), + "ask" to NativeModeDefaults( + name = "Ask", + roleDefinition = "You are Kilo Code, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics.", + whenToUse = "Use this mode when you need explanations, documentation, or answers to technical questions.", + description = "Get answers and explanations", + groups = listOf("read", "browser", "mcp"), + ), + "debug" to NativeModeDefaults( + name = "Debug", + roleDefinition = "You are Kilo Code, an expert software debugger specializing in systematic problem diagnosis and resolution.", + whenToUse = "Use this mode when you're troubleshooting issues, investigating errors, or diagnosing problems.", + description = "Diagnose and fix software issues", + groups = listOf("read", "edit", "browser", "command", "mcp"), + ), + "orchestrator" to NativeModeDefaults( + name = "Orchestrator", + roleDefinition = "You are Kilo Code, a strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized modes.", + whenToUse = "Use this mode for complex, multi-step projects that require coordination across different specialties.", + description = "Coordinate tasks across multiple modes", + groups = emptyList(), + ), + "review" to NativeModeDefaults( + name = "Review", + roleDefinition = "You are Kilo Code, an expert code reviewer with deep expertise in software engineering best practices, security vulnerabilities, performance optimization, and code quality.", + whenToUse = "Use this mode when you need to review code changes.", + description = "Review code changes locally", + groups = listOf("read", "browser", "mcp", "command"), + ), + ) + + fun isNativeModeModified( + yaml: LegacyCustomMode?, + prompt: JsonObject?, + defaults: NativeModeDefaults, + ): Boolean { + if (yaml != null) return true + if (prompt == null) return false + val role = prompt["roleDefinition"]?.jsonPrimitive?.content + if (role != null && role != defaults.roleDefinition) return true + val ci = prompt["customInstructions"]?.jsonPrimitive?.content + if (ci != null && ci != (defaults.customInstructions ?: "")) return true + val wtu = prompt["whenToUse"]?.jsonPrimitive?.content + if (wtu != null && wtu != (defaults.whenToUse ?: "")) return true + val desc = prompt["description"]?.jsonPrimitive?.content + if (desc != null && desc != (defaults.description ?: "")) return true + return false + } + + fun buildMergedNativeMode(yaml: LegacyCustomMode?, prompt: JsonObject?, slug: String): LegacyCustomMode? { + val defaults = NATIVE_MODE_DEFAULTS[slug] ?: return null + val base = yaml?.copy() ?: LegacyCustomMode( + slug = slug, + name = defaults.name, + roleDefinition = defaults.roleDefinition, + customInstructions = defaults.customInstructions, + whenToUse = defaults.whenToUse, + description = defaults.description, + groups = defaults.groups.toList(), + ) + if (prompt == null) return base + return base.copy( + roleDefinition = prompt["roleDefinition"]?.jsonPrimitive?.content ?: base.roleDefinition, + customInstructions = prompt["customInstructions"]?.jsonPrimitive?.content ?: base.customInstructions, + whenToUse = prompt["whenToUse"]?.jsonPrimitive?.content ?: base.whenToUse, + description = prompt["description"]?.jsonPrimitive?.content ?: base.description, + ) + } + + // --------------------------------------------------------------------------- + // YAML parser for custom_modes.yaml + // --------------------------------------------------------------------------- + + fun parseCustomModesYaml(text: String): List { + val modes = mutableListOf() + val lines = text.split("\n") + var inModes = false + var current: MutableMap = mutableMapOf() + var blockField: String? = null + var blockLines = mutableListOf() + var inGroups = false + var groups = mutableListOf() + + fun stripYamlQuotes(value: String) = value.replace(Regex("^(['\"])(.*?)\\1$"), "$2") + + fun flush() { + val slug = current["slug"] as? String ?: return + val name = current["name"] as? String ?: return + if (blockField != null && blockLines.isNotEmpty()) { + current[blockField!!] = blockLines.joinToString("\n").trim() + } + modes.add( + LegacyCustomMode( + slug = slug, + name = name, + roleDefinition = current["roleDefinition"] as? String ?: "", + customInstructions = current["customInstructions"] as? String, + whenToUse = current["whenToUse"] as? String, + description = current["description"] as? String, + groups = groups.toList(), + ) + ) + current = mutableMapOf() + blockField = null + blockLines = mutableListOf() + inGroups = false + groups = mutableListOf() + } + + for (raw in lines) { + if (!inModes) { + if (raw.trim() == "customModes:") inModes = true + continue + } + if (raw.matches(Regex(" - slug: .*"))) { + flush() + current["slug"] = stripYamlQuotes(raw.removePrefix(" - slug: ").trim()) + continue + } + if (current.isEmpty()) continue + + if (raw.matches(Regex(" name: .*"))) { + current["name"] = stripYamlQuotes(raw.removePrefix(" name: ").trim()) + continue + } + + val blockMatch = Regex(" (roleDefinition|customInstructions): [|>]").find(raw) + if (blockMatch != null) { + if (blockField != null && blockLines.isNotEmpty()) { + current[blockField!!] = blockLines.joinToString("\n").trim() + } + blockField = blockMatch.groupValues[1] + inGroups = false + blockLines = mutableListOf() + continue + } + + if (raw.matches(Regex(" roleDefinition: .*")) && blockField == null) { + current["roleDefinition"] = stripYamlQuotes(raw.removePrefix(" roleDefinition: ").trim()) + continue + } + if (raw.matches(Regex(" customInstructions: .*")) && blockField == null) { + current["customInstructions"] = stripYamlQuotes(raw.removePrefix(" customInstructions: ").trim()) + continue + } + if (raw.matches(Regex(" whenToUse: .*")) && blockField == null) { + current["whenToUse"] = stripYamlQuotes(raw.removePrefix(" whenToUse: ").trim()) + continue + } + if (raw.matches(Regex(" description: .*")) && blockField == null) { + current["description"] = stripYamlQuotes(raw.removePrefix(" description: ").trim()) + continue + } + + if (blockField != null) { + if (raw.startsWith(" ")) { + blockLines.add(raw.removePrefix(" ")) + continue + } + current[blockField!!] = blockLines.joinToString("\n").trim() + blockField = null + blockLines = mutableListOf() + } + + if (raw.matches(Regex(" groups:.*"))) { + inGroups = true + groups = mutableListOf() + continue + } + if (inGroups && raw.matches(Regex(" - .*"))) { + groups.add(stripYamlQuotes(raw.removePrefix(" - ").trim())) + continue + } + if (inGroups && !raw.startsWith(" ")) { + inGroups = false + } + } + flush() + return modes + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationEngine.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationEngine.kt new file mode 100644 index 00000000000..971805f7c15 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationEngine.kt @@ -0,0 +1,472 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import ai.kilocode.log.KiloLog +import ai.kilocode.backend.migration.LegacyMigrationConverters.convertAutoApproval +import ai.kilocode.backend.migration.LegacyMigrationConverters.convertCustomMode +import ai.kilocode.backend.migration.LegacyMigrationConverters.convertCustomModePermissions +import ai.kilocode.backend.migration.LegacyMigrationConverters.convertDefaultModel +import ai.kilocode.backend.migration.LegacyMigrationConverters.convertMcpServer +import ai.kilocode.backend.migration.LegacyMigrationConverters.convertProvider +import ai.kilocode.backend.migration.LegacyMigrationConverters.parseCustomModePrompts +import ai.kilocode.backend.migration.LegacyMigrationConverters.parseCustomModes +import ai.kilocode.backend.migration.LegacyMigrationConverters.parseHistoryItems +import ai.kilocode.backend.migration.LegacyMigrationConverters.parseMcpSettings +import ai.kilocode.backend.migration.LegacyMigrationConverters.parseProviderProfiles +import ai.kilocode.backend.migration.LegacyMigrationConverters.parseSettings +import ai.kilocode.backend.migration.session.LegacySessionIds +import ai.kilocode.backend.migration.session.LegacySessionParser + +/** + * Orchestrates legacy data detection, migration, and cleanup. + * + * This class is stateless per-call: [detect] and [migrate] re-read source data + * each time so the UI always sees the current snapshot. No background threads, + * locks, or EDT scheduling — callers own all sequencing. + * + * Progress sink callbacks are invoked synchronously on the caller's thread. + */ +class LegacyMigrationEngine( + private val store: LegacyMigrationStore, + private val backend: LegacyMigrationBackend, +) { + + companion object { + private val LOG = KiloLog.create(LegacyMigrationEngine::class.java) + } + + // ----------------------------------------------------------------------- + // Status + // ----------------------------------------------------------------------- + + fun status(): LegacyMigrationStatus? = store.status() + + fun mark(status: LegacyMigrationStatus) = store.mark(status) + + // ----------------------------------------------------------------------- + // Detection + // ----------------------------------------------------------------------- + + fun detect(): LegacyMigrationDetection { + val profiles = parseProviderProfiles(store.providerProfilesRaw()) + val mcpServers = parseMcpSettings(store.mcpSettingsRaw()) + val customModes = parseCustomModes(store.customModesRaw()) + val prompts = parseCustomModePrompts(store.customModePromptsRaw()) + val settings = parseSettings { store.globalStateValue(it) } + + // Detect OAuth providers + val oauthProviders = mutableSetOf() + if (store.oauthRaw("openai-codex-oauth-credentials") != null) oauthProviders.add("openai-codex") + + val providerList = buildProviderList(profiles, oauthProviders) + val mcpList = buildMcpServerList(mcpServers) + val modeList = buildCustomModeList(customModes, prompts) + val defaultModel = resolveDefaultModel(profiles, oauthProviders) + + val sessions = detectSessions() + + val hasSettings = settings.autoApprovalEnabled != null || + !settings.allowedCommands.isNullOrEmpty() || + !settings.deniedCommands.isNullOrEmpty() || + settings.alwaysAllowReadOnly != null || + settings.alwaysAllowReadOnlyOutsideWorkspace != null || + settings.alwaysAllowWrite != null || + settings.alwaysAllowExecute != null || + settings.alwaysAllowMcp != null || + settings.alwaysAllowModeSwitch != null || + settings.alwaysAllowSubtasks != null || + !settings.language.isNullOrEmpty() || + settings.autocomplete != null + + val hasData = providerList.isNotEmpty() || mcpList.isNotEmpty() || modeList.isNotEmpty() || + hasSettings || sessions.isNotEmpty() + + return LegacyMigrationDetection( + providers = providerList, + mcpServers = mcpList, + customModes = modeList, + sessions = sessions, + defaultModel = defaultModel, + settings = if (hasSettings) settings else null, + hasData = hasData, + ) + } + + fun detectSessions(): List { + val items = parseHistoryItems(store.taskHistoryRaw()) + return items.mapNotNull { item -> + if (store.taskConversationRaw(item.id) == null) return@mapNotNull null + MigrationSessionInfo( + id = item.id, + title = item.task?.trim() ?: item.id, + directory = item.workspace?.trim() ?: "", + time = item.ts ?: 0L, + ) + } + } + + // ----------------------------------------------------------------------- + // Migration + // ----------------------------------------------------------------------- + + fun migrate( + selections: LegacyMigrationSelections, + sink: LegacyMigrationSink = LegacyMigrationSink.None, + ): LegacyMigrationReport { + val profiles = parseProviderProfiles(store.providerProfilesRaw()) + val mcpServers = parseMcpSettings(store.mcpSettingsRaw()) + val customModes = parseCustomModes(store.customModesRaw()) + val prompts = parseCustomModePrompts(store.customModePromptsRaw()) + val settings = parseSettings { store.globalStateValue(it) } + val sessions = detectSessions() + val historyItems = parseHistoryItems(store.taskHistoryRaw()) + + val results = mutableListOf() + + // Providers + for (profileName in selections.providers) { + val providerSettings = profiles?.apiConfigs?.get(profileName) + if (providerSettings == null) { + results.add(LegacyMigrationResultItem(profileName, MigrationItemCategory.provider, MigrationItemStatus.error, "Profile not found")) + continue + } + sink.item(LegacyMigrationItemProgress(profileName, MigrationItemProgressStatus.migrating)) + val conv = convertProvider(profileName, providerSettings) { store.oauthRaw(it) } + conv.auth?.let { backend.setAuth(PROVIDER_MAP[providerSettings["apiProvider"]?.jsonPrimitive?.content]?.id ?: "unknown", it) } + conv.config?.let { backend.updateGlobalConfig(it) } + val item = LegacyMigrationResultItem(profileName, MigrationItemCategory.provider, conv.status, conv.message) + results.add(item) + sink.item(LegacyMigrationItemProgress(profileName, conv.status.toProgressStatus(), conv.message)) + } + + // MCP servers + if (selections.mcpServers.isNotEmpty() && mcpServers != null) { + val mcpConfig = mutableMapOf() + for (name in selections.mcpServers) { + val server = mcpServers[name] + if (server == null) { + results.add(LegacyMigrationResultItem(name, MigrationItemCategory.mcpServer, MigrationItemStatus.error, "Server not found")) + continue + } + sink.item(LegacyMigrationItemProgress(name, MigrationItemProgressStatus.migrating)) + val converted = convertMcpServer(name, server) + if (converted != null) { + mcpConfig[name] = converted + results.add(LegacyMigrationResultItem(name, MigrationItemCategory.mcpServer, MigrationItemStatus.success)) + sink.item(LegacyMigrationItemProgress(name, MigrationItemProgressStatus.success)) + } else { + results.add(LegacyMigrationResultItem(name, MigrationItemCategory.mcpServer, MigrationItemStatus.warning, "Could not convert server config")) + sink.item(LegacyMigrationItemProgress(name, MigrationItemProgressStatus.warning, "Could not convert server config")) + } + } + if (mcpConfig.isNotEmpty()) { + backend.updateGlobalConfig(buildJsonObject { put("mcp", JsonObject(mcpConfig)) }) + } + } + + // Custom modes as agents + if (selections.customModes.isNotEmpty()) { + val agentConfig = mutableMapOf() + val detected = buildCustomModeList(customModes, prompts) + for (slug in selections.customModes) { + val info = detected.find { it.slug == slug } + if (info == null) { + results.add(LegacyMigrationResultItem(slug, MigrationItemCategory.customMode, MigrationItemStatus.error, "Mode not found")) + continue + } + if (info.nativeSlug != null) { + val merged = LegacyMigrationConverters.buildMergedNativeMode( + customModes?.find { it.slug == info.nativeSlug }, + prompts?.get(info.nativeSlug), + info.nativeSlug, + ) + if (merged != null) { + sink.item(LegacyMigrationItemProgress(info.name, MigrationItemProgressStatus.migrating)) + val agent = convertCustomMode(merged).toMutableMap() + agent["name"] = JsonPrimitive(info.name) + agentConfig[slug] = JsonObject(agent) + results.add(LegacyMigrationResultItem(info.name, MigrationItemCategory.customMode, MigrationItemStatus.success)) + sink.item(LegacyMigrationItemProgress(info.name, MigrationItemProgressStatus.success)) + } else { + results.add(LegacyMigrationResultItem(info.name, MigrationItemCategory.customMode, MigrationItemStatus.error, "Failed to build merged mode")) + } + } else { + val mode = customModes?.find { it.slug == slug } + if (mode == null) { + results.add(LegacyMigrationResultItem(slug, MigrationItemCategory.customMode, MigrationItemStatus.error, "Mode not found")) + continue + } + sink.item(LegacyMigrationItemProgress(mode.name, MigrationItemProgressStatus.migrating)) + agentConfig[slug] = convertCustomMode(mode) + results.add(LegacyMigrationResultItem(mode.name, MigrationItemCategory.customMode, MigrationItemStatus.success)) + sink.item(LegacyMigrationItemProgress(mode.name, MigrationItemProgressStatus.success)) + } + } + if (agentConfig.isNotEmpty()) { + backend.updateGlobalConfig(buildJsonObject { put("agent", JsonObject(agentConfig)) }) + } + } + + // Sessions + var sessionProgressEmitted = false + for ((idx, sel) in selections.sessions.withIndex()) { + val info = sessions.find { it.id == sel.id } + val historyItem = historyItems.find { it.id == sel.id } + val conversationRaw = store.taskConversationRaw(sel.id) + + val sessionId = LegacySessionIds.createSessionId(sel.id) + + if (backend.sessionExists(sessionId)) { + LOG.info("Migration session duplicate skipped legacy=${sel.id} session=$sessionId title=${info?.title}") + continue + } + + sink.item(LegacyMigrationItemProgress(sel.id, MigrationItemProgressStatus.migrating)) + sessionProgressEmitted = true + + if (conversationRaw == null) { + val msg = "Conversation file not found" + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.error, msg)) + results.add(LegacyMigrationResultItem(sel.id, MigrationItemCategory.session, MigrationItemStatus.error, msg)) + sink.item(LegacyMigrationItemProgress(sel.id, MigrationItemProgressStatus.error, msg)) + continue + } + + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.preparing)) + + val parsed = runCatching { + LegacySessionParser.parseSession(sel.id, conversationRaw, historyItem) + }.getOrElse { e -> + val msg = e.message ?: "Parse error" + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.error, msg)) + results.add(LegacyMigrationResultItem(sel.id, MigrationItemCategory.session, MigrationItemStatus.error, msg)) + sink.item(LegacyMigrationItemProgress(sel.id, MigrationItemProgressStatus.error, msg)) + null + } ?: continue + + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.storing)) + + val projectId = runCatching { backend.importProject(parsed.project) }.getOrElse { e -> + val msg = e.message ?: "Project import failed" + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.error, msg)) + results.add(LegacyMigrationResultItem(sel.id, MigrationItemCategory.session, MigrationItemStatus.error, msg)) + sink.item(LegacyMigrationItemProgress(sel.id, MigrationItemProgressStatus.error, msg)) + null + } ?: continue + + val sessionPayload = buildJsonObject { + parsed.session.entries.forEach { (k, v) -> put(k, v) } + put("projectID", projectId) + } + + val importResult = runCatching { backend.importSession(sessionPayload) }.getOrElse { e -> + val msg = e.message ?: "Session import failed" + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.error, msg)) + results.add(LegacyMigrationResultItem(sel.id, MigrationItemCategory.session, MigrationItemStatus.error, msg)) + sink.item(LegacyMigrationItemProgress(sel.id, MigrationItemProgressStatus.error, msg)) + null + } ?: continue + + if (importResult.skipped) { + LOG.info("Migration session import duplicate skipped legacy=${sel.id} session=${importResult.id}") + continue + } + + val errors = mutableListOf() + for (msg in parsed.messages) { + runCatching { backend.importMessage(msg) }.getOrElse { e -> + val err = e.message ?: "Message import failed" + LOG.warn("Migration message import failed legacy=${sel.id}: $err") + errors.add(err) + } + } + for (part in parsed.parts) { + runCatching { backend.importPart(part) }.getOrElse { e -> + val err = e.message ?: "Part import failed" + LOG.warn("Migration part import failed legacy=${sel.id}: $err") + errors.add(err) + } + } + + val status = if (errors.isEmpty()) MigrationItemStatus.success else MigrationItemStatus.warning + val msg = errors.firstOrNull() + sink.session(LegacyMigrationSessionProgress(info, idx, selections.sessions.size, MigrationSessionPhase.done, msg)) + results.add(LegacyMigrationResultItem(sel.id, MigrationItemCategory.session, status, msg)) + sink.item(LegacyMigrationItemProgress(sel.id, status.toProgressStatus(), msg)) + } + + // Summary progress for sessions + if (sessionProgressEmitted) { + val last = sessions.find { it.id == selections.sessions.last().id } + sink.session(LegacyMigrationSessionProgress(last, selections.sessions.size, selections.sessions.size, MigrationSessionPhase.summary)) + } + + // Default model + if (selections.defaultModel && profiles != null) { + val activeName = profiles.currentApiConfigName + val active = profiles.apiConfigs[activeName] + if (active != null) { + sink.item(LegacyMigrationItemProgress("Default model", MigrationItemProgressStatus.migrating)) + val patch = convertDefaultModel(active) + if (patch != null) { + backend.updateGlobalConfig(patch) + results.add(LegacyMigrationResultItem("Default model", MigrationItemCategory.defaultModel, MigrationItemStatus.success)) + sink.item(LegacyMigrationItemProgress("Default model", MigrationItemProgressStatus.success)) + } else { + results.add(LegacyMigrationResultItem("Default model", MigrationItemCategory.defaultModel, MigrationItemStatus.warning, "No model ID found")) + sink.item(LegacyMigrationItemProgress("Default model", MigrationItemProgressStatus.warning, "No model ID found")) + } + } + } + + // Auto-approval / permissions + val apSel = selections.settings.autoApproval + val anyAutoApproval = apSel.commandRules || apSel.readPermission || apSel.writePermission || + apSel.executePermission || apSel.mcpPermission || apSel.taskPermission + if (anyAutoApproval) { + val conv = convertAutoApproval(settings, apSel) + conv.config?.let { backend.updateGlobalConfig(it) } + for ((label, status) in conv.results) { + sink.item(LegacyMigrationItemProgress(label, status.toProgressStatus())) + results.add(LegacyMigrationResultItem(label, MigrationItemCategory.settings, status)) + } + } + + // Language + if (selections.settings.language && !settings.language.isNullOrEmpty()) { + sink.item(LegacyMigrationItemProgress("Language preference", MigrationItemProgressStatus.migrating)) + val conv = LegacyMigrationConverters.convertLanguage(settings.language) + if (conv.mapped != null) { + // Language setting is JetBrains-only; for now report success but don't write anywhere + results.add(LegacyMigrationResultItem("Language preference", MigrationItemCategory.settings, MigrationItemStatus.success)) + sink.item(LegacyMigrationItemProgress("Language preference", MigrationItemProgressStatus.success)) + } else { + results.add(LegacyMigrationResultItem("Language preference", MigrationItemCategory.settings, conv.status, conv.message)) + sink.item(LegacyMigrationItemProgress("Language preference", conv.status.toProgressStatus(), conv.message)) + } + } + + // Autocomplete settings are persisted by the JetBrains frontend before backend migration starts. + if (selections.settings.autocomplete && settings.autocomplete != null) { + sink.item(LegacyMigrationItemProgress("Autocomplete settings", MigrationItemProgressStatus.migrating)) + results.add(LegacyMigrationResultItem("Autocomplete settings", MigrationItemCategory.settings, MigrationItemStatus.success)) + sink.item(LegacyMigrationItemProgress("Autocomplete settings", MigrationItemProgressStatus.success)) + } + + return LegacyMigrationReport(results) + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport = store.cleanup(targets) + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + private fun buildProviderList( + profiles: LegacyProviderProfiles?, + oauthProviders: Set, + ): List { + if (profiles?.apiConfigs == null) return emptyList() + return profiles.apiConfigs.entries.map { (profileName, settings) -> + val provider: String = settings["apiProvider"]?.jsonPrimitive?.content ?: "unknown" + val mapping = PROVIDER_MAP[provider] + val unsupported = provider in UNSUPPORTED_PROVIDERS + val modelField: String = mapping?.modelField ?: "apiModelId" + val model: String? = settings[modelField]?.jsonPrimitive?.content + val hasApiKey: Boolean = when { + mapping?.oauthSecretKey != null -> provider in oauthProviders + mapping?.skipAuth == true -> { + val fields: List = mapping.configFields ?: emptyList() + fields.any { f -> settings[f.from]?.jsonPrimitive?.content?.isNotBlank() == true } + } + mapping != null -> settings[mapping.key]?.jsonPrimitive?.content?.isNotBlank() == true + else -> false + } + MigrationProviderInfo( + profileName = profileName, + provider = provider, + model = model, + hasApiKey = hasApiKey, + supported = mapping != null && !unsupported, + newProviderName = mapping?.name, + ) + } + } + + private fun buildMcpServerList(servers: Map?): List { + servers ?: return emptyList() + return servers.map { (name, s) -> + MigrationMcpServerInfo(name = name, type = s.type ?: "stdio", disabled = s.disabled) + } + } + + fun buildCustomModeList( + modes: List?, + prompts: Map?, + ): List { + val result = mutableListOf() + // Non-native custom modes + modes?.forEach { m -> + if (m.slug !in DEFAULT_MODE_SLUGS) result.add(MigrationCustomModeInfo(name = m.name, slug = m.slug)) + } + // Modified native modes + for (slug in DEFAULT_MODE_SLUGS) { + val defaults = LegacyMigrationConverters.NATIVE_MODE_DEFAULTS[slug] ?: continue + val yaml = modes?.find { it.slug == slug } + val prompt = prompts?.get(slug) + if (!LegacyMigrationConverters.isNativeModeModified(yaml, prompt, defaults)) continue + val name = yaml?.name ?: defaults.name + result.add(MigrationCustomModeInfo(name = "$name (Custom)", slug = "$slug-custom", nativeSlug = slug)) + } + return result + } + + private fun resolveDefaultModel( + profiles: LegacyProviderProfiles?, + oauthProviders: Set, + ): MigrationDefaultModelInfo? { + profiles ?: return null + val active = profiles.apiConfigs[profiles.currentApiConfigName] ?: return null + val provider = active["apiProvider"]?.jsonPrimitive?.content ?: return null + val mapping = PROVIDER_MAP[provider] ?: return null + if (mapping.oauthSecretKey != null && provider !in oauthProviders) return null + val modelField = mapping.modelField ?: "apiModelId" + val model = active[modelField]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return null + return MigrationDefaultModelInfo(provider = mapping.name, model = model) + } +} + +// ----------------------------------------------------------------------- +// Progress sink interface and no-op implementation +// ----------------------------------------------------------------------- + +interface LegacyMigrationSink { + fun item(progress: LegacyMigrationItemProgress) + fun session(progress: LegacyMigrationSessionProgress) + + companion object { + val None: LegacyMigrationSink = object : LegacyMigrationSink { + override fun item(progress: LegacyMigrationItemProgress) = Unit + override fun session(progress: LegacyMigrationSessionProgress) = Unit + } + } +} + +// ----------------------------------------------------------------------- +// Extension: MigrationItemStatus → MigrationItemProgressStatus +// ----------------------------------------------------------------------- + +private fun MigrationItemStatus.toProgressStatus() = when (this) { + MigrationItemStatus.success -> MigrationItemProgressStatus.success + MigrationItemStatus.warning -> MigrationItemProgressStatus.warning + MigrationItemStatus.error -> MigrationItemProgressStatus.error +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationHttpBackend.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationHttpBackend.kt new file mode 100644 index 00000000000..3bc4e026883 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationHttpBackend.kt @@ -0,0 +1,149 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * OkHttp implementation of [LegacyMigrationBackend] against `kilo serve`. + * + * Uses raw OkHttp so that partial/dynamic JSON payloads (provider auth, global config, + * session-import) can be built exactly as needed without the generated client's type + * constraints getting in the way. Mirrors the pattern in KiloBackendChatManager. + * + * All calls are synchronous blocking. The caller owns threading and error handling. + */ +class LegacyMigrationHttpBackend( + private val client: OkHttpClient, + private val base: String, +) : LegacyMigrationBackend { + + companion object { + private val JSON_TYPE = "application/json".toMediaType() + private val json = Json { ignoreUnknownKeys = true } + } + + // ----------------------------------------------------------------------- + // Auth + // ----------------------------------------------------------------------- + + override fun setAuth(provider: String, auth: JsonObject) { + val body = auth.toString() + val request = Request.Builder() + .url("$base/auth/$provider") + .put(body.toRequestBody(JSON_TYPE)) + .build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("setAuth failed for $provider: HTTP ${resp.code} — ${resp.body?.string()}") + } + } + + // ----------------------------------------------------------------------- + // Global config + // ----------------------------------------------------------------------- + + override fun updateGlobalConfig(config: JsonObject) { + val body = config.toString() + val request = Request.Builder() + .url("$base/global/config") + .patch(body.toRequestBody(JSON_TYPE)) + .build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("updateGlobalConfig failed: HTTP ${resp.code} — ${resp.body?.string()}") + } + } + + // ----------------------------------------------------------------------- + // Session existence check + // ----------------------------------------------------------------------- + + override fun sessionExists(id: String): Boolean { + val request = Request.Builder() + .url("$base/session/$id") + .get() + .build() + client.newCall(request).execute().use { resp -> + return resp.isSuccessful + } + } + + // ----------------------------------------------------------------------- + // Session import + // ----------------------------------------------------------------------- + + override fun importProject(project: JsonObject): String { + val dir = project["worktree"]?.jsonPrimitive?.content ?: "" + val url = "$base/kilocode/session-import/project?directory=${encode(dir)}" + val body = project.toString() + val request = Request.Builder() + .url(url) + .post(body.toRequestBody(JSON_TYPE)) + .build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("importProject failed: HTTP ${resp.code} — ${resp.body?.string()}") + val raw = resp.body?.string() ?: throw RuntimeException("importProject: empty response") + val obj = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() + return obj?.get("id")?.jsonPrimitive?.content + ?: project["id"]?.jsonPrimitive?.content + ?: "" + } + } + + override fun importSession(session: JsonObject): LegacyImportResult { + val dir = session["directory"]?.jsonPrimitive?.content ?: "" + val url = "$base/kilocode/session-import/session?directory=${encode(dir)}" + val body = session.toString() + val request = Request.Builder() + .url(url) + .post(body.toRequestBody(JSON_TYPE)) + .build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("importSession failed: HTTP ${resp.code} — ${resp.body?.string()}") + val raw = resp.body?.string() ?: throw RuntimeException("importSession: empty response") + val obj = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() + val id = obj?.get("id")?.jsonPrimitive?.content + ?: session["id"]?.jsonPrimitive?.content + ?: "" + val skipped = obj?.get("skipped")?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false + return LegacyImportResult(id = id, skipped = skipped) + } + } + + override fun importMessage(message: JsonObject) { + val sessionId = message["sessionID"]?.jsonPrimitive?.content ?: "" + val url = "$base/kilocode/session-import/message?sessionID=${encode(sessionId)}" + val body = message.toString() + val request = Request.Builder() + .url(url) + .post(body.toRequestBody(JSON_TYPE)) + .build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("importMessage failed: HTTP ${resp.code} — ${resp.body?.string()}") + } + } + + override fun importPart(part: JsonObject) { + val sessionId = part["sessionID"]?.jsonPrimitive?.content ?: "" + val url = "$base/kilocode/session-import/part?sessionID=${encode(sessionId)}" + val body = part.toString() + val request = Request.Builder() + .url(url) + .post(body.toRequestBody(JSON_TYPE)) + .build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("importPart failed: HTTP ${resp.code} — ${resp.body?.string()}") + } + } + + // ----------------------------------------------------------------------- + // Utilities + // ----------------------------------------------------------------------- + + private fun encode(value: String): String = + java.net.URLEncoder.encode(value, "UTF-8") +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationJson.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationJson.kt new file mode 100644 index 00000000000..f09cfb45f5a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationJson.kt @@ -0,0 +1,67 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Shared JSON helpers for parsing legacy source data and building migration payloads. + */ +object LegacyMigrationJson { + + val json = Json { ignoreUnknownKeys = true } + + fun parseObject(raw: String): JsonObject? = + runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() + + fun parseArray(raw: String): JsonArray? = + runCatching { json.parseToJsonElement(raw).jsonArray }.getOrNull() + + /** Build a JsonObject from key→value pairs, skipping null values. */ + fun obj(vararg pairs: Pair): JsonObject = + JsonObject(pairs.mapNotNull { (k, v) -> v?.let { k to it } }.toMap()) + + fun str(value: String?): JsonPrimitive? = value?.let { JsonPrimitive(it) } + fun bool(value: Boolean): JsonPrimitive = JsonPrimitive(value) + fun num(value: Long): JsonPrimitive = JsonPrimitive(value) + fun num(value: Double): JsonPrimitive = JsonPrimitive(value) + + fun arr(elements: List): JsonArray = JsonArray(elements) + + fun JsonObject.str(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull + + fun JsonObject.bool(key: String): Boolean? = + this[key]?.let { if (it is JsonNull) null else it.jsonPrimitive.booleanOrNull } + + fun JsonObject.long(key: String): Long? = + this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull() + + fun JsonObject.obj(key: String): JsonObject? = + runCatching { this[key]?.jsonObject }.getOrNull() + + fun JsonObject.arr(key: String): JsonArray? = + runCatching { this[key]?.jsonArray }.getOrNull() + + /** Extract a string value from a dynamic JsonElement (any provider settings map) */ + fun JsonElement?.asString(): String? = + this?.let { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } + + /** Deep-merge two JSON objects: values in [patch] override [base]. */ + fun merge(base: JsonObject, patch: JsonObject): JsonObject { + val result = base.toMutableMap() + for ((k, v) in patch) { + val existing = result[k] + result[k] = if (existing is JsonObject && v is JsonObject) merge(existing, v) else v + } + return JsonObject(result) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationModels.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationModels.kt new file mode 100644 index 00000000000..6efc256fb73 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationModels.kt @@ -0,0 +1,237 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +// --------------------------------------------------------------------------- +// Legacy input models (data shapes from legacy Kilo Code v5.x) +// --------------------------------------------------------------------------- + +data class LegacyProviderProfiles( + val currentApiConfigName: String, + val apiConfigs: Map, + val modeApiConfigs: Map? = null, +) + +data class LegacyMcpServer( + val type: String?, + val command: String?, + val args: List?, + val url: String?, + val env: Map?, + val headers: Map?, + val disabled: Boolean?, + val timeout: Int?, +) + +data class LegacyCustomMode( + val slug: String, + val name: String, + val roleDefinition: String, + val customInstructions: String?, + val whenToUse: String?, + val description: String?, + /** Each element is either a plain String group name or a Pair with options */ + val groups: List, +) + +data class LegacySettings( + val autoApprovalEnabled: Boolean?, + val allowedCommands: List?, + val deniedCommands: List?, + val alwaysAllowReadOnly: Boolean?, + val alwaysAllowReadOnlyOutsideWorkspace: Boolean?, + val alwaysAllowWrite: Boolean?, + val alwaysAllowExecute: Boolean?, + val alwaysAllowMcp: Boolean?, + val alwaysAllowModeSwitch: Boolean?, + val alwaysAllowSubtasks: Boolean?, + val language: String?, + val autocomplete: LegacyAutocompleteSettings?, +) + +data class LegacyAutocompleteSettings( + val enableAutoTrigger: Boolean?, + val enableSmartInlineTaskKeybinding: Boolean?, + val enableChatAutocomplete: Boolean?, +) + +data class LegacyHistoryItem( + val id: String, + val task: String?, + val workspace: String?, + val ts: Long?, + val mode: String?, + val rootTaskId: String?, + val parentTaskId: String?, +) + +// --------------------------------------------------------------------------- +// Detection summary models (what the wizard shows before migration) +// --------------------------------------------------------------------------- + +data class MigrationProviderInfo( + val profileName: String, + val provider: String, + val model: String?, + val hasApiKey: Boolean, + val supported: Boolean, + val newProviderName: String?, +) + +data class MigrationMcpServerInfo( + val name: String, + val type: String, + val disabled: Boolean?, +) + +data class MigrationCustomModeInfo( + val name: String, + val slug: String, + /** Original slug when migrating a modified native mode under a new slug */ + val nativeSlug: String? = null, +) + +data class MigrationSessionInfo( + val id: String, + val title: String, + val directory: String, + val time: Long, +) + +data class MigrationDefaultModelInfo( + val provider: String, + val model: String, +) + +data class LegacyMigrationDetection( + val providers: List, + val mcpServers: List, + val customModes: List, + val sessions: List, + val defaultModel: MigrationDefaultModelInfo?, + val settings: LegacySettings?, + val hasData: Boolean, +) + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +enum class LegacyMigrationStatus { + Completed, + CompletedWithErrors, + Skipped, +} + +// --------------------------------------------------------------------------- +// Migration selections (what the user wants to migrate) +// --------------------------------------------------------------------------- + +data class MigrationAutoApprovalSelections( + val commandRules: Boolean, + val readPermission: Boolean, + val writePermission: Boolean, + val executePermission: Boolean, + val mcpPermission: Boolean, + val taskPermission: Boolean, +) + +data class MigrationSettingsSelections( + val autoApproval: MigrationAutoApprovalSelections, + val language: Boolean, + val autocomplete: Boolean, +) + +data class MigrationSessionSelection( + val id: String, +) + +data class LegacyMigrationSelections( + val providers: List, + val mcpServers: List, + val customModes: List, + val sessions: List, + val defaultModel: Boolean, + val settings: MigrationSettingsSelections, + val keepLegacySettingsFile: Boolean = true, +) + +// --------------------------------------------------------------------------- +// Result / progress models +// --------------------------------------------------------------------------- + +enum class MigrationItemCategory { + provider, mcpServer, customMode, session, defaultModel, settings +} + +enum class MigrationItemStatus { + success, warning, error +} + +data class LegacyMigrationResultItem( + val item: String, + val category: MigrationItemCategory, + val status: MigrationItemStatus, + val message: String? = null, +) + +data class LegacyMigrationReport( + val items: List, +) { + val hasErrors: Boolean get() = items.any { it.status == MigrationItemStatus.error } + val hasWarnings: Boolean get() = items.any { it.status == MigrationItemStatus.warning } +} + +// --------------------------------------------------------------------------- +// Progress sink models +// --------------------------------------------------------------------------- + +enum class MigrationItemProgressStatus { + migrating, success, warning, error +} + +data class LegacyMigrationItemProgress( + val item: String, + val status: MigrationItemProgressStatus, + val message: String? = null, +) + +enum class MigrationSessionPhase { + preparing, storing, skipped, done, summary, error +} + +data class LegacyMigrationSessionProgress( + val session: MigrationSessionInfo?, + val index: Int, + val total: Int, + val phase: MigrationSessionPhase, + val error: String? = null, +) + +// --------------------------------------------------------------------------- +// Cleanup models +// --------------------------------------------------------------------------- + +data class LegacyCleanupTargets( + val providerProfiles: Boolean = false, + val mcpSettings: Boolean = false, + val customModes: Boolean = false, + val globalState: Boolean = false, + val taskHistory: Boolean = false, + val legacySettingsFile: Boolean = false, +) + +data class LegacyCleanupReport( + val cleaned: List, + val errors: List, +) + +// --------------------------------------------------------------------------- +// Import result from backend +// --------------------------------------------------------------------------- + +data class LegacyImportResult( + val id: String, + val skipped: Boolean, +) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt new file mode 100644 index 00000000000..908d81eca53 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyMigrationStore.kt @@ -0,0 +1,36 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonElement + +/** + * Source adapter for legacy Kilo Code v5.x data. + * + * Abstracts over VS Code SecretStorage, globalState, and filesystem access. + * Callers supply raw content; this interface never reads VS Code storage directly. + * The store also persists the migration status key ("kilo.legacyMigrationStatus"). + */ +interface LegacyMigrationStore { + fun status(): LegacyMigrationStatus? + fun mark(status: LegacyMigrationStatus) + + /** Raw JSON from "roo_cline_config_api_config" secret */ + fun providerProfilesRaw(): String? + /** Raw JSON from an OAuth secret key (e.g. openai-codex-oauth-credentials) */ + fun oauthRaw(key: String): String? + /** Raw JSON from mcp_settings.json */ + fun mcpSettingsRaw(): String? + /** Raw YAML or JSON from custom_modes.yaml */ + fun customModesRaw(): String? + /** Raw JSON from customModePrompts globalState key */ + fun customModePromptsRaw(): String? + /** Raw JSON from ghostServiceSettings globalState key */ + fun autocompleteRaw(): String? + /** Value from a globalState key */ + fun globalStateValue(key: String): JsonElement? + /** Raw JSON array from "taskHistory" globalState key */ + fun taskHistoryRaw(): String? + /** Raw JSON array from tasks//api_conversation_history.json */ + fun taskConversationRaw(id: String): String? + + fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyProviderMapping.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyProviderMapping.kt new file mode 100644 index 00000000000..143e1e2b722 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/LegacyProviderMapping.kt @@ -0,0 +1,102 @@ +package ai.kilocode.backend.migration + +/** + * Port of packages/kilo-vscode/src/legacy-migration/provider-mapping.ts + * + * Maps legacy apiProvider values to new provider IDs and key fields. + */ +data class ProviderMapping( + /** New provider ID for the auth endpoint (PUT /auth/:id) */ + val id: String, + /** Field name in provider settings holding the primary API key */ + val key: String, + /** Display name */ + val name: String, + /** Field holding model ID (defaults to "apiModelId") */ + val modelField: String? = null, + /** Field holding custom base URL */ + val urlField: String? = null, + /** Field holding organization/account ID */ + val organizationIdField: String? = null, + /** VS Code secret key holding OAuth credentials stored separately */ + val oauthSecretKey: String? = null, + /** If true, skip auth.set — uses env/ADC-based auth (e.g. Vertex AI) */ + val skipAuth: Boolean = false, + /** Legacy settings fields to write as provider config options */ + val configFields: List? = null, +) + +data class ConfigField( + val from: String, + val option: String, +) + +val PROVIDER_MAP: Map = mapOf( + "anthropic" to ProviderMapping(id = "anthropic", key = "apiKey", name = "Anthropic"), + "openrouter" to ProviderMapping(id = "openrouter", key = "openRouterApiKey", name = "OpenRouter", modelField = "openRouterModelId"), + "openai" to ProviderMapping(id = "openai-compatible", key = "openAiApiKey", name = "OpenAI (Compatible)", modelField = "openAiModelId", urlField = "openAiBaseUrl"), + "openai-native" to ProviderMapping(id = "openai", key = "openAiNativeApiKey", name = "OpenAI", urlField = "openAiNativeBaseUrl"), + "openai-responses" to ProviderMapping(id = "openai", key = "openAiApiKey", name = "OpenAI", modelField = "openAiModelId"), + "gemini" to ProviderMapping(id = "google", key = "geminiApiKey", name = "Google Gemini", urlField = "googleGeminiBaseUrl"), + "vertex" to ProviderMapping( + id = "google-vertex", + key = "vertexJsonCredentials", + name = "Google Vertex AI", + skipAuth = true, + configFields = listOf( + ConfigField(from = "vertexProjectId", option = "project"), + ConfigField(from = "vertexRegion", option = "location"), + ), + ), + "bedrock" to ProviderMapping(id = "amazon-bedrock", key = "awsAccessKey", name = "AWS Bedrock"), + "deepseek" to ProviderMapping(id = "deepseek", key = "deepSeekApiKey", name = "DeepSeek", urlField = "deepSeekBaseUrl"), + "mistral" to ProviderMapping(id = "mistral", key = "mistralApiKey", name = "Mistral"), + "groq" to ProviderMapping(id = "groq", key = "groqApiKey", name = "Groq"), + "xai" to ProviderMapping(id = "xai", key = "xaiApiKey", name = "xAI"), + "fireworks" to ProviderMapping(id = "fireworks", key = "fireworksApiKey", name = "Fireworks"), + "featherless" to ProviderMapping(id = "featherless", key = "featherlessApiKey", name = "Featherless"), + "cerebras" to ProviderMapping(id = "cerebras", key = "cerebrasApiKey", name = "Cerebras"), + "sambanova" to ProviderMapping(id = "sambanova", key = "sambaNovaApiKey", name = "SambaNova"), + "ollama" to ProviderMapping(id = "ollama", key = "ollamaApiKey", name = "Ollama", modelField = "ollamaModelId", urlField = "ollamaBaseUrl"), + "lmstudio" to ProviderMapping(id = "lmstudio", key = "lmStudioBaseUrl", name = "LM Studio", modelField = "lmStudioModelId", urlField = "lmStudioBaseUrl"), + "kilocode" to ProviderMapping(id = "kilo", key = "kilocodeToken", name = "Kilo (Gateway)", modelField = "kilocodeModel", organizationIdField = "kilocodeOrganizationId"), + "litellm" to ProviderMapping(id = "litellm", key = "litellmApiKey", name = "LiteLLM", modelField = "litellmModelId", urlField = "litellmBaseUrl"), + "deepinfra" to ProviderMapping(id = "deepinfra", key = "deepInfraApiKey", name = "DeepInfra", modelField = "deepInfraModelId", urlField = "deepInfraBaseUrl"), + "chutes" to ProviderMapping(id = "chutes", key = "chutesApiKey", name = "Chutes"), + "baseten" to ProviderMapping(id = "baseten", key = "basetenApiKey", name = "Baseten"), + "corethink" to ProviderMapping(id = "corethink", key = "corethinkApiKey", name = "Corethink"), + "unbound" to ProviderMapping(id = "unbound", key = "unboundApiKey", name = "Unbound", modelField = "unboundModelId"), + "requesty" to ProviderMapping(id = "requesty", key = "requestyApiKey", name = "Requesty", modelField = "requestyModelId", urlField = "requestyBaseUrl"), + "huggingface" to ProviderMapping(id = "huggingface", key = "huggingFaceApiKey", name = "Hugging Face", modelField = "huggingFaceModelId"), + "io-intelligence" to ProviderMapping(id = "io-intelligence", key = "ioIntelligenceApiKey", name = "IO Intelligence", modelField = "ioIntelligenceModelId"), + "vercel-ai-gateway" to ProviderMapping(id = "vercel-ai-gateway", key = "vercelAiGatewayApiKey", name = "Vercel AI Gateway", modelField = "vercelAiGatewayModelId"), + "zai" to ProviderMapping(id = "zai", key = "zaiApiKey", name = "Z.ai"), + "moonshot" to ProviderMapping(id = "moonshot", key = "moonshotApiKey", name = "Moonshot", urlField = "moonshotBaseUrl"), + "doubao" to ProviderMapping(id = "doubao", key = "doubaoApiKey", name = "Doubao", urlField = "doubaoBaseUrl"), + "minimax" to ProviderMapping(id = "minimax", key = "minimaxApiKey", name = "MiniMax", urlField = "minimaxBaseUrl"), + "ovhcloud" to ProviderMapping(id = "ovhcloud", key = "ovhCloudAiEndpointsApiKey", name = "OVHcloud AI Endpoints", modelField = "ovhCloudAiEndpointsModelId", urlField = "ovhCloudAiEndpointsBaseUrl"), + "inception" to ProviderMapping(id = "inception", key = "inceptionLabsApiKey", name = "Inception Labs", modelField = "inceptionLabsModelId", urlField = "inceptionLabsBaseUrl"), + "sap-ai-core" to ProviderMapping(id = "sap-ai-core", key = "sapAiCoreServiceKey", name = "SAP AI Core"), + "synthetic" to ProviderMapping(id = "synthetic", key = "syntheticApiKey", name = "Synthetic"), + "apertis" to ProviderMapping(id = "apertis", key = "apertisApiKey", name = "Apertis", modelField = "apertisModelId", urlField = "apertisBaseUrl"), + "openai-codex" to ProviderMapping(id = "openai", key = "", name = "OpenAI (ChatGPT Plus/Pro)", oauthSecretKey = "openai-codex-oauth-credentials"), + "nano-gpt" to ProviderMapping(id = "nano-gpt", key = "nanoGptApiKey", name = "NanoGPT", modelField = "nanoGptModelId"), + "poe" to ProviderMapping(id = "poe", key = "poeApiKey", name = "Poe", modelField = "poeModelId"), + "aihubmix" to ProviderMapping(id = "aihubmix", key = "aihubmixApiKey", name = "AiHubMix", modelField = "aihubmixModelId", urlField = "aihubmixBaseUrl"), + "zenmux" to ProviderMapping(id = "zenmux", key = "zenmuxApiKey", name = "ZenMux", modelField = "zenmuxModelId", urlField = "zenmuxBaseUrl"), +) + +val UNSUPPORTED_PROVIDERS: Set = setOf( + "fake-ai", + "human-relay", + "vscode-lm", + "claude-code", + "qwen-code", + "virtual-quota-fallback", + "glama", + "roo", +) + +val DEFAULT_MODE_SLUGS: Set = setOf( + "code", "build", "architect", "ask", "debug", "orchestrator", "review" +) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionIds.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionIds.kt new file mode 100644 index 00000000000..0ba4781d4a0 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionIds.kt @@ -0,0 +1,31 @@ +package ai.kilocode.backend.migration.session + +import java.security.MessageDigest + +/** + * Deterministic SHA-1 IDs matching VS Code migration formulas exactly. + * + * These must stay compatible with packages/kilo-vscode/src/legacy-migration/sessions/lib/ids.ts + * so that sessions imported by VS Code have the same IDs as those imported by JetBrains. + */ +object LegacySessionIds { + + fun createProjectId(worktree: String = ""): String = hash(worktree) + + fun createSessionId(id: String): String = prefixed("ses", id) + + fun createMessageId(id: String, index: Int): String = prefixed("msg", "$id:$index") + + fun createPartId(id: String, index: Int, part: Int): String = prefixed("prt", "$id:$index:$part") + + fun createExtraPartId(id: String, index: Int, kind: String): String = prefixed("prt", "$id:$index:$kind") + + private fun prefixed(prefix: String, value: String): String = + "${prefix}_migrated_${hash(value).take(26)}" + + fun hash(value: String): String { + val digest = MessageDigest.getInstance("SHA-1") + val bytes = digest.digest(value.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionMessages.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionMessages.kt new file mode 100644 index 00000000000..e544fe77e44 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionMessages.kt @@ -0,0 +1,107 @@ +package ai.kilocode.backend.migration.session + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import ai.kilocode.backend.migration.LegacyHistoryItem + +/** + * Message conversion for legacy conversation history. + * + * Port of packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts + */ +object LegacySessionMessages { + + /** + * Convert legacy API messages to session-import message payloads. + * Only "user" and "assistant" roles are migrated. + */ + fun parseMessages( + conversation: List, + id: String, + dir: String, + item: LegacyHistoryItem? = null, + ): List { + return conversation + .filter { it.role == "user" || it.role == "assistant" } + .mapIndexed { index, entry -> parseMessage(entry, index, id, dir, item) } + .filterNotNull() + } + + private fun parseMessage( + entry: LegacyApiMessage, + index: Int, + id: String, + dir: String, + item: LegacyHistoryItem?, + ): JsonObject? { + val created = entry.ts ?: item?.ts ?: 0L + val msgId = LegacySessionIds.createMessageId(id, index) + val sessionId = LegacySessionIds.createSessionId(id) + + return when (entry.role) { + "user" -> buildJsonObject { + put("id", msgId) + put("sessionID", sessionId) + put("timeCreated", created) + put("data", buildJsonObject { + put("role", "user") + put("time", buildJsonObject { put("created", created) }) + put("agent", "user") + put("model", buildJsonObject { + put("providerID", "legacy") + put("modelID", "legacy") + }) + }) + } + "assistant" -> buildJsonObject { + put("id", msgId) + put("sessionID", sessionId) + put("timeCreated", created) + put("data", buildJsonObject { + put("role", "assistant") + put("time", buildJsonObject { + put("created", created) + put("completed", created) + }) + put("parentID", if (index > 0) LegacySessionIds.createMessageId(id, index - 1) else msgId) + put("modelID", "legacy") + put("providerID", "legacy") + put("mode", item?.mode ?: "code") + put("agent", "main") + put("path", buildJsonObject { + put("cwd", dir) + put("root", dir) + }) + put("cost", 0.0) + put("tokens", buildJsonObject { + put("input", 0L) + put("output", 0L) + put("reasoning", 0L) + put("cache", buildJsonObject { + put("read", 0L) + put("write", 0L) + }) + }) + }) + } + else -> null + } + } +} + +/** + * A single entry in a legacy api_conversation_history.json array. + * Fields beyond role/content/ts are only present for reasoning-capable models. + */ +data class LegacyApiMessage( + val role: String, + val content: Any?, // String or List<*> + val ts: Long?, + val isSummary: Boolean?, + val id: String?, + val type: String?, + val text: String?, + val reasoning_content: String?, + val reasoning_details: List<*>?, +) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionParser.kt new file mode 100644 index 00000000000..715c7c13faa --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionParser.kt @@ -0,0 +1,136 @@ +package ai.kilocode.backend.migration.session + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import ai.kilocode.backend.migration.LegacyHistoryItem +import ai.kilocode.backend.migration.LegacyMigrationJson + +/** + * Parses legacy conversation history files into normalized session-import payloads. + * + * Port of packages/kilo-vscode/src/legacy-migration/sessions/parser.ts and related lib/ files. + */ +object LegacySessionParser { + + data class NormalizedSession( + val project: JsonObject, + val session: JsonObject, + val messages: List, + val parts: List, + ) + + fun parseSession( + id: String, + conversationRaw: String, + item: LegacyHistoryItem? = null, + ): NormalizedSession { + val workspace = LegacySessionPath.normalize(item?.workspace) + val effectiveItem = item?.copy(workspace = workspace.takeIf { it.isNotEmpty() }) + + val project = createProject(effectiveItem) + val session = createSession(id, effectiveItem, project["id"]?.jsonPrimitive?.content ?: "", workspace) + val conversation = parseConversation(conversationRaw) + val messages = LegacySessionMessages.parseMessages(conversation, id, workspace, effectiveItem) + val parts = LegacySessionParts.parseParts(conversation, id, effectiveItem) + + return NormalizedSession(project = project, session = session, messages = messages, parts = parts) + } + + // ----------------------------------------------------------------------- + // Project payload + // ----------------------------------------------------------------------- + + fun createProject(item: LegacyHistoryItem?): JsonObject { + val dir = item?.workspace ?: "" + val ts = item?.ts ?: 0L + return buildJsonObject { + put("id", LegacySessionIds.createProjectId(dir)) + put("worktree", dir) + put("sandboxes", if (dir.isNotEmpty()) JsonArray(listOf(JsonPrimitive(dir))) else JsonArray(emptyList())) + put("timeCreated", ts) + put("timeUpdated", ts) + } + } + + // ----------------------------------------------------------------------- + // Session payload + // ----------------------------------------------------------------------- + + fun createSession(id: String, item: LegacyHistoryItem?, projectId: String, dir: String): JsonObject { + val ts = item?.ts ?: 0L + return buildJsonObject { + put("id", LegacySessionIds.createSessionId(id)) + put("projectID", projectId) + put("slug", id) + put("directory", dir) + put("title", item?.task ?: id) + put("version", "v2") + put("timeCreated", ts) + put("timeUpdated", ts) + } + } + + // ----------------------------------------------------------------------- + // Conversation file parsing + // ----------------------------------------------------------------------- + + fun parseConversation(raw: String): List { + val arr = LegacyMigrationJson.parseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + val obj = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null + val role = obj["role"]?.jsonPrimitive?.content ?: return@mapNotNull null + val ts = obj["ts"]?.jsonPrimitive?.content?.toLongOrNull() + val type = obj["type"]?.jsonPrimitive?.content + val text = obj["text"]?.jsonPrimitive?.content + val reasoningContent = obj["reasoning_content"]?.jsonPrimitive?.content + val reasoningDetails = runCatching { + obj["reasoning_details"]?.let { + (it as? JsonArray)?.map { e -> + val m = runCatching { e.jsonObject }.getOrNull() ?: return@map null + m.entries.associate { (k, v) -> + k to (runCatching { v.jsonPrimitive.content }.getOrNull() ?: "") + } + }?.filterNotNull() + } + }.getOrNull() + + // Parse content: either a String or a List of blocks + val contentRaw = obj["content"] + val content: Any? = when { + contentRaw == null -> null + contentRaw is JsonPrimitive && contentRaw.isString -> contentRaw.content + contentRaw is JsonArray -> contentRaw.map { e -> + val block = runCatching { e.jsonObject }.getOrNull() + block?.entries?.associate { (k, v) -> + k to (runCatching { v.jsonPrimitive.content }.getOrNull() + ?: runCatching { v.jsonObject.toMap() }.getOrNull() + ?: runCatching { (v as JsonArray).map { inner -> + runCatching { inner.jsonPrimitive.content }.getOrNull() + ?: runCatching { inner.jsonObject.entries.associate { (ik, iv) -> ik to runCatching { iv.jsonPrimitive.content }.getOrNull() } }.getOrNull() + }}.getOrNull() + ?: v.toString()) + } + }.filterNotNull() + else -> null + } + + LegacyApiMessage( + role = role, + content = content, + ts = ts, + isSummary = obj["isSummary"]?.jsonPrimitive?.content?.toBooleanStrictOrNull(), + id = obj["id"]?.jsonPrimitive?.content, + type = type, + text = text, + reasoning_content = reasoningContent, + reasoning_details = reasoningDetails, + ) + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionParts.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionParts.kt new file mode 100644 index 00000000000..9159ecc2349 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionParts.kt @@ -0,0 +1,292 @@ +package ai.kilocode.backend.migration.session + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import ai.kilocode.backend.migration.LegacyHistoryItem + +/** + * Part conversion for legacy conversation history. + * + * Port of packages/kilo-vscode/src/legacy-migration/sessions/lib/parts/ + */ +object LegacySessionParts { + + fun parseParts( + conversation: List, + id: String, + item: LegacyHistoryItem? = null, + ): List { + val filtered = conversation.filter { it.role == "user" || it.role == "assistant" } + return filtered.flatMapIndexed { index, entry -> + parseSingleEntryParts(entry, index, id, filtered, item) + } + } + + private fun parseSingleEntryParts( + entry: LegacyApiMessage, + index: Int, + id: String, + conversation: List, + item: LegacyHistoryItem?, + ): List { + val messageId = LegacySessionIds.createMessageId(id, index) + val sessionId = LegacySessionIds.createSessionId(id) + val created = entry.ts ?: item?.ts ?: 0L + val parts = mutableListOf() + + // Simple string content + if (entry.content is String) { + val content = entry.content + if (isEnvironmentDetails(content)) return emptyList() + parts.add(toText(LegacySessionIds.createPartId(id, index, 0), messageId, sessionId, created, content)) + return parts + } + + val contentList = entry.content as? List<*> ?: return emptyList() + + // Reasoning entry (type=reasoning with text field) + if (entry.type == "reasoning" && entry.text != null) { + parts.add(toReasoning(LegacySessionIds.createExtraPartId(id, index, "reasoning"), messageId, sessionId, created, entry.text)) + } + + // Provider-specific reasoning (reasoning_content or reasoning_details) + if (entry.type != "reasoning") { + val reasoning = extractReasoningText(entry) + if (reasoning != null) { + parts.add(toReasoning(LegacySessionIds.createExtraPartId(id, index, "provider-reasoning"), messageId, sessionId, created, reasoning)) + } + } + + contentList.forEachIndexed { partIndex, part -> + val partId = LegacySessionIds.createPartId(id, index, partIndex) + val elem = part as? Map<*, *> ?: return@forEachIndexed + + val type = elem["type"] as? String + + // Text block + if (type == "text") { + val text = elem["text"] as? String ?: return@forEachIndexed + if (isEnvironmentDetails(text)) return@forEachIndexed + parts.add(toText(partId, messageId, sessionId, created, text)) + return@forEachIndexed + } + + // attempt_completion result → visible text + if (type == "tool_use" && elem["name"] == "attempt_completion") { + val input = elem["input"] as? Map<*, *> + val result = input?.get("result") as? String + if (!result.isNullOrBlank()) { + parts.add(toText(partId, messageId, sessionId, created, result)) + } + return@forEachIndexed + } + + // tool_use without matching result + if (type == "tool_use") { + val toolId = elem["id"] as? String + if (thereIsNoToolResult(conversation, toolId)) { + parts.add(toTool(partId, messageId, sessionId, created, elem)) + } + return@forEachIndexed + } + + // tool_result — extract feedback and merge with matching tool_use + if (type == "tool_result") { + val feedback = getFeedbackText(elem["content"]) + if (feedback != null) { + parts.add(toText( + LegacySessionIds.createExtraPartId(id, index, "feedback-$partIndex"), + messageId, sessionId, created, feedback, + )) + } + val toolId = elem["tool_use_id"] as? String + val merged = mergeToolUseAndResult(partId, messageId, sessionId, created, conversation, elem, toolId) + if (merged != null) parts.add(merged) + } + } + + return parts + } + + // ----------------------------------------------------------------------- + // Builders + // ----------------------------------------------------------------------- + + fun toText(partId: String, messageId: String, sessionId: String, created: Long, rawText: String): JsonObject { + val text = cleanLegacyTaskText(rawText) + return buildJsonObject { + put("id", partId) + put("messageID", messageId) + put("sessionID", sessionId) + put("timeCreated", created) + put("data", buildJsonObject { + put("type", "text") + put("text", text) + if (isLegacySystemErrorText(text)) { + put("ignored", true) + put("metadata", buildJsonObject { put("source", "legacy-system-error") }) + } + put("time", buildJsonObject { put("start", created); put("end", created) }) + }) + } + } + + fun toReasoning(partId: String, messageId: String, sessionId: String, created: Long, text: String): JsonObject = + buildJsonObject { + put("id", partId) + put("messageID", messageId) + put("sessionID", sessionId) + put("timeCreated", created) + put("data", buildJsonObject { + put("type", "reasoning") + put("text", text) + put("time", buildJsonObject { put("start", created); put("end", created) }) + }) + } + + fun toTool(partId: String, messageId: String, sessionId: String, created: Long, elem: Map<*, *>): JsonObject { + val tool = elem["name"] as? String ?: "unknown" + val callId = elem["id"] as? String ?: partId + return buildJsonObject { + put("id", partId) + put("messageID", messageId) + put("sessionID", sessionId) + put("timeCreated", created) + put("data", buildJsonObject { + put("type", "tool") + put("callID", callId) + put("tool", tool) + put("state", buildJsonObject { + put("status", "completed") + put("input", mapToJsonObject(elem["input"])) + put("output", tool) + put("title", tool) + put("metadata", JsonObject(emptyMap())) + put("time", buildJsonObject { put("start", created); put("end", created) }) + }) + }) + } + } + + private fun mergeToolUseAndResult( + partId: String, + messageId: String, + sessionId: String, + created: Long, + conversation: List, + result: Map<*, *>, + toolId: String?, + ): JsonObject? { + val toolUse = findToolUseInConversation(conversation, toolId) ?: return null + val tool = toolUse["name"] as? String ?: "unknown" + val callId = toolUse["id"] as? String ?: partId + val output = getTextFromContent(result["content"]) ?: tool + + return buildJsonObject { + put("id", partId) + put("messageID", messageId) + put("sessionID", sessionId) + put("timeCreated", created) + put("data", buildJsonObject { + put("type", "tool") + put("callID", callId) + put("tool", tool) + put("state", buildJsonObject { + put("status", "completed") + put("input", mapToJsonObject(toolUse["input"])) + put("output", output) + put("title", tool) + put("metadata", JsonObject(emptyMap())) + put("time", buildJsonObject { put("start", created); put("end", created) }) + }) + }) + } + } + + // ----------------------------------------------------------------------- + // Utilities + // ----------------------------------------------------------------------- + + private fun findToolUseInConversation(conversation: List, id: String?): Map<*, *>? { + if (id == null) return null + for (entry in conversation) { + val list = entry.content as? List<*> ?: continue + val match = list.filterIsInstance>() + .firstOrNull { it["type"] == "tool_use" && it["id"] == id } + if (match != null) return match + } + return null + } + + fun thereIsNoToolResult(conversation: List, id: String?): Boolean { + if (id == null) return true + return conversation.none { entry -> + (entry.content as? List<*>)?.filterIsInstance>() + ?.any { it["type"] == "tool_result" && it["tool_use_id"] == id } == true + } + } + + fun extractReasoningText(entry: LegacyApiMessage): String? { + val rc = entry.reasoning_content?.trim() + if (!rc.isNullOrEmpty()) return rc + val details = entry.reasoning_details ?: return null + return details.flatMap { item -> + val m = item as? Map<*, *> ?: return@flatMap emptyList() + val text = m["text"] as? String + val reasoning = m["reasoning"] as? String + listOfNotNull(text ?: reasoning) + }.joinToString("\n").trim().takeIf { it.isNotEmpty() } + } + + fun isEnvironmentDetails(input: String): Boolean = + Regex("^\\s*[\\s\\S]*\\s*$", RegexOption.IGNORE_CASE).matches(input) + + fun cleanLegacyTaskText(input: String): String { + val task = Regex("([\\s\\S]*?)", RegexOption.IGNORE_CASE).find(input)?.groupValues?.get(1)?.trim() + if (task != null) return task + if (isEnvironmentDetails(input)) return "" + return input + } + + fun isLegacySystemErrorText(input: String): Boolean = input.trimStart().startsWith("[ERROR]") + + fun getFeedbackText(content: Any?): String? { + val text = getTextFromContent(content) ?: return null + return Regex("([\\s\\S]*?)", RegexOption.IGNORE_CASE) + .find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun getTextFromContent(content: Any?): String? { + if (content is String) return content + val list = content as? List<*> ?: return null + return list.filterIsInstance>() + .mapNotNull { m -> if (m["type"] == "text") m["text"] as? String else null } + .joinToString("\n").trim().takeIf { it.isNotEmpty() } + } + + private fun mapToJsonObject(input: Any?): JsonObject { + if (input == null || input !is Map<*, *>) return JsonObject(emptyMap()) + return JsonObject( + input.entries.mapNotNull { (k, v) -> + val key = k as? String ?: return@mapNotNull null + key to (valueToJsonElement(v) ?: return@mapNotNull null) + }.toMap() + ) + } + + private fun valueToJsonElement(v: Any?): JsonElement? = when (v) { + null -> null + is String -> JsonPrimitive(v) + is Number -> JsonPrimitive(v.toDouble()) + is Boolean -> JsonPrimitive(v) + is Map<*, *> -> mapToJsonObject(v) + is List<*> -> JsonArray(v.mapNotNull { valueToJsonElement(it) }) + else -> JsonPrimitive(v.toString()) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionPath.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionPath.kt new file mode 100644 index 00000000000..0d87aef173b --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/migration/session/LegacySessionPath.kt @@ -0,0 +1,30 @@ +package ai.kilocode.backend.migration.session + +import java.io.File +import java.nio.file.Paths + +/** + * Path normalization for legacy workspace paths. + * + * Port of packages/kilo-vscode/src/legacy-migration/sessions/lib/path.ts + */ +object LegacySessionPath { + + fun normalize(input: String?): String { + val raw = input?.trim() ?: return "" + if (raw.isEmpty()) return "" + val resolved = Paths.get(raw).normalize().toAbsolutePath().toString() + val canonical = normalizeWindowsDriveLetter(resolved) + return runCatching { File(canonical).canonicalPath }.getOrElse { canonical } + } + + private fun normalizeWindowsDriveLetter(input: String): String { + val drivePath = Regex("^[a-zA-Z]:[/\\\\]") + if (!drivePath.containsMatchIn(input)) return input + val head = input[0] + return head.uppercaseChar() + input.substring(1) + } + + fun isWindowsDrivePath(input: String): Boolean = + Regex("^[a-zA-Z]:[/\\\\]").containsMatchIn(input) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt new file mode 100644 index 00000000000..ffcf17f387c --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt @@ -0,0 +1,21 @@ +package ai.kilocode.backend.plugin + +import ai.kilocode.KiloPlugin +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.log.KiloLog +import com.intellij.ide.plugins.DynamicPluginListener +import com.intellij.ide.plugins.IdeaPluginDescriptor +import com.intellij.openapi.components.service +import kotlinx.coroutines.runBlocking + +class KiloBackendDynamicPluginListener : DynamicPluginListener { + private val log = KiloLog.create(KiloBackendDynamicPluginListener::class.java) + + override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) { + if (pluginDescriptor.pluginId != KiloPlugin.id) return + log.info("Shutting down Kilo backend for plugin unload (isUpdate=$isUpdate)") + runBlocking { + service().shutdownForUnload() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt new file mode 100644 index 00000000000..cfe2c9b3cde --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManager.kt @@ -0,0 +1,294 @@ +package ai.kilocode.backend.provider + +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.LoadError +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.rpc.KiloWorkspaceDtoMapper +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.CustomModelFetchDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto +import ai.kilocode.rpc.dto.CustomProviderConfigDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ProviderActionResultDto +import ai.kilocode.rpc.dto.ProviderConnectDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto +import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto +import ai.kilocode.rpc.dto.ProviderOAuthReadyDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit + +internal class KiloBackendProviderSettingsManager( + private val app: KiloBackendAppService, +) { + companion object { + private val LOG = KiloLog.create(KiloBackendProviderSettingsManager::class.java) + private val JSON = "application/json".toMediaType() + private val FETCH = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(15, TimeUnit.SECONDS) + .build() + private const val CALL_TIMEOUT_SECONDS = 15L + private const val OAUTH_CALL_TIMEOUT_SECONDS = 60L + } + + suspend fun state(directory: String): ProviderSettingsDto { + val start = System.currentTimeMillis() + LOG.debug { "provider settings state: start dir=$directory" } + app.awaitReady() + val errors = mutableListOf() + val providers = load("providers", errors) { + KiloCliDataParser.parseProviderSettingsProviders(get("/provider?directory=${enc(directory)}")) + } + val auth = load("provider_auth", errors) { + KiloCliDataParser.parseProviderAuth(get("/provider/auth?directory=${enc(directory)}")) + } ?: emptyMap() + val empty = ParsedConfig(emptyMap(), emptyList(), emptyList()) + val global = load("global_config", errors) { + parsed(get("/global/config")) + } ?: empty + val local = load("workspace_config", errors) { + parsed(get("/config?directory=${enc(directory)}")) + } ?: empty + val cfg = scopedConfig(global.config, local.config) + val disabled = (global.disabled + local.disabled).distinct().sorted() + val enabled = (global.enabled + local.enabled).distinct().sorted() + val disabledScopes = scopedIds(global.disabled, local.disabled) + val enabledScopes = scopedIds(global.enabled, local.enabled) + val result = ProviderSettingsDto( + providers = providers?.first ?: emptyList(), + connected = providers?.second ?: emptyList(), + defaults = providers?.third ?: emptyMap(), + auth = auth, + config = cfg, + disabled = disabled, + enabled = enabled, + disabledScopes = disabledScopes, + enabledScopes = enabledScopes, + errors = errors, + ) + result.providers.forEach { provider -> + val configured = provider.id in result.connected || provider.key != null || provider.source == "config" || provider.id in result.config + LOG.debug { + "provider settings provider: id=${provider.id} source=${provider.source} connected=${provider.id in result.connected} configured=$configured disabled=${provider.id in result.disabled} enabled=${provider.id in result.enabled} hasKey=${provider.key != null} auth=${result.auth[provider.id].orEmpty().map { it.type }.distinct().joinToString(",")} config=${provider.id in result.config} models=${provider.models.size} description=${provider.description?.isNotBlank() == true} noteKey=${provider.metadata?.noteKey} icon=${provider.metadata?.icon} priority=${provider.metadata?.priority}" + } + } + LOG.debug { "provider settings state: completed dir=$directory providers=${result.providers.size} connected=${result.connected.size} auth=${result.auth.size} errors=${result.errors.size} durationMs=${System.currentTimeMillis() - start}" } + return result + } + + suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto { + val body = KiloCliDataParser.buildProviderAuthJson(input.key, input.metadata) + put("/auth/${enc(input.providerId)}", body) + dispose() + return ProviderActionResultDto(state(input.directory)) + } + + suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto { + val body = KiloCliDataParser.buildProviderOAuthJson(input.method, input.inputs) + val raw = post("/provider/${enc(input.providerId)}/oauth/authorize?directory=${enc(input.directory)}", body, OAUTH_CALL_TIMEOUT_SECONDS) + val parsed = KiloCliDataParser.parseOAuthReady(raw) + return ProviderOAuthReadyDto(parsed.first, parsed.second, parsed.third) + } + + suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto { + val body = KiloCliDataParser.buildProviderOAuthJson(input.method, code = input.code) + post("/provider/${enc(input.providerId)}/oauth/callback?directory=${enc(input.directory)}", body, OAUTH_CALL_TIMEOUT_SECONDS) + dispose() + return ProviderActionResultDto(state(input.directory)) + } + + suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto { + val current = state(input.directory) + val provider = current.providers.firstOrNull { it.id == input.providerId } + val cfg = current.config[input.providerId] + if (input.providerId == "kilo") { + return ProviderActionResultDto(current, error = "Kilo Gateway cannot be disconnected from provider settings.") + } + if (provider?.source == "env") { + return ProviderActionResultDto(current, error = "Provider is configured by environment variables.") + } + val configured = input.providerId in current.connected || provider?.key != null || provider?.source == "config" || cfg != null + if (!configured) { + return ProviderActionResultDto(current, error = "Provider is not connected.") + } + if (cfg?.npm == "@ai-sdk/openai-compatible") { + patch(input.directory, cfg.scope, KiloCliDataParser.buildCustomProviderDeletePatch(input.providerId)) + deleteAuth(input.providerId) + dispose() + return ProviderActionResultDto(state(input.directory)) + } + if (provider?.source == "config") { + val scope = cfg?.scope ?: "global" + val ids = disabledFor(current, scope) + input.providerId + patch(input.directory, scope, KiloCliDataParser.buildDisabledProviderPatch(ids)) + dispose() + return ProviderActionResultDto(state(input.directory)) + } + deleteAuth(input.providerId) + dispose() + return ProviderActionResultDto(state(input.directory)) + } + + suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto { + val current = state(input.directory) + val scopes = current.disabledScopes[input.providerId]?.takeIf { it.isNotEmpty() } ?: listOf("global") + scopes.distinct().forEach { scope -> + patch(input.directory, scope, KiloCliDataParser.buildDisabledProviderPatch(disabledFor(current, scope).filter { it != input.providerId })) + } + dispose() + return ProviderActionResultDto(state(input.directory)) + } + + suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto { + val err = validate(input) + if (err != null) return ProviderActionResultDto(state(input.directory), error = err) + patch(KiloCliDataParser.buildCustomProviderPatch(input)) + if (input.envVar.isNullOrBlank()) { + val key = input.apiKey?.takeIf { it.isNotBlank() } + if (key != null) put("/auth/${enc(input.id)}", KiloCliDataParser.buildProviderAuthJson(key, emptyMap())) + } else { + deleteAuth(input.id) + } + dispose() + return ProviderActionResultDto(state(input.directory)) + } + + suspend fun fetch(input: CustomModelFetchDto): CustomModelFetchResultDto { + val url = input.baseUrl.trim().trimEnd('/') + "/models" + return try { + val request = Request.Builder().url(url).get().apply { + input.apiKey?.takeIf { it.isNotBlank() }?.let { header("Authorization", "Bearer $it") } + input.headers.forEach { (key, value) -> header(key, value) } + }.build() + val raw = withContext(Dispatchers.IO) { + FETCH.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) throw IllegalStateException("HTTP ${response.code}: $body") + body + } + } + CustomModelFetchResultDto(KiloCliDataParser.parseModelIds(raw)) + } catch (e: Exception) { + LOG.warn("Custom provider model fetch failed: ${e.message}", e) + CustomModelFetchResultDto(error = e.message) + } + } + + private suspend fun load(resource: String, errors: MutableList, block: suspend () -> T): T? { + val start = System.currentTimeMillis() + LOG.debug { "provider settings $resource: start" } + return try { + val result = block() + LOG.debug { "provider settings $resource: completed durationMs=${System.currentTimeMillis() - start}" } + result + } catch (e: Exception) { + LOG.warn("Provider settings $resource fetch failed durationMs=${System.currentTimeMillis() - start}: ${e.message}", e) + errors.add(KiloWorkspaceDtoMapper.error(LoadError(resource = resource, detail = e.message))) + null + } + } + + private suspend fun get(path: String) = request(Request.Builder().url(url(path)).get().build()) + private suspend fun post(path: String, body: String, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS) = request(Request.Builder().url(url(path)).post(body.toRequestBody(JSON)).build(), timeoutSeconds) + private suspend fun put(path: String, body: String) = request(Request.Builder().url(url(path)).put(body.toRequestBody(JSON)).build()) + private suspend fun patch(body: String) = request(Request.Builder().url(url("/global/config")).patch(body.toRequestBody(JSON)).build()) + private suspend fun patch(directory: String, scope: String, body: String) { + val path = if (scope == "workspace") "/config?directory=${enc(directory)}" else "/global/config" + request(Request.Builder().url(url(path)).patch(body.toRequestBody(JSON)).build()) + } + + private fun parsed(raw: String): ParsedConfig { + val result = KiloCliDataParser.parseProviderConfig(raw) + return ParsedConfig(result.first, result.second.first, result.second.second) + } + + private fun scopedConfig(global: Map, local: Map): Map { + val ids = (global.keys + local.keys).distinct() + return ids.mapNotNull { id -> + val localCfg = local[id] + val globalCfg = global[id] + val cfg = localCfg ?: globalCfg ?: return@mapNotNull null + val scope = if (localCfg != null && (globalCfg == null || localCfg.withoutScope() != globalCfg.withoutScope())) "workspace" else "global" + id to cfg.copy(scope = scope) + }.toMap() + } + + private fun scopedIds(global: List, local: List): Map> { + val globals = global.toSet() + return (global + local).distinct().associateWith { id -> + buildList { + if (id in globals) add("global") + if (id in local && id !in globals) add("workspace") + } + } + } + + private fun disabledFor(state: ProviderSettingsDto, scope: String): List = + state.disabledScopes.entries.filter { scope in it.value }.map { it.key } + + private fun CustomProviderConfigDto.withoutScope() = copy(scope = "global") + + private suspend fun deleteAuth(id: String) { + runCatching { request(Request.Builder().url(url("/auth/${enc(id)}")).delete().build()) } + .onFailure { LOG.warn("Provider auth delete failed for $id: ${it.message}", it) } + } + + private suspend fun dispose() { + runCatching { request(Request.Builder().url(url("/global/dispose")).post("{}".toRequestBody(JSON)).build()) } + .onFailure { LOG.debug { "Provider settings dispose skipped: ${it.message}" } } + } + + private suspend fun request(request: Request, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS): String { + val start = System.currentTimeMillis() + LOG.debug { "provider settings http: start ${request.method} ${request.url.encodedPath}" } + val http = app.http?.newBuilder() + ?.callTimeout(timeoutSeconds, TimeUnit.SECONDS) + ?.readTimeout(timeoutSeconds, TimeUnit.SECONDS) + ?.build() ?: throw IllegalStateException("Kilo HTTP client is unavailable") + return withContext(Dispatchers.IO) { + try { + http.newCall(request.newBuilder().header("Accept", "application/json").build()).execute().use { response -> + val body = response.body?.string().orEmpty() + LOG.debug { "provider settings http: completed ${request.method} ${request.url.encodedPath} code=${response.code} bytes=${body.length} durationMs=${System.currentTimeMillis() - start}" } + if (!response.isSuccessful) throw IllegalStateException("HTTP ${response.code}: $body") + body + } + } catch (e: Exception) { + LOG.debug { "provider settings http: failed ${request.method} ${request.url.encodedPath} durationMs=${System.currentTimeMillis() - start}: ${e.message}" } + throw e + } + } + } + + private fun url(path: String) = "http://127.0.0.1:${app.port}$path" + + private fun enc(value: String) = URLEncoder.encode(value, StandardCharsets.UTF_8) + + private fun validate(input: CustomProviderSaveDto): String? { + val env = input.envVar + if (!Regex("^[a-zA-Z0-9_-]+$").matches(input.id.trim())) return "Provider ID can only contain letters, numbers, underscores, and hyphens." + if (!input.baseUrl.startsWith("http://") && !input.baseUrl.startsWith("https://")) return "Base URL must start with http:// or https://." + if (!env.isNullOrBlank() && !Regex("^[A-Za-z_][A-Za-z0-9_]*$").matches(env)) return "Environment variable name is invalid." + if (input.headers.keys.any { it.isBlank() }) return "Header names cannot be empty." + if (input.models.any { it.id.isBlank() }) return "Model IDs cannot be empty." + return null + } + + private data class ParsedConfig( + val config: Map, + val disabled: List, + val enabled: List, + ) +} 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 c02afa53371..27b6ce1f4a8 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 @@ -4,6 +4,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.telemetry.KiloBackendTelemetry import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress @@ -14,6 +15,7 @@ import ai.kilocode.jetbrains.api.model.ConfigAgent import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.DeviceAuthDto @@ -30,6 +32,7 @@ import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import ai.kilocode.rpc.dto.ProfileStatusDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import com.intellij.openapi.components.service import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -58,15 +61,35 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun reinstall() = app.reinstall() - override suspend fun modelState(): ModelStateDto = app.models.state() + override suspend fun modelState(): ModelStateDto { + app.requireReady() + return app.models.state() + } - override suspend fun updateModelFavorite(update: ModelFavoriteUpdateDto): ModelStateDto = app.models.favorite(update) + override suspend fun updateModelFavorite(update: ModelFavoriteUpdateDto): ModelStateDto { + app.requireReady() + return app.models.favorite(update) + } - override suspend fun updateModelSelection(update: ModelSelectionUpdateDto): ModelStateDto = app.models.selection(update) + override suspend fun updateModelSelection(update: ModelSelectionUpdateDto): ModelStateDto { + app.requireReady() + return app.models.selection(update) + } + + override suspend fun clearModelSelection(agent: String): ModelStateDto { + app.requireReady() + return app.models.clear(agent) + } - override suspend fun clearModelSelection(agent: String): ModelStateDto = app.models.clear(agent) + override suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto { + app.requireReady() + return app.models.variant(update) + } - override suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto = app.models.variant(update) + override suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto { + app.requireReady() + return appStateDto(app.updateConfig(patch)) + } override suspend fun refreshProfile(): ProfileDto? = app.refreshProfile()?.let(::profileDto) @@ -79,6 +102,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun setOrganization(organizationId: String?): ProfileDto? = app.setOrganization(organizationId)?.let(::profileDto) + override suspend fun captureTelemetry(capture: TelemetryCaptureDto) { + service().capture(app.http, app.port, capture.event, capture.properties) + } + private fun dto(state: KiloAppState): KiloAppStateDto = appStateDto(state) } @@ -91,6 +118,10 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto = status = KiloAppStatusDto.LOADING, progress = progress(state.progress), ) + is KiloAppState.MigrationRequired -> KiloAppStateDto( + status = KiloAppStatusDto.MIGRATION_REQUIRED, + migration = MigrationRpcMapper.toDto(state.detection), + ) is KiloAppState.Ready -> KiloAppStateDto( status = KiloAppStatusDto.READY, progress = LoadProgressDto( @@ -144,6 +175,9 @@ private fun warning(w: ConfigWarning) = ConfigWarningDto( private fun config(c: Config) = ConfigDto( model = c.model, + smallModel = c.smallModel, + subagentModel = c.subagentModel, + subagentVariant = c.subagentVariant, agent = agents(c.agent), ) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt new file mode 100644 index 00000000000..0853ba8d0d4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiImpl.kt @@ -0,0 +1,126 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService +import ai.kilocode.backend.migration.LegacyMigrationResultItem +import ai.kilocode.backend.migration.LegacyMigrationSink +import ai.kilocode.backend.migration.LegacyMigrationStatus +import ai.kilocode.backend.migration.MigrationItemCategory +import ai.kilocode.backend.migration.MigrationItemStatus +import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.backend.app.KiloBackendMigrationManager +import ai.kilocode.log.KiloLog +import com.intellij.openapi.components.service +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withContext + +class KiloMigrationRpcApiImpl : KiloMigrationRpcApi { + + companion object { + private val LOG = KiloLog.create(KiloMigrationRpcApiImpl::class.java) + } + + private val app: KiloBackendAppService get() = service() + private val storeService: KiloBackendLegacyMigrationStoreService get() = service() + + private fun manager(): KiloBackendMigrationManager { + val http = app.http ?: throw IllegalStateException("Not connected") + val port = app.port + return KiloBackendMigrationManager(http, port) + } + + override suspend fun status(): LegacyMigrationStatusDto? { + val store = storeService.store() + val status = withContext(Dispatchers.IO) { store.status() } ?: return null + LOG.info("Migration RPC status: status=$status") + return MigrationRpcMapper.toDto(status) + } + + override suspend fun detect(): LegacyMigrationDetectionDto { + LOG.info("Migration RPC detect: started") + val mgr = manager() + val store = storeService.store() + val detection = withContext(Dispatchers.IO) { mgr.detect(store) } + LOG.info("Migration RPC detect: completed hasData=${detection.hasData} providers=${detection.providers.size} mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size}") + return MigrationRpcMapper.toDto(detection) + } + + override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow { + LOG.info("Migration RPC migrate: starting ${selectionSummary(selections)}") + val mgr = manager() + val domainSelections = MigrationRpcMapper.fromDto(selections) + val store = storeService.store() + return channelFlow { + withContext(Dispatchers.IO) { + val sink = object : LegacyMigrationSink { + override fun item(progress: ai.kilocode.backend.migration.LegacyMigrationItemProgress) { + LOG.info("Migration RPC item: item=${progress.item} status=${progress.status} message=${progress.message}") + trySendBlocking(LegacyMigrationEventDto.Item(MigrationRpcMapper.toDto(progress))) + } + override fun session(progress: ai.kilocode.backend.migration.LegacyMigrationSessionProgress) { + LOG.info("Migration RPC session: phase=${progress.phase} session=${progress.session?.id} error=${progress.error}") + trySendBlocking(LegacyMigrationEventDto.Session(MigrationRpcMapper.toDto(progress))) + } + } + val report = runCatching { + mgr.migrate(store, domainSelections, sink) + }.getOrElse { e -> + val msg = e.message ?: "Migration failed" + LOG.warn("Migration RPC migrate: failed message=$msg", e) + val errItem = LegacyMigrationResultItem( + item = "Migration", + category = MigrationItemCategory.settings, + status = MigrationItemStatus.error, + message = msg, + ) + trySendBlocking(LegacyMigrationEventDto.Complete(listOf(MigrationRpcMapper.toDto(errItem)))) + return@withContext + } + LOG.info("Migration RPC migrate: complete items=${report.items.size} errors=${report.items.count { it.status == MigrationItemStatus.error }}") + trySendBlocking(LegacyMigrationEventDto.Complete(report.items.map(MigrationRpcMapper::toDto))) + } + } + } + + override suspend fun skip() { + LOG.info("Migration RPC skip: marking skipped") + val store = storeService.store() + withContext(Dispatchers.IO) { store.mark(LegacyMigrationStatus.Skipped) } + app.resumeAfterMigration() + LOG.info("Migration RPC skip: resumed app load") + } + + override suspend fun finalize(status: LegacyMigrationStatusDto) { + LOG.info("Migration RPC finalize: status=$status") + val store = storeService.store() + val domain = MigrationRpcMapper.fromDto(status) + if (domain != LegacyMigrationStatus.Skipped) { + withContext(Dispatchers.IO) { store.mark(domain) } + } + app.resumeAfterMigration() + LOG.info("Migration RPC finalize: resumed app load") + } + + override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto { + LOG.info("Migration RPC cleanup: providerProfiles=${targets.providerProfiles} mcp=${targets.mcpSettings} modes=${targets.customModes} state=${targets.globalState} history=${targets.taskHistory} file=${targets.legacySettingsFile}") + val mgr = manager() + val store = storeService.store() + val report = withContext(Dispatchers.IO) { mgr.cleanup(store, MigrationRpcMapper.fromDto(targets)) } + LOG.info("Migration RPC cleanup: cleaned=${report.cleaned.size} errors=${report.errors.size}") + return MigrationRpcMapper.toDto(report) + } + + private fun selectionSummary(selections: LegacyMigrationSelectionsDto): String = + "providers=${selections.providers.size} mcp=${selections.mcpServers.size} modes=${selections.customModes.size} sessions=${selections.sessions.size} model=${selections.defaultModel} settings=true keepFile=${selections.keepLegacySettingsFile}" +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiProvider.kt new file mode 100644 index 00000000000..eb6e67fb8c7 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloMigrationRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloMigrationRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloMigrationRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloMigrationRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiImpl.kt new file mode 100644 index 00000000000..76aab9a5426 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiImpl.kt @@ -0,0 +1,51 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.provider.KiloBackendProviderSettingsManager +import ai.kilocode.rpc.KiloProviderRpcApi +import ai.kilocode.rpc.dto.CustomModelFetchDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.ProviderActionResultDto +import ai.kilocode.rpc.dto.ProviderConnectDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto +import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto +import ai.kilocode.rpc.dto.ProviderOAuthReadyDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import com.intellij.openapi.components.service +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.log.KiloLog + +internal class KiloProviderRpcApiImpl : KiloProviderRpcApi { + companion object { + private val LOG = KiloLog.create(KiloProviderRpcApiImpl::class.java) + } + + private val manager: KiloBackendProviderSettingsManager + get() = KiloBackendProviderSettingsManager(service()) + + override suspend fun state(directory: String): ProviderSettingsDto = logged("state dir=$directory") { manager.state(directory) } + override suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto = logged("connect provider=${input.providerId}") { manager.connect(input) } + override suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto = logged("authorize provider=${input.providerId}") { manager.authorize(input) } + override suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto = logged("callback provider=${input.providerId}") { manager.callback(input) } + override suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto = logged("disconnect provider=${input.providerId}") { manager.disconnect(input) } + override suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto = logged("enable provider=${input.providerId}") { manager.enable(input) } + override suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto = logged("save custom provider=${input.id}") { manager.saveCustom(input) } + override suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto = logged("fetch custom models") { manager.fetch(input) } + + private suspend fun logged(name: String, block: suspend () -> T): T { + val start = System.currentTimeMillis() + LOG.info("provider rpc $name: start") + return try { + val result = block() + LOG.info("provider rpc $name: completed durationMs=${System.currentTimeMillis() - start}") + result + } catch (e: Exception) { + LOG.warn("provider rpc $name: failed durationMs=${System.currentTimeMillis() - start}", e) + throw e + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiProvider.kt new file mode 100644 index 00000000000..5e56ec3c6a1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProviderRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloProviderRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloProviderRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloProviderRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 0192304ef4a..0f079d2c097 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -16,6 +16,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -42,45 +43,52 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { } private val workspaces: KiloBackendWorkspaceManager - get() = service().workspaces + get() = app.workspaces private val sessions: KiloBackendSessionManager - get() = service().sessions + get() = app.sessions private val chat: KiloBackendChatManager - get() = service().chat + get() = app.chat + + private val app: KiloBackendAppService + get() = service() override suspend fun list(directory: String): SessionListDto = - workspaces.get(directory).sessions() + ready { workspaces.get(directory).sessions() } override suspend fun recent(directory: String, limit: Int): SessionListDto = - sessions.recent(directory, limit) + ready { sessions.recent(directory, limit) } override suspend fun create(directory: String): SessionDto { + app.requireReady() LOG.info("create session: directory=$directory") return workspaces.get(directory).createSession() } override suspend fun get(id: String, directory: String): SessionDto { + app.requireReady() val dir = sessions.getDirectory(id, directory) return sessions.get(id, dir) } override suspend fun delete(id: String, directory: String) { + app.requireReady() val dir = sessions.getDirectory(id, directory) workspaces.get(dir).deleteSession(id) } override suspend fun rename(id: String, directory: String, title: String): ai.kilocode.rpc.dto.SessionDto { + app.requireReady() val dir = sessions.getDirectory(id, directory) return sessions.rename(id, dir, title) } override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto = - sessions.cloudSessions(directory, cursor, limit, gitUrl) + ready { sessions.cloudSessions(directory, cursor, limit, gitUrl) } override suspend fun importCloudSession(id: String, directory: String): SessionDto = - sessions.importCloudSession(id, directory) + ready { sessions.importCloudSession(id, directory) } override suspend fun statuses(): Flow> = sessions.statuses @@ -93,19 +101,32 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { // ------ chat ------ + override suspend fun enhancePrompt(directory: String, text: String): String = + ready { chat.enhancePrompt(directory, text) } + override suspend fun prompt(id: String, directory: String, prompt: PromptDto) { + app.requireReady() LOG.info("prompt RPC: session=$id, dir=$directory, parts=${prompt.parts.size}") chat.prompt(id, directory, prompt) } + override suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) { + app.requireReady() + LOG.info("command RPC: session=$id, dir=$directory, command=$command, parts=${prompt.parts.size}") + chat.command(id, directory, command, arguments, prompt) + } + override suspend fun abort(id: String, directory: String) = - chat.abort(id, directory) + ready { chat.abort(id, directory) } override suspend fun compact(id: String, directory: String, model: ModelSelectionDto) = - chat.compact(id, directory, model) + ready { chat.compact(id, directory, model) } override suspend fun messages(id: String, directory: String): List = - chat.messages(id, directory) + ready { chat.messages(id, directory) } + + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = + ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } override suspend fun events(id: String, directory: String): Flow = chat.events.filter { event -> @@ -116,6 +137,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.PartRemoved -> event.sessionID is ChatEventDto.TurnOpen -> event.sessionID is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.SessionCreated -> event.sessionID is ChatEventDto.Error -> event.sessionID is ChatEventDto.MessageRemoved -> event.sessionID is ChatEventDto.PermissionAsked -> event.sessionID @@ -130,40 +152,55 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID } - val passes = sid == null || sid == id + val passes = event is ChatEventDto.SessionCreated || sid == null || sid == id if (passes) LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" } else LOG.debug { "${ChatLogSummary.sid(id)} pass=false srcSid=$sid ${ChatLogSummary.eventBody(event)}" } + if (passes && event is ChatEventDto.SessionStatusChanged && event.status.type != "busy") { + LOG.info( + "${ChatLogSummary.sid(id)} kind=status route=rpc-events pass=true " + + ChatLogSummary.status(event.status), + ) + } passes } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = - chat.updateConfig(directory, config) + ready { chat.updateConfig(directory, config) } // ------ permission / question resolution ------ override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { + app.requireReady() LOG.info("replyPermission: requestId=$requestId, reply=${reply.reply}") chat.replyPermission(requestId, directory, reply) } override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) { + app.requireReady() LOG.info("savePermissionRules: requestId=$requestId") chat.savePermissionRules(requestId, directory, rules) } override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) { + app.requireReady() LOG.info("replyQuestion: requestId=$requestId, answers=${answers.answers.size}") chat.replyQuestion(requestId, directory, answers) } override suspend fun rejectQuestion(requestId: String, directory: String) { + app.requireReady() LOG.info("rejectQuestion: requestId=$requestId") chat.rejectQuestion(requestId, directory) } override suspend fun pendingPermissions(directory: String): List = - chat.pendingPermissions(directory) + ready { chat.pendingPermissions(directory) } override suspend fun pendingQuestions(directory: String): List = - chat.pendingQuestions(directory) + ready { chat.pendingQuestions(directory) } + + private suspend fun ready(block: suspend () -> T): T { + app.requireReady() + return block() + } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt new file mode 100644 index 00000000000..08559711ff0 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt @@ -0,0 +1,95 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.LoadError +import ai.kilocode.backend.workspace.AgentData +import ai.kilocode.backend.workspace.AgentInfo +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.KiloWorkspaceLoadProgress +import ai.kilocode.backend.workspace.ModelInfo +import ai.kilocode.backend.workspace.ProviderData +import ai.kilocode.backend.workspace.ProviderInfo +import ai.kilocode.backend.workspace.SkillInfo +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ModelLimitDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import ai.kilocode.rpc.dto.SkillDto + +internal object KiloWorkspaceDtoMapper { + fun error(e: LoadError) = LoadErrorDto( + resource = e.resource, + status = e.status, + detail = e.detail, + ) + + fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( + providers = p.providers, + agents = p.agents, + commands = p.commands, + skills = p.skills, + ) + + fun providers(d: ProviderData) = ProvidersDto( + providers = d.providers.map(::provider), + connected = d.connected, + defaults = d.defaults, + ) + + fun agents(d: AgentData) = AgentsDto( + agents = d.agents.map(::agent), + all = d.all.map(::agent), + default = d.default, + ) + + fun command(c: CommandInfo) = CommandDto( + name = c.name, + description = c.description, + source = c.source, + hints = c.hints, + ) + + fun skill(s: SkillInfo) = SkillDto( + name = s.name, + description = s.description, + location = s.location, + ) + + private fun provider(p: ProviderInfo) = ProviderDto( + id = p.id, + name = p.name, + source = p.source, + models = p.models.mapValues { (_, m) -> model(m) }, + ) + + private fun model(m: ModelInfo) = ModelDto( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.free, + byok = m.byok, + status = m.status, + recommendedIndex = m.recommendedIndex, + variants = m.variants, + limit = m.limit?.let { ModelLimitDto(it.context, it.input, it.output) }, + mayTrainOnYourPrompts = m.mayTrainOnYourPrompts, + ) + + private fun agent(a: AgentInfo) = AgentDto( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index eb1dcd02a75..5b54f93cfa8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -1,41 +1,68 @@ -@file:Suppress("UnstableApiUsage") - package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.app.LoadError +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.cli.buildKiloCliEnv +import ai.kilocode.backend.cli.KiloCliConfigPath import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo -import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager -import ai.kilocode.backend.workspace.KiloWorkspaceLoadProgress import ai.kilocode.backend.workspace.KiloWorkspaceState -import ai.kilocode.backend.workspace.ModelInfo -import ai.kilocode.backend.workspace.ProviderData -import ai.kilocode.backend.workspace.ProviderInfo -import ai.kilocode.backend.workspace.SkillInfo +import ai.kilocode.log.KiloLog +import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi -import ai.kilocode.rpc.dto.AgentDto -import ai.kilocode.rpc.dto.AgentsDto -import ai.kilocode.rpc.dto.CommandDto -import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto +import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.ModelDto -import ai.kilocode.rpc.dto.ModelLimitDto -import ai.kilocode.rpc.dto.ProviderDto -import ai.kilocode.rpc.dto.ProvidersDto -import ai.kilocode.rpc.dto.SkillDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.ide.actions.searcheverywhere.FoundItemDescriptor +import com.intellij.ide.util.gotoByName.ChooseByNameInScopeItemProvider +import com.intellij.ide.util.gotoByName.ChooseByNamePopup +import com.intellij.ide.util.gotoByName.ChooseByNameViewModel +import com.intellij.ide.util.gotoByName.GotoFileModel +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.readAction import com.intellij.openapi.components.service +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.IndexNotReadyException +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.navigation.NavigationItem +import com.intellij.psi.PsiFileSystemItem +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.util.indexing.FindSymbolParameters +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume /** * Backend implementation of [KiloWorkspaceRpcApi]. @@ -45,9 +72,25 @@ import kotlinx.coroutines.flow.map * directory (including worktrees) can get a workspace. */ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { + companion object { + private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java) + private const val SCHEMA = "https://app.kilo.ai/config.json" + private val MODERN = listOf("kilo.jsonc", "kilo.json") + private val LEGACY = listOf("opencode.jsonc", "opencode.json") + private val GLOBAL = MODERN + LEGACY + "config.json" + private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") + private const val SEARCH_CAP = 2_000 + private const val DIFF_CAP = 200_000 + private val CONFIG = """{ + "${'$'}schema": "$SCHEMA" +} +""" + } private val app: KiloBackendAppService get() = service() + private val gitCache = ConcurrentHashMap() + private val manager: KiloBackendWorkspaceManager get() = app.workspaces @@ -83,6 +126,288 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { manager.get(directory).reload() } + override suspend fun models(directory: String): ModelsWorkspaceDto { + app.requireReady() + val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") + val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") + val errors = mutableListOf() + + val prov = try { + val raw = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("http://127.0.0.1:${app.port}/provider?directory=${encode(directory)}") + .get() + .build() + http.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $body") + body + } + } + KiloCliDataParser.parseProviders(raw) + } catch (e: Exception) { + LOG.warn("Models settings providers fetch failed for $directory: ${e.message}", e) + errors.add(LoadError(resource = "providers", detail = e.message)) + null + } + + val agents = try { + val response = api.appAgents(directory = directory) + val mapped = response.map(::agent) + val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } + AgentData( + agents = visible.map(::agent), + all = mapped, + default = visible.firstOrNull()?.name ?: "code", + ) + } catch (e: Exception) { + LOG.warn("Models settings agents fetch failed for $directory: ${e.message}", e) + errors.add(LoadError(resource = "agents", detail = e.message)) + null + } + + return ModelsWorkspaceDto( + providers = prov?.let(KiloWorkspaceDtoMapper::providers), + agents = agents?.let(KiloWorkspaceDtoMapper::agents), + errors = errors.map(KiloWorkspaceDtoMapper::error), + ) + } + + override suspend fun files(directory: String, path: String): List { + val item = clean(path) ?: return emptyList() + val file = file(item) ?: return emptyList() + val bases = listOf(directory) + ProjectManager.getInstance().openProjects + .asSequence() + .filter { !it.isDefault } + .mapNotNull { it.basePath } + .filter { it != directory } + .toList() + val paths = if (file.isAbsolute) listOf(file) else bases.mapNotNull { base -> + file(base)?.resolve(file)?.normalize() + } + val found = linkedMapOf() + for (target in paths) { + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue + found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory) + } + return found.values.toList() + } + + override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { + val base = file(clean(directory) ?: directory) ?: return FileSearchResultDto() + val git = withContext(Dispatchers.IO) { gitAvailable(base) } + val project = project(base) ?: return FileSearchResultDto(git = git) + if (DumbService.getInstance(project).isDumb) return FileSearchResultDto(indexing = true, git = git) + return try { + val files = readAction { search(project, base, query, limit.coerceIn(1, 200)) } + FileSearchResultDto(files = files, git = git) + } catch (e: IndexNotReadyException) { + FileSearchResultDto(indexing = true, git = git) + } catch (e: LinkageError) { + LOG.warn("file search API unavailable; returning no suggestions", e) + FileSearchResultDto(git = git) + } + } + + override suspend fun gitChanges(directory: String): String? = withContext(Dispatchers.IO) { + val base = file(clean(directory) ?: directory) ?: return@withContext null + if (!gitAvailable(base)) return@withContext null + val unstaged = git(base, "diff") + val staged = git(base, "diff", "--staged") + val text = listOf(unstaged, staged).filter { it.isNotBlank() }.joinToString("\n") + text.takeIf { it.isNotBlank() }?.take(DIFF_CAP) + } + + override suspend fun openFile(path: String): Boolean { + val item = clean(path) ?: return false + val target = file(item)?.takeIf { it.isAbsolute } ?: return false + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false + val project = project(target) ?: run { + LOG.warn("No project available to open file: $path") + return false + } + navigate(project, vf) + return true + } + + override suspend fun localConfigTarget(directory: String): ConfigTargetDto = withContext(Dispatchers.IO) { + target(localConfig(directory)) + } + + override suspend fun globalConfigTarget(): ConfigTargetDto = withContext(Dispatchers.IO) { + target(globalConfig()) + } + + override suspend fun openLocalConfig(directory: String): Boolean = openConfig(withContext(Dispatchers.IO) { + localConfig(directory) + }) + + override suspend fun openGlobalConfig(): Boolean = openConfig(withContext(Dispatchers.IO) { + globalConfig() + }) + + private suspend fun openConfig(path: Path): Boolean { + val target = withContext(Dispatchers.IO) { + Files.createDirectories(path.parent) + if (!Files.exists(path)) Files.writeString(path, CONFIG, StandardCharsets.UTF_8) + path + } + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false + val project = project(target) ?: run { + LOG.warn("No project available to open config file: $target") + return false + } + navigate(project, vf) + return true + } + + private fun localConfig(directory: String): Path { + val root = file(clean(directory) ?: directory)?.takeIf { it.isAbsolute } ?: Path.of(directory).normalize() + val dirs = LOCAL_DIRS.map { root.resolve(it) } + root + val found = dirs.asSequence() + .flatMap { dir -> (MODERN + LEGACY).asSequence().map { name -> dir.resolve(name) } } + .firstOrNull { Files.exists(it) } + return found ?: root.resolve(".kilo").resolve("kilo.jsonc") + } + + private fun globalConfig(): Path { + val env = buildKiloCliEnv("config") + val root = KiloCliConfigPath.resolve(env).toPath().normalize() + return GLOBAL.asSequence() + .map { root.resolve(it) } + .firstOrNull { Files.exists(it) } + ?: root.resolve("kilo.jsonc") + } + + private fun target(path: Path): ConfigTargetDto { + val raw = path.toString() + return ConfigTargetDto(raw, FileUtil.getLocationRelativeToUserHome(raw, false), Files.exists(path)) + } + + private fun clean(path: String): String? { + val result = normalizeWorkspacePath(path) + if (result == null && path.isNotBlank()) LOG.debug { "Failed to normalize workspace file path: $path" } + return result + } + + private fun file(path: String): Path? = try { + Path.of(path).normalize() + } catch (e: InvalidPathException) { + LOG.debug { "Invalid workspace file path: $path (${e.message})" } + null + } + + private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont -> + ApplicationManager.getApplication().invokeLater({ + OpenFileDescriptor(project, file).navigate(true) + if (cont.isActive) cont.resume(Unit) + }, ModalityState.any()) + } + + private fun project(path: Path): Project? { + val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault } + return projects.firstOrNull { item -> + val base = item.basePath?.let(::file) ?: return@firstOrNull false + path.startsWith(base) + } ?: projects.firstOrNull() + } + + // Uses the IDE Go-to-File engine (com.intellij.ide.util.gotoByName.*). These are public but + // unstable lang-impl classes (not @ApiStatus.Internal) -- the same engine behind Search Everywhere, + // chosen for proven large-repo performance. searchFiles() degrades gracefully on LinkageError. + @Suppress("UnstableApiUsage") + private fun search(project: Project, base: Path, query: String, limit: Int): List { + val text = query.trim() + if (text.isBlank()) return roots(project, base, limit) + val scope = GlobalSearchScope.projectScope(project) + val model = object : GotoFileModel(project) { + override fun acceptItem(item: NavigationItem): Boolean { + val psi = item as? PsiFileSystemItem ?: return false + val path = file(psi.virtualFile.path) ?: return false + return path.startsWith(base) && super.acceptItem(item) + } + + override fun loadInitialCheckBoxState(): Boolean = false + + override fun saveInitialCheckBoxState(state: Boolean) {} + } + val view = object : ChooseByNameViewModel { + override fun getProject(): Project = project + + override fun getModel() = model + + override fun isSearchInAnyPlace(): Boolean = model.useMiddleMatching() + + override fun transformPattern(pattern: String): String = ChooseByNamePopup.getTransformedPattern(pattern, model) + + override fun canShowListForEmptyPattern(): Boolean = false + + override fun getMaximumListSizeLimit(): Int = limit + } + val provider = model.getItemProvider(null) + val params = FindSymbolParameters.wrap(text, scope) + val found = mutableListOf>() + val indicator = EmptyProgressIndicator() + if (provider is ChooseByNameInScopeItemProvider) { + provider.filterElementsWithWeights(view, params, indicator) { item -> + found += item + found.size < SEARCH_CAP + } + } else { + provider.filterElements(view, text, false, indicator) { item -> + found += FoundItemDescriptor(item, 0) + found.size < SEARCH_CAP + } + } + return found.asSequence() + .sortedByDescending { it.weight } + .mapNotNull { item -> (item.item as? PsiFileSystemItem)?.virtualFile } + .mapNotNull { vf -> fileDto(base, vf) } + .distinctBy { it.path } + .take(limit) + .toList() + } + + private fun roots(project: Project, base: Path, limit: Int): List { + val root = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(base) ?: return emptyList() + val index = ProjectFileIndex.getInstance(project) + return root.children.asSequence() + .filter { it.name != ".git" } + .filterNot { index.isExcluded(it) } + .mapNotNull { fileDto(base, it) } + .sortedWith( + compareByDescending { it.directory } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) + .take(limit) + .toList() + } + + private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? { + val path = file(vf.path) ?: return null + val rel = relativeWithinBase(base, path) ?: return null + return WorkspaceFileDto(rel, vf.name, vf.isDirectory) + } + + private fun gitAvailable(base: Path): Boolean { + return workspaceGitAvailable(base, gitCache) + } + + private fun git(base: Path, vararg args: String): String { + return runWorkspaceGit(base, *args) + } + + private fun agent(a: Agent) = AgentInfo( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode.value, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) + // ------ mapping: domain model → DTO ------ private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto = @@ -90,89 +415,60 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { KiloWorkspaceState.Pending -> KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) is KiloWorkspaceState.Loading -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.LOADING, - progress = progress(state.progress), + progress = KiloWorkspaceDtoMapper.progress(state.progress), ) is KiloWorkspaceState.Ready -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.READY, - providers = providers(state.providers), - agents = agents(state.agents), - commands = state.commands.map(::command), - skills = state.skills.map(::skill), + providers = KiloWorkspaceDtoMapper.providers(state.providers), + agents = KiloWorkspaceDtoMapper.agents(state.agents), + commands = state.commands.map(KiloWorkspaceDtoMapper::command), + skills = state.skills.map(KiloWorkspaceDtoMapper::skill), ) is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, - errors = state.errors.map(::error), + errors = state.errors.map(KiloWorkspaceDtoMapper::error), ) } +} - private fun error(e: LoadError) = LoadErrorDto( - resource = e.resource, - status = e.status, - detail = e.detail, - ) - - private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( - providers = p.providers, - agents = p.agents, - commands = p.commands, - skills = p.skills, - ) - - private fun providers(d: ProviderData) = ProvidersDto( - providers = d.providers.map(::provider), - connected = d.connected, - defaults = d.defaults, - ) - - private fun provider(p: ProviderInfo) = ProviderDto( - id = p.id, - name = p.name, - source = p.source, - models = p.models.mapValues { (_, m) -> model(m) }, - ) - - private fun model(m: ModelInfo) = ModelDto( - id = m.id, - name = m.name, - attachment = m.attachment, - reasoning = m.reasoning, - temperature = m.temperature, - toolCall = m.toolCall, - free = m.free, - status = m.status, - recommendedIndex = m.recommendedIndex, - variants = m.variants, - limit = m.limit?.let { ModelLimitDto(it.context, it.input, it.output) }, - ) +private fun encode(value: String) = URLEncoder.encode(value, Charsets.UTF_8) - private fun agents(d: AgentData) = AgentsDto( - agents = d.agents.map(::agent), - all = d.all.map(::agent), - default = d.default, - ) +internal fun normalizeWorkspacePath(path: String): String? { + val raw = path.trim().takeIf { it.isNotBlank() } ?: return null + return try { + val cut = raw.substringBefore('#').substringBefore('?') + val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8) + Path.of(decoded.replace('\\', '/')).normalize().toString() + } catch (_: Exception) { + null + } +} - private fun agent(a: AgentInfo) = AgentDto( - name = a.name, - displayName = a.displayName, - description = a.description, - mode = a.mode, - native = a.native, - hidden = a.hidden, - color = a.color, - deprecated = a.deprecated, - ) +internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap = ConcurrentHashMap()): Boolean { + if (Files.exists(base.resolve(".git"))) return true + return cache.getOrPut(base.toString()) { + runWorkspaceGit(base, "rev-parse", "--is-inside-work-tree").trim() == "true" + } +} - private fun command(c: CommandInfo) = CommandDto( - name = c.name, - description = c.description, - source = c.source, - hints = c.hints, - ) +internal fun runWorkspaceGit(base: Path, vararg args: String): String { + return try { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile()) + val out = CapturingProcessHandler(cmd).runProcess(5_000) + out.stdout.takeIf { !out.isTimeout && out.exitCode == 0 }.orEmpty() + } catch (_: Exception) { + "" + } +} - private fun skill(s: SkillInfo) = SkillDto( - name = s.name, - description = s.description, - location = s.location, - ) +/** + * Relativizes [target] against [base], returning the forward-slash relative path, or null if + * [target] is not strictly inside [base] (path-traversal guard) or equals the base itself. + */ +internal fun relativeWithinBase(base: Path, target: Path): String? { + val path = target.normalize() + if (!path.startsWith(base)) return null + val rel = base.relativize(path).toString().replace('\\', '/') + return rel.ifBlank { null } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/MigrationRpcMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/MigrationRpcMapper.kt new file mode 100644 index 00000000000..af9d7fdcb38 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/MigrationRpcMapper.kt @@ -0,0 +1,232 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.migration.LegacyAutocompleteSettings +import ai.kilocode.backend.migration.LegacyCleanupReport +import ai.kilocode.backend.migration.LegacyCleanupTargets +import ai.kilocode.backend.migration.LegacyMigrationDetection +import ai.kilocode.backend.migration.LegacyMigrationItemProgress +import ai.kilocode.backend.migration.LegacyMigrationResultItem +import ai.kilocode.backend.migration.LegacyMigrationSelections +import ai.kilocode.backend.migration.LegacyMigrationSessionProgress +import ai.kilocode.backend.migration.LegacyMigrationStatus +import ai.kilocode.backend.migration.LegacySettings +import ai.kilocode.backend.migration.MigrationAutoApprovalSelections +import ai.kilocode.backend.migration.MigrationCustomModeInfo +import ai.kilocode.backend.migration.MigrationDefaultModelInfo +import ai.kilocode.backend.migration.MigrationItemCategory +import ai.kilocode.backend.migration.MigrationItemProgressStatus +import ai.kilocode.backend.migration.MigrationItemStatus +import ai.kilocode.backend.migration.MigrationMcpServerInfo +import ai.kilocode.backend.migration.MigrationProviderInfo +import ai.kilocode.backend.migration.MigrationSessionInfo +import ai.kilocode.backend.migration.MigrationSessionPhase +import ai.kilocode.backend.migration.MigrationSettingsSelections +import ai.kilocode.backend.migration.MigrationSessionSelection +import ai.kilocode.rpc.dto.LegacyAutocompleteSettingsDto +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationItemProgressDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.rpc.dto.LegacySettingsDto +import ai.kilocode.rpc.dto.MigrationAutoApprovalSelectionsDto +import ai.kilocode.rpc.dto.MigrationCustomModeInfoDto +import ai.kilocode.rpc.dto.MigrationDefaultModelInfoDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationMcpServerInfoDto +import ai.kilocode.rpc.dto.MigrationProviderInfoDto +import ai.kilocode.rpc.dto.MigrationSessionInfoDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import ai.kilocode.rpc.dto.MigrationSessionSelectionDto +import ai.kilocode.rpc.dto.MigrationSettingsSelectionsDto + +internal object MigrationRpcMapper { + + // ----------------------------------------------------------------------- + // Status + // ----------------------------------------------------------------------- + + fun toDto(status: LegacyMigrationStatus): LegacyMigrationStatusDto = when (status) { + LegacyMigrationStatus.Completed -> LegacyMigrationStatusDto.completed + LegacyMigrationStatus.CompletedWithErrors -> LegacyMigrationStatusDto.completed_with_errors + LegacyMigrationStatus.Skipped -> LegacyMigrationStatusDto.skipped + } + + fun fromDto(dto: LegacyMigrationStatusDto): LegacyMigrationStatus = when (dto) { + LegacyMigrationStatusDto.completed -> LegacyMigrationStatus.Completed + LegacyMigrationStatusDto.completed_with_errors -> LegacyMigrationStatus.CompletedWithErrors + LegacyMigrationStatusDto.skipped -> LegacyMigrationStatus.Skipped + } + + // ----------------------------------------------------------------------- + // Detection + // ----------------------------------------------------------------------- + + fun toDto(detection: LegacyMigrationDetection): LegacyMigrationDetectionDto = + LegacyMigrationDetectionDto( + providers = detection.providers.map(::toDto), + mcpServers = detection.mcpServers.map(::toDto), + customModes = detection.customModes.map(::toDto), + sessions = detection.sessions.map(::toDto), + defaultModel = detection.defaultModel?.let(::toDto), + settings = detection.settings?.let(::toDto), + hasData = detection.hasData, + ) + + private fun toDto(p: MigrationProviderInfo): MigrationProviderInfoDto = + MigrationProviderInfoDto( + profileName = p.profileName, + provider = p.provider, + model = p.model, + hasApiKey = p.hasApiKey, + supported = p.supported, + newProviderName = p.newProviderName, + ) + + private fun toDto(m: MigrationMcpServerInfo): MigrationMcpServerInfoDto = + MigrationMcpServerInfoDto(name = m.name, type = m.type, disabled = m.disabled) + + private fun toDto(c: MigrationCustomModeInfo): MigrationCustomModeInfoDto = + MigrationCustomModeInfoDto(name = c.name, slug = c.slug, nativeSlug = c.nativeSlug) + + fun toDto(s: MigrationSessionInfo): MigrationSessionInfoDto = + MigrationSessionInfoDto(id = s.id, title = s.title, directory = s.directory, time = s.time) + + private fun toDto(d: MigrationDefaultModelInfo): MigrationDefaultModelInfoDto = + MigrationDefaultModelInfoDto(provider = d.provider, model = d.model) + + private fun toDto(s: LegacySettings): LegacySettingsDto = + LegacySettingsDto( + autoApprovalEnabled = s.autoApprovalEnabled, + allowedCommands = s.allowedCommands, + deniedCommands = s.deniedCommands, + alwaysAllowReadOnly = s.alwaysAllowReadOnly, + alwaysAllowReadOnlyOutsideWorkspace = s.alwaysAllowReadOnlyOutsideWorkspace, + alwaysAllowWrite = s.alwaysAllowWrite, + alwaysAllowExecute = s.alwaysAllowExecute, + alwaysAllowMcp = s.alwaysAllowMcp, + alwaysAllowModeSwitch = s.alwaysAllowModeSwitch, + alwaysAllowSubtasks = s.alwaysAllowSubtasks, + language = s.language, + autocomplete = s.autocomplete?.let(::toDto), + ) + + private fun toDto(a: LegacyAutocompleteSettings): LegacyAutocompleteSettingsDto = + LegacyAutocompleteSettingsDto( + enableAutoTrigger = a.enableAutoTrigger, + enableSmartInlineTaskKeybinding = a.enableSmartInlineTaskKeybinding, + enableChatAutocomplete = a.enableChatAutocomplete, + ) + + // ----------------------------------------------------------------------- + // Selections (DTO → domain) + // ----------------------------------------------------------------------- + + fun fromDto(dto: LegacyMigrationSelectionsDto): LegacyMigrationSelections = + LegacyMigrationSelections( + providers = dto.providers, + mcpServers = dto.mcpServers, + customModes = dto.customModes, + sessions = dto.sessions.map(::fromDto), + defaultModel = dto.defaultModel, + settings = fromDto(dto.settings), + keepLegacySettingsFile = dto.keepLegacySettingsFile, + ) + + private fun fromDto(dto: MigrationSessionSelectionDto): MigrationSessionSelection = + MigrationSessionSelection(id = dto.id) + + private fun fromDto(dto: MigrationSettingsSelectionsDto): MigrationSettingsSelections = + MigrationSettingsSelections( + autoApproval = fromDto(dto.autoApproval), + language = dto.language, + autocomplete = dto.autocomplete, + ) + + private fun fromDto(dto: MigrationAutoApprovalSelectionsDto): MigrationAutoApprovalSelections = + MigrationAutoApprovalSelections( + commandRules = dto.commandRules, + readPermission = dto.readPermission, + writePermission = dto.writePermission, + executePermission = dto.executePermission, + mcpPermission = dto.mcpPermission, + taskPermission = dto.taskPermission, + ) + + // ----------------------------------------------------------------------- + // Progress / result + // ----------------------------------------------------------------------- + + fun toDto(p: LegacyMigrationItemProgress): LegacyMigrationItemProgressDto = + LegacyMigrationItemProgressDto(item = p.item, status = toDto(p.status), message = p.message) + + fun toDto(p: LegacyMigrationSessionProgress): LegacyMigrationSessionProgressDto = + LegacyMigrationSessionProgressDto( + session = p.session?.let(::toDto), + index = p.index, + total = p.total, + phase = toDto(p.phase), + error = p.error, + ) + + fun toDto(r: LegacyMigrationResultItem): LegacyMigrationResultItemDto = + LegacyMigrationResultItemDto( + item = r.item, + category = toDto(r.category), + status = toDto(r.status), + message = r.message, + ) + + private fun toDto(s: MigrationItemProgressStatus): MigrationItemProgressStatusDto = when (s) { + MigrationItemProgressStatus.migrating -> MigrationItemProgressStatusDto.migrating + MigrationItemProgressStatus.success -> MigrationItemProgressStatusDto.success + MigrationItemProgressStatus.warning -> MigrationItemProgressStatusDto.warning + MigrationItemProgressStatus.error -> MigrationItemProgressStatusDto.error + } + + private fun toDto(p: MigrationSessionPhase): MigrationSessionPhaseDto = when (p) { + MigrationSessionPhase.preparing -> MigrationSessionPhaseDto.preparing + MigrationSessionPhase.storing -> MigrationSessionPhaseDto.storing + MigrationSessionPhase.skipped -> MigrationSessionPhaseDto.skipped + MigrationSessionPhase.done -> MigrationSessionPhaseDto.done + MigrationSessionPhase.summary -> MigrationSessionPhaseDto.summary + MigrationSessionPhase.error -> MigrationSessionPhaseDto.error + } + + private fun toDto(c: MigrationItemCategory): MigrationItemCategoryDto = when (c) { + MigrationItemCategory.provider -> MigrationItemCategoryDto.provider + MigrationItemCategory.mcpServer -> MigrationItemCategoryDto.mcpServer + MigrationItemCategory.customMode -> MigrationItemCategoryDto.customMode + MigrationItemCategory.session -> MigrationItemCategoryDto.session + MigrationItemCategory.defaultModel -> MigrationItemCategoryDto.defaultModel + MigrationItemCategory.settings -> MigrationItemCategoryDto.settings + } + + private fun toDto(s: MigrationItemStatus): MigrationItemStatusDto = when (s) { + MigrationItemStatus.success -> MigrationItemStatusDto.success + MigrationItemStatus.warning -> MigrationItemStatusDto.warning + MigrationItemStatus.error -> MigrationItemStatusDto.error + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + fun fromDto(dto: LegacyCleanupTargetsDto): LegacyCleanupTargets = + LegacyCleanupTargets( + providerProfiles = dto.providerProfiles, + mcpSettings = dto.mcpSettings, + customModes = dto.customModes, + globalState = dto.globalState, + taskHistory = dto.taskHistory, + legacySettingsFile = dto.legacySettingsFile, + ) + + fun toDto(r: LegacyCleanupReport): LegacyCleanupReportDto = + LegacyCleanupReportDto(cleaned = r.cleaned, errors = r.errors) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetry.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetry.kt new file mode 100644 index 00000000000..9f0ee195da1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetry.kt @@ -0,0 +1,74 @@ +package ai.kilocode.backend.telemetry + +import ai.kilocode.backend.dev.KiloDevMode +import ai.kilocode.log.KiloLog +import com.intellij.openapi.components.Service +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +@Service(Service.Level.APP) +class KiloBackendTelemetry( + private val log: KiloLog = KiloLog.create(KiloBackendTelemetry::class.java), +) { + companion object { + private const val TIMEOUT_MS = 5_000L + } + + suspend fun capture(http: OkHttpClient?, port: Int, event: String, properties: Map) { + val body = payload(event, properties) + if (KiloDevMode.enabled()) { + log.info(body) + return + } + if (http == null || port <= 0) return + post(http, port, "telemetry/capture", body) + } + + suspend fun setEnabled(http: OkHttpClient?, port: Int, enabled: Boolean) { + val body = JsonObject(mapOf("enabled" to JsonPrimitive(enabled))).toString() + if (KiloDevMode.enabled()) { + log.info(body) + return + } + if (http == null || port <= 0) return + post(http, port, "telemetry/setEnabled", body) + } + + private suspend fun post(http: OkHttpClient, port: Int, path: String, body: String) { + withContext(Dispatchers.IO) { + try { + val client = http.newBuilder() + .callTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build() + val req = Request.Builder() + .url("http://127.0.0.1:$port/$path") + .header("Accept", "application/json") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + client.newCall(req).execute().use { res -> + if (!res.isSuccessful) log.warn("telemetry $path failed: HTTP ${res.code}") + } + } catch (e: Exception) { + log.warn("telemetry $path failed: ${e.message}", e) + } + } + } + + private fun payload(event: String, properties: Map): String = JsonObject( + mapOf( + "event" to JsonPrimitive(event), + "properties" to JsonObject(base() + properties.mapValues { JsonPrimitive(it.value) }), + ), + ).toString() + + private fun base(): Map = + KiloLog.payload(log).mapValues { JsonPrimitive(it.value) } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 5aa272cf72e..2d9bd74d8b7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -264,7 +264,7 @@ class KiloBackendWorkspace( ) private fun fetch(path: String): String { - val request = Request.Builder().url("http://localhost:$port$path").get().build() + val request = Request.Builder().url("http://127.0.0.1:$port$path").get().build() http.newCall(request).execute().use { response -> val raw = response.body?.string().orEmpty() if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $raw") @@ -291,8 +291,8 @@ class KiloBackendWorkspace( } private fun setWorkspaceError(message: String, errors: List) { - _state.value = KiloWorkspaceState.Error(message, errors) log.warn("Workspace error [$directory]: $message") + _state.value = KiloWorkspaceState.Error(message, errors) } private data class FetchResult(val value: T?, val error: LoadError?) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 991f679ef48..66127633cd1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -54,10 +54,12 @@ data class ModelInfo( val temperature: Boolean, val toolCall: Boolean, val free: Boolean, + val byok: Boolean = false, val status: String?, val recommendedIndex: Double?, val variants: List, val limit: ModelLimitInfo?, + val mayTrainOnYourPrompts: Boolean = false, ) data class ModelLimitInfo( @@ -92,6 +94,6 @@ data class CommandInfo( data class SkillInfo( val name: String, - val description: String, + val description: String?, val location: String, ) diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index 1a88a88350a..5e174a05e2e 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -7,7 +7,15 @@ + + + + + + + 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 6864fb20f0f..d19eb944e65 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 @@ -37,8 +37,8 @@ class KiloBackendAppServiceTest { mock.close() } - private fun create(): KiloBackendAppService = - KiloBackendAppService.create(scope, FakeCliServer(mock), log) + private fun create(loadTimeoutMs: Long = 30_000L): KiloBackendAppService = + KiloBackendAppService.create(scope, FakeCliServer(mock), log, loadTimeoutMs) @Test fun `full lifecycle reaches Ready`() = runBlocking { @@ -54,6 +54,28 @@ class KiloBackendAppServiceTest { assertNotNull(ready.data.notifications) } + @Test + fun `shutdown for unload clears runtime and disposes server once`() = runBlocking { + val server = FakeCliServer(mock) + val svc = KiloBackendAppService.create(scope, server, log) + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + svc.shutdownForUnload() + svc.shutdownForUnload() + svc.dispose() + + assertEquals(KiloAppState.Disconnected, svc.appState.value) + assertNull(svc.profile) + assertNull(svc.config) + assertTrue(svc.notifications.isEmpty()) + assertTrue(svc.warnings.isEmpty()) + assertEquals(1, server.disposeCount) + } + @Test fun `config is loaded`() = runBlocking { mock.config = """{"model":"claude-4","username":"testuser"}""" @@ -285,10 +307,12 @@ class KiloBackendAppServiceTest { svc.connect() withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } + svc.appState.first { state -> + state is KiloAppState.Ready && state.data.warnings.any { it.path == ".kilo/kilo.json" } + } } - assertTrue(log.messages.any { + assertTrue(log.awaitMessage { it.contains("App warnings:") && it.contains(".kilo/kilo.json: Invalid JSON") }) } @@ -404,6 +428,104 @@ class KiloBackendAppServiceTest { } } + @Test + fun `hung app load transitions from Loading to Error`() = runBlocking { + val gate = CountDownLatch(1) + mock.responseGate = gate + val svc = create(loadTimeoutMs = 300L) + + try { + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Loading } + } + + val err = withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Error } + } as KiloAppState.Error + + assertEquals("Failed to load required data", err.message) + assertTrue(err.errors.any { it.detail?.contains("timeout", ignoreCase = true) == true }) + } finally { + gate.countDown() + } + } + + @Test + fun `hung warnings do not prevent Ready`() = runBlocking { + val gate = CountDownLatch(1) + mock.warningsGate = gate + val svc = create(loadTimeoutMs = 300L) + + try { + svc.connect() + + val ready = withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } as KiloAppState.Ready + + assertTrue(ready.data.warnings.isEmpty()) + assertTrue(svc.warnings.isEmpty()) + } finally { + gate.countDown() + } + } + + @Test + fun `restart during Loading cancels stale load and reaches Ready`() = runBlocking { + val gate = CountDownLatch(1) + mock.responseGate = gate + val svc = create(loadTimeoutMs = 500L) + + try { + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Loading } + } + + gate.countDown() + svc.restart() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + assertFalse(log.messages.any { it.contains("Application start timed out") }) + } finally { + gate.countDown() + } + } + + @Test + fun `reinstall during Loading cancels stale load and reaches Ready`() = runBlocking { + val gate = CountDownLatch(1) + mock.responseGate = gate + val svc = create(loadTimeoutMs = 500L) + + try { + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Loading } + } + + gate.countDown() + svc.reinstall() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + assertFalse(log.messages.any { it.contains("Application start timed out") }) + } finally { + gate.countDown() + } + } + @Test fun `SSE config updated event refreshes config`() = runBlocking { mock.config = """{"model":"initial"}""" @@ -418,13 +540,14 @@ class KiloBackendAppServiceTest { // Change the config response and push an SSE event mock.config = """{"model":"updated"}""" + val before = mock.requestCount("/global/config") mock.awaitSseConnection() mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""") - // Wait for config to be refreshed + assertTrue(mock.awaitRequestCount("/global/config", before + 1)) withTimeout(5_000) { - while (svc.config?.model != "updated") { - delay(100) + svc.appState.first { state -> + state is KiloAppState.Ready && state.data.config.model == "updated" } } @@ -444,12 +567,14 @@ class KiloBackendAppServiceTest { assertEquals(1, (svc.appState.value as KiloAppState.Ready).data.warnings.size) mock.warnings = "[]" + val before = mock.requestCount("/config/warnings") mock.awaitSseConnection() mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""") + assertTrue(mock.awaitRequestCount("/config/warnings", before + 1)) withTimeout(5_000) { - while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { - delay(100) + svc.appState.first { state -> + state is KiloAppState.Ready && state.data.warnings.isEmpty() } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index fd628ac4e8a..29b0ec03507 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -3,15 +3,22 @@ package ai.kilocode.backend.app import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ModelSelectionDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient +import java.util.concurrent.CountDownLatch import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -39,4 +46,66 @@ class KiloBackendChatManagerTest { assertTrue(mock.lastSummarizePath!!.startsWith("/session/ses_abc/summarize?directory=")) assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", mock.lastSummarizeBody) } + + @Test + fun `enhance prompt posts scoped request and returns rewritten text`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + mock.enhanced = """{"text":"Use a focused implementation plan"}""" + + val result = chat.enhancePrompt("/test/project", "make a plan") + + assertEquals("Use a focused implementation plan", result) + assertEquals(1, mock.requestCount("/enhance-prompt")) + assertTrue(mock.lastEnhancePath!!.startsWith("/enhance-prompt?directory=")) + assertEquals("""{"text":"make a plan"}""", mock.lastEnhanceBody) + } + + @Test + fun `enhance prompt hides provider response details`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + mock.enhanceStatus = 500 + mock.enhanced = """{"error":"provider unavailable"}""" + + val error = assertFailsWith { + chat.enhancePrompt("/test/project", "make a plan") + } + + assertEquals("Enhance prompt failed: HTTP 500", error.message) + } + + @Test + fun `prompt failure includes CLI response body summary`() { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + mock.promptStatus = 400 + mock.promptResponse = """{"issues":[{"message":"invalid source type"}]}""" + + val error = assertFailsWith { + chat.prompt("ses_abc", "/test/project", PromptDto(parts = listOf(PromptPartDto(type = "text", text = "hello")))) + } + + assertTrue(error.message!!.contains("prompt_async failed: HTTP 400"), error.message) + assertTrue(error.message!!.contains("chars="), error.message) + } + + @Test + fun `enhance prompt cancels the HTTP request with its coroutine`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + val gate = CountDownLatch(1) + mock.responseGate = gate + val request = async(Dispatchers.Default) { chat.enhancePrompt("/test/project", "make a plan") } + assertTrue(mock.awaitRequestCount("/enhance-prompt", 1)) + + request.cancelAndJoin() + gate.countDown() + + assertTrue(request.isCancelled) + } } 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 de84bfced22..335b91e2e87 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 @@ -6,16 +6,26 @@ 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.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import okhttp3.Request +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -79,13 +89,11 @@ class KiloConnectionServiceTest { } // Use first{} on the flow to capture the event — avoids race with SharedFlow subscription - val deferred = scope.launch { + val deferred = scope.launch(start = CoroutineStart.UNDISPATCHED) { val event = svc.events.first { it.type == "global.config.updated" } assertEquals("global.config.updated", event.type) } - // Small delay to ensure the collector subscription is active - delay(200) mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""") withTimeout(5_000) { @@ -93,6 +101,70 @@ class KiloConnectionServiceTest { } } + @Test + fun `SSE events preserve callback order`() = runBlocking { + val svc = KiloConnectionService(scope, fake, {}, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + val count = 100 + val received = async(start = CoroutineStart.UNDISPATCHED) { + withTimeout(5_000) { + svc.events.take(count).toList() + } + } + + repeat(count) { idx -> + mock.pushEvent("test.event", idx.toString()) + } + + assertEquals((0 until count).map { it.toString() }, received.await().map { it.data }) + } + + @Test + fun `SSE concurrent callbacks preserve callback order`() = runBlocking { + val blocked = BlockingLog() + val svc = KiloConnectionService(scope, fake, {}, blocked) + val field = KiloConnectionService::class.java.getDeclaredField("listener") + field.isAccessible = true + val listener = field.get(svc) as EventSourceListener + val source = object : EventSource { + override fun request(): Request = Request.Builder().url("http://127.0.0.1/global/event").build() + override fun cancel() {} + } + val sourceField = KiloConnectionService::class.java.getDeclaredField("source") + sourceField.isAccessible = true + @Suppress("UNCHECKED_CAST") + val ref = sourceField.get(svc) as AtomicReference + ref.set(source) + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + withTimeout(5_000) { + svc.events.take(2).toList() + } + } + + val entered = CountDownLatch(1) + val first = Thread { listener.onEvent(source, null, "first.event", "first") } + val second = Thread { + entered.countDown() + listener.onEvent(source, null, "second.event", "second") + } + first.start() + assertTrue(blocked.started.await(1, TimeUnit.SECONDS)) + second.start() + assertTrue(entered.await(1, TimeUnit.SECONDS)) + blocked.release.countDown() + first.join() + second.join() + + assertEquals(listOf("first", "second"), received.await().map { it.data }) + svc.dispose() + } + @Test fun `SSE close triggers error state`() = runBlocking { val svc = KiloConnectionService(scope, fake, {}, log) @@ -177,9 +249,11 @@ class KiloConnectionServiceTest { } // Restart should tear down and re-open + val seen = mock.sseConnectionCount svc.restart() // Wait for reconnection + assertTrue(mock.awaitSseConnections(seen + 1)) withTimeout(10_000) { svc.state.first { it is ConnectionState.Connected } } @@ -208,4 +282,21 @@ class KiloConnectionServiceTest { svc.state.first { it !is ConnectionState.Connected } } } + + private class BlockingLog : KiloLog { + val started = CountDownLatch(1) + val release = CountDownLatch(1) + override var isDebugEnabled: Boolean = true + + override fun debug(block: () -> String) { + val msg = block() + if (!msg.contains("evt=first.event")) return + started.countDown() + release.await(1, TimeUnit.SECONDS) + } + + override fun info(msg: String) {} + override fun warn(msg: String, t: Throwable?) {} + override fun error(msg: String, t: Throwable?) {} + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt index 194f9098b86..09df6ad5902 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -216,10 +216,30 @@ class ChatDtoSerializationTest { assertEquals(2.0, decoded.time?.end) } + @Test + fun `PartDto file fields are preserved in round-trip`() { + val part = PartDto( + id = "p1", sessionID = "s1", messageID = "m1", + type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png", + ) + val encoded = json.encodeToString(PartDto.serializer(), part) + assertTrue(encoded.contains(""""mime":"image/png"""")) + assertTrue(encoded.contains(""""url":"file:///tmp/a.png"""")) + assertTrue(encoded.contains(""""filename":"a.png"""")) + + val decoded = json.decodeFromString(PartDto.serializer(), encoded) + assertEquals("image/png", decoded.mime) + assertEquals("file:///tmp/a.png", decoded.url) + assertEquals("a.png", decoded.filename) + } + @Test fun `PromptDto variant is preserved in round-trip`() { val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "hello")), + parts = listOf( + PromptPartDto("text", "hello"), + PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"), + ), providerID = "kilo", modelID = "gpt-5", agent = "code", @@ -228,7 +248,12 @@ class ChatDtoSerializationTest { val encoded = json.encodeToString(PromptDto.serializer(), prompt) assertTrue(encoded.contains(""""variant":"medium"""")) - assertEquals("medium", json.decodeFromString(PromptDto.serializer(), encoded).variant) + val decoded = json.decodeFromString(PromptDto.serializer(), encoded) + assertEquals("medium", decoded.variant) + assertEquals("file", decoded.parts[1].type) + assertEquals("image/png", decoded.parts[1].mime) + assertEquals("file:///tmp/a.png", decoded.parts[1].url) + assertEquals("a.png", decoded.parts[1].filename) } // ------ helpers ------ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt index 507c7503c0c..29deb0cce41 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -154,6 +154,26 @@ class ChatLogSummaryTest { assertTrue(out.contains("variant=medium"), out) } + @Test + fun `prompt dto summary redacts file attachment urls`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + + val out = ChatLogSummary.prompt( + PromptDto( + parts = listOf( + PromptPartDto(type = "text", text = "inspect"), + PromptPartDto(type = "file", mime = "image/png", url = "file:///secret/path.png", filename = "path.png"), + ) + ) + ) + + assertTrue(out.contains("attachments=1"), out) + assertTrue(out.contains("media=1"), out) + assertTrue(out.contains("attachmentTypes=image/png"), out) + assertFalse(out.contains("secret"), out) + assertFalse(out.contains("file:///"), out) + } + @Test fun `message updated summary includes role and model`() { val out = ChatLogSummary.event( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt index b19ed6072dd..a457d308835 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt @@ -128,7 +128,8 @@ class GeneratedApiModelSerializationTest { "options": {}, "headers": {}, "release_date": "2025-01-01", - "isFree": true + "isFree": true, + "hasUserByokAvailable": true } } }], @@ -139,6 +140,7 @@ class GeneratedApiModelSerializationTest { val obj = json.decodeFromString(src) val model = obj.all[0].models["free-model"]!! assertEquals(true, model.isFree) + assertEquals(true, model.hasUserByokAvailable) assertEquals( ai.kilocode.jetbrains.api.model.Model.Status.BETA, model.status, 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 d869ea28c42..1a6ed2b5701 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 @@ -19,12 +19,14 @@ class KiloBackendCliManagerEnvTest { tmp = Files.createTempDirectory("kilo-cli-env-test").toFile() System.clearProperty("kilo.dev.storage.isolated") System.clearProperty("kilo.dev.worktree.root") + System.clearProperty("idea.plugin.in.sandbox.mode") } @AfterTest fun tearDown() { System.clearProperty("kilo.dev.storage.isolated") System.clearProperty("kilo.dev.worktree.root") + System.clearProperty("idea.plugin.in.sandbox.mode") tmp.deleteRecursively() } @@ -36,11 +38,37 @@ class KiloBackendCliManagerEnvTest { assertEquals("true", env["KILO_ENABLE_QUESTION_TOOL"]) assertEquals("jetbrains", env["KILO_PLATFORM"]) assertEquals("kilo-code", env["KILO_APP_NAME"]) + assertEquals("all", env["KILO_TELEMETRY_LEVEL"]) assertEquals("true", env["KILO_DISABLE_CLAUDE_CODE"]) assertEquals("jetbrains-plugin", env["KILOCODE_FEATURE"]) assertEquals("pwd123", env["KILO_SERVER_PASSWORD"]) } + @Test + fun `dev mode disables CLI telemetry`() { + System.setProperty("idea.plugin.in.sandbox.mode", "true") + + val env = manager.buildEnv("pwd123", emptyMap()) + + assertEquals("off", env["KILO_TELEMETRY_LEVEL"]) + } + + @Test + fun `isolation disabled - default CLI config asks for edit and bash permissions`() { + val env = manager.buildEnv("pwd123", emptyMap()) + + assertEquals("""{"permission":{"edit":"ask","bash":"ask"}}""", env["KILO_CONFIG_CONTENT"]) + } + + @Test + fun `isolation disabled - base CLI config is preserved`() { + val cfg = """{"permission":{"edit":"allow"}}""" + + val env = manager.buildEnv("pwd123", mapOf("KILO_CONFIG_CONTENT" to cfg)) + + assertEquals(cfg, env["KILO_CONFIG_CONTENT"]) + } + @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/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index c614d14bdb2..3d96d257108 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -3,11 +3,15 @@ package ai.kilocode.backend.cli import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelStateDto +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.QuestionReplyDto @@ -15,6 +19,7 @@ import org.junit.jupiter.api.Nested import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -160,6 +165,143 @@ class KiloCliDataParserTest { assertEquals("Hello", result.part.text) } + @Test + fun `parseChatEvent - file part preserves metadata`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "file_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "file", + "mime": "image/png", + "url": "file:///tmp/a.png", + "filename": "a.png" + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("file", result.part.type) + assertEquals("image/png", result.part.mime) + assertEquals("file:///tmp/a.png", result.part.url) + assertEquals("a.png", result.part.filename) + } + + @Test + fun `parseChatEvent - part preserves synthetic flag and source metadata`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "file_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "file", + "mime": "text/plain", + "url": "file:///tmp/a.kt", + "filename": "a.kt", + "synthetic": true, + "source": { + "type": "file", + "path": "src/a.kt", + "text": { "value": "@src/a.kt", "start": 4, "end": 13 } + } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals(true, result.part.synthetic) + assertEquals("file", result.part.source?.type) + assertEquals("src/a.kt", result.part.source?.path) + assertEquals("@src/a.kt", result.part.source?.text?.value) + assertEquals(4.0, result.part.source?.text?.start) + assertEquals(13.0, result.part.source?.text?.end) + } + + @Test + fun `ChatEventNormalizer - user part updated sanitizes text`() { + val norm = KiloCliDataParser.ChatEventNormalizer() + norm.parse("message.updated", messageUpdated("m1", "user")) + + val events = norm.parse("message.part.updated", partUpdated( + "m1", + "p1", + "text", + "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter", + )) + + val event = events!!.single() as ChatEventDto.PartUpdated + assertEquals("before\nafter", event.part.text) + } + + @Test + fun `ChatEventNormalizer - assistant part updated preserves text`() { + val norm = KiloCliDataParser.ChatEventNormalizer() + norm.parse("message.updated", messageUpdated("m1", "assistant")) + val payload = "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}" + + val events = norm.parse("message.part.updated", partUpdated("m1", "p1", "text", payload)) + + val event = events!!.single() as ChatEventDto.PartUpdated + assertEquals(payload, event.part.text) + } + + @Test + fun `ChatEventNormalizer - user text deltas append normally`() { + val norm = KiloCliDataParser.ChatEventNormalizer() + norm.parse("message.updated", messageUpdated("m1", "user")) + + val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello")) + val second = norm.parse("message.part.delta", partDelta("m1", "p1", " world")) + + assertEquals("hello", (first!!.single() as ChatEventDto.PartDelta).delta) + assertEquals(" world", (second!!.single() as ChatEventDto.PartDelta).delta) + } + + @Test + fun `ChatEventNormalizer - split generated payload delta is suppressed`() { + val norm = KiloCliDataParser.ChatEventNormalizer() + norm.parse("message.updated", messageUpdated("m1", "user")) + + val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello\n")) + val second = norm.parse( + "message.part.delta", + partDelta("m1", "p1", "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"), + ) + + assertEquals("hello\n", (first!!.single() as ChatEventDto.PartDelta).delta) + val event = second!!.single() as ChatEventDto.PartUpdated + assertEquals("hello", event.part.text) + assertFalse(event.part.text!!.contains("Read tool")) + assertFalse(event.part.text!!.contains("/tmp/a.kt")) + } + + @Test + fun `ChatEventNormalizer - partial noisy line is replaced when identified`() { + val norm = KiloCliDataParser.ChatEventNormalizer() + norm.parse("message.updated", messageUpdated("m1", "user")) + + val first = norm.parse("message.part.delta", partDelta("m1", "p1", "before\nCalled the Read")) + val second = norm.parse( + "message.part.delta", + partDelta("m1", "p1", " tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"), + ) + + assertEquals("before\nCalled the Read", (first!!.single() as ChatEventDto.PartDelta).delta) + val event = second!!.single() as ChatEventDto.PartUpdated + assertEquals("before\nafter", event.part.text) + } + @Test fun `parseChatEvent - read tool part preserves input metadata and time`() { val data = globalEvent(""" @@ -199,6 +341,93 @@ class KiloCliDataParserTest { assertEquals(12.0, result.part.time?.end) } + @Test + fun `parseChatEvent - todowrite part parses typed todo metadata`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_todo", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "todowrite", + "callID": "call_todo", + "metadata": { + "todos": [ + {"content": "Top wins", "status": "completed", "priority": "high", "changed": true} + ], + "view": { + "mode": "compact", + "hiddenBefore": 1, + "hiddenAfter": 2, + "changed": 1, + "todos": [ + {"content": "Visible", "status": "pending", "priority": "medium", "changed": true} + ] + } + }, + "state": { + "status": "completed", + "input": { + "todos": [ + {"content": "Input fallback", "status": "pending", "priority": "low"} + ] + }, + "metadata": { + "todos": [ + {"content": "State fallback", "status": "in_progress", "priority": "medium"} + ] + } + } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + assertEquals("Top wins", result.part.todos.single().content) + assertEquals(true, result.part.todos.single().changed) + assertEquals("compact", result.part.todoView?.mode) + assertEquals(1, result.part.todoView?.hiddenBefore) + assertEquals(2, result.part.todoView?.hiddenAfter) + assertEquals(1, result.part.todoView?.changed) + assertEquals("Visible", result.part.todoView?.todos?.single()?.content) + assertEquals(true, result.part.todoView?.todos?.single()?.changed) + assertEquals("[{\"content\":\"Input fallback\",\"status\":\"pending\",\"priority\":\"low\"}]", result.part.input["todos"]) + assertTrue(result.part.metadata["view"]?.contains("compact") == true) + } + + @Test + fun `parseChatEvent - empty top metadata todos overrides fallback todos`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_todo", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "todowrite", + "metadata": { "todos": [] }, + "state": { + "status": "completed", + "metadata": { + "todos": [ + {"content": "Fallback", "status": "pending", "priority": "medium"} + ] + } + } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + + assertEquals(emptyList(), result.part.todos) + } + @Test fun `parseChatEvent - bash tool part preserves command output and error`() { val data = globalEvent(""" @@ -415,6 +644,30 @@ class KiloCliDataParserTest { assertEquals(2, result.session.summary?.files) } + @Test + fun `parseChatEvent - session created`() { + val data = globalEvent(""" + "type": "session.created", + "properties": { + "sessionID": "ses_new", + "info": { + "id": "ses_new", + "projectID": "proj_1", + "directory": "/test", + "title": "Implementation", + "version": "1", + "time": { "created": 1.0, "updated": 2.0 } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.created", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionCreated) + assertEquals("ses_new", result.sessionID) + assertEquals("/test", result.info.directory) + } + @Test fun `parseChatEvent - session diff`() { val data = globalEvent(""" @@ -455,7 +708,7 @@ class KiloCliDataParserTest { "properties": { "sessionID": "ses_1", "todos": [ - {"content": "Write tests", "status": "in_progress", "priority": "high"}, + {"content": "Write tests", "status": "in_progress", "priority": "high", "changed": true}, {"content": "Review PR", "status": "pending", "priority": "medium"} ] } @@ -468,6 +721,8 @@ class KiloCliDataParserTest { assertEquals(2, result.todos.size) assertEquals("Write tests", result.todos[0].content) assertEquals("high", result.todos[0].priority) + assertEquals(true, result.todos[0].changed) + assertEquals(false, result.todos[1].changed) } // ---- session status events ---- @@ -597,6 +852,48 @@ class KiloCliDataParserTest { assertEquals("A", result.request.questions[0].options[0].label) } + @Test + fun `parseChatEvent - plan follow-up question preserves fields`() { + val data = globalEvent(""" + "type": "question.asked", + "properties": { + "id": "q_plan", + "sessionID": "ses_1", + "blocking": true, + "questions": [{ + "question": "Ready to implement?", + "questionKey": "plan.followup.question", + "header": "Implement", + "headerKey": "plan.followup.header", + "multiple": false, + "custom": true, + "options": [{ + "label": "Continue here", + "labelKey": "plan.followup.answer.continue", + "description": "Implement the plan in this session", + "descriptionKey": "plan.followup.answer.continue.description", + "mode": "code" + }] + }], + "tool": null + } + """) + + val result = KiloCliDataParser.parseChatEvent("question.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionAsked) + assertEquals(true, result.request.blocking) + val item = result.request.questions.single() + assertEquals("plan.followup.question", item.questionKey) + assertEquals("plan.followup.header", item.headerKey) + assertEquals(false, item.multiple) + assertEquals(true, item.custom) + val opt = item.options.single() + assertEquals("plan.followup.answer.continue", opt.labelKey) + assertEquals("plan.followup.answer.continue.description", opt.descriptionKey) + assertEquals("code", opt.mode) + } + @Test fun `parseChatEvent - question replied`() { val data = globalEvent(""" @@ -732,11 +1029,16 @@ class KiloCliDataParserTest { @Test fun `parseQuestionRequests - parses list`() { val raw = """[ - {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} + {"id": "q1", "sessionID": "s1", "blocking": true, "questions": [{"question": "pick", "questionKey": "q.key", "header": "h", "headerKey": "h.key", "multiple": true, "custom": false, "options": [{"label": "A", "description": "B", "mode": "code"}]}]} ]""" val result = KiloCliDataParser.parseQuestionRequests(raw) assertEquals(1, result.size) assertEquals("q1", result[0].id) + assertEquals(true, result[0].blocking) + assertEquals("q.key", result[0].questions[0].questionKey) + assertEquals(true, result[0].questions[0].multiple) + assertEquals(false, result[0].questions[0].custom) + assertEquals("code", result[0].questions[0].options[0].mode) } } @@ -820,6 +1122,53 @@ class KiloCliDataParserTest { assertEquals("Hi there", result[1].parts[0].text) } + @Test + fun `parseMessages - sanitizes user text read payloads only`() { + val raw = """[ + { + "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "parts": [ + { "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/user.kt\"}\nafter" }, + { "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "filename": "a.png", "url": "file:///tmp/a.png" }, + { "id": "t1", "sessionID": "s1", "messageID": "m1", "type": "tool", "tool": "read", "state": { "input": { "filePath": "/tmp/tool.kt" } } } + ] + }, + { + "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, + "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}" }] + } + ]""" + + val result = KiloCliDataParser.parseMessages(raw) + + assertEquals("before\nafter", result[0].parts[0].text) + assertEquals("a.png", result[0].parts[1].filename) + assertEquals("/tmp/tool.kt", result[0].parts[2].input["filePath"]) + assertEquals( + "Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}", + result[1].parts[0].text, + ) + } + + @Test + fun `parseMessages - preserves synthetic and source metadata`() { + val raw = """[ + { + "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "parts": [ + { "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "hidden", "synthetic": true }, + { "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "mime": "text/plain", "url": "file:///tmp/a.kt", "source": { "type": "file", "path": "src/a.kt", "text": { "value": "@src/a.kt", "start": 0, "end": 9 } } } + ] + } + ]""" + + val result = KiloCliDataParser.parseMessages(raw).single() + + assertEquals(true, result.parts[0].synthetic) + assertEquals("src/a.kt", result.parts[1].source?.path) + assertEquals("@src/a.kt", result.parts[1].source?.text?.value) + } + @Test fun `parseMessages - message with tool parts`() { val raw = """[{ @@ -962,6 +1311,9 @@ class KiloCliDataParserTest { }, "limit": {"context": 200000, "input": 100000, "output": 16000}, "status": "active", + "isFree": false, + "hasUserByokAvailable": true, + "mayTrainOnYourPrompts": true, "recommendedIndex": 2, "variants": {"high": {}, "low": {}, "medium": {}}, "options": {}, "headers": {} @@ -981,6 +1333,9 @@ class KiloCliDataParserTest { assertTrue(model.temperature) assertTrue(model.toolCall) assertEquals("active", model.status) + assertFalse(model.free) + assertTrue(model.byok) + assertTrue(model.mayTrainOnYourPrompts) assertEquals(2.0, model.recommendedIndex) assertEquals(200000L, model.limit?.context) assertEquals(100000L, model.limit?.input) @@ -1015,6 +1370,58 @@ class KiloCliDataParserTest { assertEquals(emptyMap(), result.defaults) } + @Test + fun `parseProviderSettingsProviders - preserves provider metadata and unknown fields`() { + val raw = """{ + "all": [{ + "id": "openai", + "name": "OpenAI", + "description": "Build with OpenAI models", + "source": "api", + "metadata": { + "noteKey": "settings.providers.note.openai", + "icon": "openai", + "priority": 3, + "extra": true + }, + "unknown": "ok", + "models": { + "gpt-5": { + "name": "GPT-5", + "capabilities": {}, + "mayTrainOnYourPrompts": true + } + } + }], + "default": {"code":"openai/gpt-5"}, + "connected": ["openai"] + }""" + + val result = KiloCliDataParser.parseProviderSettingsProviders(raw) + val provider = result.first.single() + + assertEquals("settings.providers.note.openai", provider.metadata?.noteKey) + assertEquals("Build with OpenAI models", provider.description) + assertEquals("openai", provider.metadata?.icon) + assertEquals(3, provider.metadata?.priority) + assertTrue(provider.models.getValue("gpt-5").mayTrainOnYourPrompts) + assertEquals(listOf("openai"), result.second) + assertEquals(mapOf("code" to "openai/gpt-5"), result.third) + } + + @Test + fun `parseProviderSettingsProviders - malformed metadata becomes null`() { + val raw = """{ + "all": [{"id":"p","name":"P","source":"api","metadata":"bad","models":{}}], + "default": {}, + "connected": [] + }""" + + val provider = KiloCliDataParser.parseProviderSettingsProviders(raw).first.single() + + assertNull(provider.metadata) + } + @Test fun `parseProviders - model boolean capabilities default to false`() { val raw = """{ @@ -1031,6 +1438,7 @@ class KiloCliDataParserTest { assertEquals(false, model.reasoning) assertEquals(false, model.temperature) assertEquals(false, model.toolCall) + assertFalse(model.mayTrainOnYourPrompts) assertNull(model.limit) } @@ -1048,6 +1456,33 @@ class KiloCliDataParserTest { } } + @Test + fun `parseProviderAuth - maps structured select options`() { + val raw = """{ + "azure": [{ + "type": "api", + "label": "API key", + "prompts": [{ + "type": "select", + "key": "endpointType", + "message": "Select Azure endpoint configuration", + "options": [ + {"label": "Resource name", "value": "resourceName", "hint": "Build the endpoint"}, + {"label": "Full endpoint URL", "value": "baseURL"} + ] + }] + }] + }""" + + val prompt = KiloCliDataParser.parseProviderAuth(raw).getValue("azure").single().prompts.single() + + assertEquals("Select Azure endpoint configuration", prompt.label) + assertEquals("Resource name", prompt.options[0].label) + assertEquals("resourceName", prompt.options[0].value) + assertEquals("Full endpoint URL", prompt.options[1].label) + assertEquals("baseURL", prompt.options[1].value) + } + // ---- parseCommands ---- @Test @@ -1181,6 +1616,13 @@ class KiloCliDataParserTest { assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result) } + @Test + fun `buildProviderOAuthJson - numeric method index`() { + val result = KiloCliDataParser.buildProviderOAuthJson("0", mapOf("deploymentType" to "github.com")) + + assertEquals("""{"method":0,"inputs":{"deploymentType":"github.com"}}""", result) + } + @Test fun `buildPromptJson - with agent`() { val prompt = PromptDto( @@ -1208,6 +1650,128 @@ class KiloCliDataParserTest { assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) } + @Test + fun `buildPromptJson - mixed text and file parts`() { + val prompt = PromptDto( + parts = listOf( + PromptPartDto(type = "text", text = "see this"), + PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"), + ) + ) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"text","text":"see this"},{"type":"file","mime":"image/png","url":"file:///tmp/a.png","filename":"a.png"}]}""", + result, + ) + } + + @Test + fun `buildPromptJson - file only omits optional filename`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto(type = "file", mime = "application/pdf", url = "file:///tmp/a.pdf")) + ) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"file","mime":"application/pdf","url":"file:///tmp/a.pdf"}]}""", + result, + ) + } + + @Test + fun `buildPromptJson - escapes file metadata`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto(type = "file", mime = "text/plain", url = "file:///tmp/a%20b.txt", filename = "a \"b\".txt")) + ) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertTrue(result.contains(""""filename":"a \"b\".txt""""), result) + } + + @Test + fun `buildPromptJson - file part includes source metadata`() { + val prompt = PromptDto(parts = listOf(PromptPartDto( + type = "file", + mime = "text/plain", + url = "file:///tmp/a.kt", + filename = "a.kt", + source = PartSourceDto( + type = "file", + path = "src/a.kt", + text = PartSourceTextDto("@src/a.kt", 4.0, 13.0), + ), + ))) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"file","mime":"text/plain","url":"file:///tmp/a.kt","filename":"a.kt","source":{"type":"file","text":{"value":"@src/a.kt","start":4.0,"end":13.0},"path":"src/a.kt"}}]}""", + result, + ) + } + + @Test + fun `buildPromptJson - data file part includes source metadata`() { + val prompt = PromptDto(parts = listOf(PromptPartDto( + type = "file", + mime = "text/plain", + url = "data:text/plain;charset=utf-8,diff%20content", + filename = "git-changes.txt", + source = PartSourceDto( + type = "file", + text = PartSourceTextDto("@git-changes", 7.0, 19.0), + path = "git-changes", + ), + ))) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"file","mime":"text/plain","url":"data:text/plain;charset=utf-8,diff%20content","filename":"git-changes.txt","source":{"type":"file","text":{"value":"@git-changes","start":7.0,"end":19.0},"path":"git-changes"}}]}""", + result, + ) + } + + @Test + fun `buildCommandJson - file part includes source metadata`() { + val prompt = PromptDto(parts = listOf(PromptPartDto( + type = "file", + mime = "text/plain", + url = "file:///tmp/a.kt", + source = PartSourceDto( + type = "file", + path = "src/a.kt", + text = PartSourceTextDto("@src/a.kt", 0.0, 9.0), + ), + ))) + + val result = KiloCliDataParser.buildCommandJson("review", "", prompt) + + assertTrue(result.contains(""""source":{"type":"file","text":{"value":"@src/a.kt","start":0.0,"end":9.0},"path":"src/a.kt"}"""), result) + } + + @Test + fun `buildCommandJson - includes agent variant model and arguments`() { + val prompt = PromptDto( + parts = emptyList(), + agent = "code", + variant = "high", + providerID = "kilo", + modelID = "gpt-5", + ) + + val result = KiloCliDataParser.buildCommandJson("review", "src/", prompt) + + assertEquals( + """{"command":"review","arguments":"src/","agent":"code","variant":"high","model":"kilo/gpt-5"}""", + result, + ) + } + // ---- buildSummarizeJson ---- @Test @@ -1239,6 +1803,47 @@ class KiloCliDataParserTest { assertEquals("{}", result) } + @Test + fun `buildConfigPatch - top-level model set`() { + val patch = ConfigPatchDto(values = linkedMapOf("model" to "anthropic/claude")) + assertEquals("{\"model\":\"anthropic/claude\"}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - top-level model clear emits null`() { + val patch = ConfigPatchDto(values = linkedMapOf("model" to null)) + assertEquals("{\"model\":null}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - small and subagent values`() { + val patch = ConfigPatchDto(values = linkedMapOf("small_model" to "kilo/auto-small", "subagent_model" to null, "subagent_variant" to null)) + assertEquals("{\"small_model\":\"kilo/auto-small\",\"subagent_model\":null,\"subagent_variant\":null}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - per-agent model set`() { + val patch = ConfigPatchDto(agents = linkedMapOf("code" to AgentConfigPatchDto(model = "kilo/gpt-5"))) + assertEquals("{\"agent\":{\"code\":{\"model\":\"kilo/gpt-5\"}}}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - per-agent model clear emits null`() { + val patch = ConfigPatchDto(agents = linkedMapOf("code" to AgentConfigPatchDto(model = null))) + assertEquals("{\"agent\":{\"code\":{\"model\":null}}}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - empty patch`() { + assertEquals("{}", KiloCliDataParser.buildConfigPatch(ConfigPatchDto())) + } + + @Test + fun `buildConfigPatch - escapes special characters`() { + val patch = ConfigPatchDto(values = linkedMapOf("model" to "kilo/a\\b\"c")) + assertEquals("{\"model\":\"kilo/a\\\\b\\\"c\"}", KiloCliDataParser.buildConfigPatch(patch)) + } + @Test fun `buildConfigPartial - temperature without agent defaults to ask`() { val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) @@ -1513,6 +2118,34 @@ class KiloCliDataParserTest { assertNull(result[0].message) } + @Test + fun `sanitizeUserPromptText - removes read payload lines`() { + val text = "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter" + + assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text)) + } + + @Test + fun `sanitizeUserPromptText - handles read case variants and path key`() { + val text = "before\nCalled the READ tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter" + + assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text)) + } + + @Test + fun `sanitizeUserPromptText - preserves ordinary prose without path key`() { + val text = "Called the Read tool with the following input: please inspect the file" + + assertEquals(text, KiloCliDataParser.sanitizeUserPromptText(text)) + } + + @Test + fun `sanitizeUserPromptText - collapses only blanks introduced by payload removal`() { + val text = "before\n\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\n\nafter\n\n\nkeep" + + assertEquals("before\n\nafter\n\n\nkeep", KiloCliDataParser.sanitizeUserPromptText(text)) + } + // ================================================================ // Helpers // ================================================================ @@ -1520,4 +2153,44 @@ class KiloCliDataParserTest { /** Wrap payload content in a GlobalEvent structure. */ private fun globalEvent(payload: String): String = """{"directory":"/tmp","payload":{$payload}}""" + + private fun messageUpdated(id: String, role: String): String = globalEvent(""" + "type": "message.updated", + "properties": { + "sessionID": "s1", + "info": { "id": "$id", "sessionID": "s1", "role": "$role", "time": { "created": 1.0 } } + } + """) + + private fun partUpdated(mid: String, pid: String, type: String, text: String): String = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "s1", + "part": { "id": "$pid", "sessionID": "s1", "messageID": "$mid", "type": "$type", "text": ${escape(text)} } + } + """) + + private fun partDelta(mid: String, pid: String, delta: String): String = globalEvent(""" + "type": "message.part.delta", + "properties": { + "sessionID": "s1", + "messageID": "$mid", + "partID": "$pid", + "field": "text", + "delta": ${escape(delta)} + } + """) + + private fun escape(text: String) = buildString { + append('"') + for (ch in text) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + else -> append(ch) + } + } + append('"') + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt index 35ba6f52e34..d9ad9a7c96f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt @@ -111,7 +111,8 @@ class ProjectModelSerializationTest { "options": {}, "headers": {}, "release_date": "2025-01-01", - "isFree": true + "isFree": true, + "hasUserByokAvailable": true } } }], @@ -122,6 +123,7 @@ class ProjectModelSerializationTest { val obj = json.decodeFromString(src) val model = obj.all[0].models["free-model"]!! assertEquals(true, model.isFree) + assertEquals(true, model.hasUserByokAvailable) assertEquals( ai.kilocode.jetbrains.api.model.Model.Status.BETA, model.status, diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index e837d2693b9..078b1262f02 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend.cli +import ai.kilocode.backend.migration.session.LegacySessionIds import ai.kilocode.jetbrains.api.infrastructure.Serializer import ai.kilocode.jetbrains.api.model.Session import ai.kilocode.jetbrains.api.model.SessionStatus @@ -40,6 +41,27 @@ class SessionModelSerializationTest { assertNull(obj.summary) } + @Test + fun `Session decodes canonical migrated and unprefixed legacy IDs`() { + val src = """{ + "id": "ses_abc", + "slug": "canonical", + "projectID": "prj_123", + "directory": "/test/project", + "title": "Canonical", + "version": "1.0.0", + "time": {"created": 1000, "updated": 2000} + }""" + val migrated = LegacySessionIds.createSessionId("task-abc") + val canonical = json.decodeFromString(src) + val imported = json.decodeFromString(src.replace("ses_abc", migrated)) + val legacy = json.decodeFromString(src.replace("ses_abc", "s1")) + + assertEquals("ses_abc", canonical.id) + assertEquals(migrated, imported.id) + assertEquals("s1", legacy.id) + } + @Test fun `Session with summary`() { val src = """{ @@ -54,9 +76,9 @@ class SessionModelSerializationTest { }""" val obj = json.decodeFromString(src) assertNotNull(obj.summary) - assertEquals(10, obj.summary!!.additions) - assertEquals(5, obj.summary!!.deletions) - assertEquals(3, obj.summary!!.files) + assertEquals(10.0, obj.summary!!.additions) + assertEquals(5.0, obj.summary!!.deletions) + assertEquals(3.0, obj.summary!!.files) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationConversionTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationConversionTest.kt new file mode 100644 index 00000000000..978f6adb777 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationConversionTest.kt @@ -0,0 +1,320 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for provider, MCP, custom-mode, and settings conversion logic. + */ +class LegacyMigrationConversionTest { + + // ----------------------------------------------------------------------- + // Provider mapping + // ----------------------------------------------------------------------- + + @Test + fun `convertProvider - api-key provider builds api auth`() { + val settings = buildJsonObject { + put("apiProvider", "anthropic") + put("apiKey", "sk-ant-abc") + } + val result = LegacyMigrationConverters.convertProvider("anthropic-profile", settings) { null } + assertEquals(MigrationItemStatus.success, result.status) + assertNotNull(result.auth) + assertEquals("api", result.auth!!["type"]?.jsonPrimitive?.content) + assertEquals("sk-ant-abc", result.auth["key"]?.jsonPrimitive?.content) + assertNull(result.config) + } + + @Test + fun `convertProvider - kilo provider builds OAuth auth with 1-year expiry`() { + val settings = buildJsonObject { + put("apiProvider", "kilocode") + put("kilocodeToken", "tok-123") + } + val result = LegacyMigrationConverters.convertProvider("kilo-profile", settings) { null } + assertEquals(MigrationItemStatus.success, result.status) + val auth = result.auth!! + assertEquals("oauth", auth["type"]?.jsonPrimitive?.content) + assertEquals("tok-123", auth["access"]?.jsonPrimitive?.content) + assertEquals("tok-123", auth["refresh"]?.jsonPrimitive?.content) + val expires = auth["expires"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L + assertTrue(expires > System.currentTimeMillis()) + } + + @Test + fun `convertProvider - unsupported provider returns warning`() { + val settings = buildJsonObject { + put("apiProvider", "glama") + put("apiKey", "k") + } + val result = LegacyMigrationConverters.convertProvider("p", settings) { null } + assertEquals(MigrationItemStatus.warning, result.status) + assertNull(result.auth) + } + + @Test + fun `convertProvider - unknown provider returns warning`() { + val settings = buildJsonObject { put("apiProvider", "totally-unknown-provider") } + val result = LegacyMigrationConverters.convertProvider("p", settings) { null } + assertEquals(MigrationItemStatus.warning, result.status) + } + + @Test + fun `convertProvider - no api key returns warning`() { + val settings = buildJsonObject { put("apiProvider", "anthropic") } + val result = LegacyMigrationConverters.convertProvider("p", settings) { null } + assertEquals(MigrationItemStatus.warning, result.status) + } + + @Test + fun `convertProvider - vertex skips auth and writes config`() { + val settings = buildJsonObject { + put("apiProvider", "vertex") + put("vertexProjectId", "my-project") + put("vertexRegion", "us-central1") + } + val result = LegacyMigrationConverters.convertProvider("p", settings) { null } + assertEquals(MigrationItemStatus.success, result.status) + assertNull(result.auth) + assertNotNull(result.config) + val opts = result.config!!["provider"]?.jsonObject?.get("google-vertex")?.jsonObject?.get("options")?.jsonObject + assertEquals("my-project", opts?.get("project")?.jsonPrimitive?.content) + assertEquals("us-central1", opts?.get("location")?.jsonPrimitive?.content) + } + + @Test + fun `convertProvider - vertex with inline credentials returns warning`() { + val settings = buildJsonObject { + put("apiProvider", "vertex") + put("vertexProjectId", "p") + put("vertexJsonCredentials", "{\"type\":\"service_account\"}") + } + val result = LegacyMigrationConverters.convertProvider("p", settings) { null } + assertEquals(MigrationItemStatus.warning, result.status) + } + + @Test + fun `convertProvider - base URL config patch written for openai`() { + val settings = buildJsonObject { + put("apiProvider", "openai") + put("openAiApiKey", "sk-x") + put("openAiBaseUrl", "https://my.openai.com/v1") + } + val result = LegacyMigrationConverters.convertProvider("p", settings) { null } + assertEquals(MigrationItemStatus.success, result.status) + assertNotNull(result.config) + val opts = result.config!!["provider"]?.jsonObject?.get("openai-compatible")?.jsonObject?.get("options")?.jsonObject + assertEquals("https://my.openai.com/v1", opts?.get("baseURL")?.jsonPrimitive?.content) + } + + @Test + fun `convertProvider - OAuth secret provider uses oauthRaw callback`() { + val settings = buildJsonObject { put("apiProvider", "openai-codex") } + val oauthJson = """{"access_token":"acc","refresh_token":"ref","expires":9999999999000}""" + val result = LegacyMigrationConverters.convertProvider("p", settings) { if (it == "openai-codex-oauth-credentials") oauthJson else null } + assertEquals(MigrationItemStatus.success, result.status) + val auth = result.auth!! + assertEquals("oauth", auth["type"]?.jsonPrimitive?.content) + assertEquals("acc", auth["access"]?.jsonPrimitive?.content) + } + + // ----------------------------------------------------------------------- + // MCP conversion + // ----------------------------------------------------------------------- + + @Test + fun `convertMcpServer - stdio becomes local`() { + val server = LegacyMcpServer( + type = null, + command = "npx", + args = listOf("-y", "my-tool"), + url = null, env = null, headers = null, disabled = null, timeout = null, + ) + val result = LegacyMigrationConverters.convertMcpServer("my-tool", server)!! + assertEquals("local", result["type"]?.jsonPrimitive?.content) + val cmd = result["command"] + assertNotNull(cmd) + } + + @Test + fun `convertMcpServer - sse becomes remote`() { + val server = LegacyMcpServer(type = "sse", command = null, args = null, url = "https://example.com/mcp", env = null, headers = null, disabled = null, timeout = null) + val result = LegacyMigrationConverters.convertMcpServer("remote", server)!! + assertEquals("remote", result["type"]?.jsonPrimitive?.content) + assertEquals("https://example.com/mcp", result["url"]?.jsonPrimitive?.content) + } + + @Test + fun `convertMcpServer - streamable-http becomes remote`() { + val server = LegacyMcpServer(type = "streamable-http", command = null, args = null, url = "https://api.example.com/mcp", env = null, headers = null, disabled = null, timeout = null) + val result = LegacyMigrationConverters.convertMcpServer("s", server)!! + assertEquals("remote", result["type"]?.jsonPrimitive?.content) + } + + @Test + fun `convertMcpServer - timeout seconds to milliseconds`() { + val server = LegacyMcpServer(type = null, command = "node", args = null, url = null, env = null, headers = null, disabled = null, timeout = 30) + val result = LegacyMigrationConverters.convertMcpServer("t", server)!! + assertEquals("30000", result["timeout"]?.jsonPrimitive?.content) + } + + @Test + fun `convertMcpServer - disabled true sets enabled false`() { + val server = LegacyMcpServer(type = null, command = "node", args = null, url = null, env = null, headers = null, disabled = true, timeout = null) + val result = LegacyMigrationConverters.convertMcpServer("d", server)!! + assertEquals("false", result["enabled"]?.jsonPrimitive?.content) + } + + @Test + fun `convertMcpServer - missing url for sse returns null`() { + val server = LegacyMcpServer(type = "sse", command = null, args = null, url = null, env = null, headers = null, disabled = null, timeout = null) + assertNull(LegacyMigrationConverters.convertMcpServer("no-url", server)) + } + + // ----------------------------------------------------------------------- + // Custom mode / agent + // ----------------------------------------------------------------------- + + @Test + fun `convertCustomMode - description from description field`() { + val mode = LegacyCustomMode( + slug = "my-mode", + name = "My Mode", + roleDefinition = "You are helpful.", + customInstructions = null, + whenToUse = null, + description = "Short desc", + groups = listOf("read", "edit"), + ) + val result = LegacyMigrationConverters.convertCustomMode(mode) + assertEquals("Short desc", result["description"]?.jsonPrimitive?.content) + assertEquals("primary", result["mode"]?.jsonPrimitive?.content) + assertTrue(result["prompt"]?.jsonPrimitive?.content?.contains("You are helpful.") == true) + } + + @Test + fun `convertCustomMode - description falls back to whenToUse`() { + val mode = LegacyCustomMode("s", "N", "Role", null, "When to use", null, listOf()) + val result = LegacyMigrationConverters.convertCustomMode(mode) + assertEquals("When to use", result["description"]?.jsonPrimitive?.content) + } + + @Test + fun `convertCustomMode - description falls back to roleDefinition truncated`() { + val role = "X".repeat(200) + val mode = LegacyCustomMode("s", "N", role, null, null, null, listOf()) + val result = LegacyMigrationConverters.convertCustomMode(mode) + assertEquals(120, result["description"]?.jsonPrimitive?.content?.length) + } + + @Test + fun `convertCustomMode - customInstructions appended to prompt`() { + val mode = LegacyCustomMode("s", "N", "Role.", "Custom extra.", null, null, listOf()) + val prompt = LegacyMigrationConverters.convertCustomMode(mode)["prompt"]?.jsonPrimitive?.content!! + assertTrue(prompt.contains("Role.")) + assertTrue(prompt.contains("Custom extra.")) + assertTrue(prompt.contains("USER'S CUSTOM INSTRUCTIONS")) + } + + @Test + fun `convertCustomModePermissions - read edit groups`() { + val groups: List = listOf("read", "edit") + val perm = LegacyMigrationConverters.convertCustomModePermissions(groups) + assertEquals("allow", perm["read"]?.jsonPrimitive?.content) + assertEquals("allow", perm["edit"]?.jsonPrimitive?.content) + assertEquals("deny", perm["bash"]?.jsonPrimitive?.content) + assertEquals("deny", perm["skill"]?.jsonPrimitive?.content) + } + + @Test + fun `convertCustomModePermissions - browser and command both map to bash`() { + val groups: List = listOf("browser", "command") + val perm = LegacyMigrationConverters.convertCustomModePermissions(groups) + assertEquals("allow", perm["bash"]?.jsonPrimitive?.content) + } + + @Test + fun `convertCustomModePermissions - mcp maps to skill`() { + val perm = LegacyMigrationConverters.convertCustomModePermissions(listOf("mcp")) + assertEquals("allow", perm["skill"]?.jsonPrimitive?.content) + } + + @Test + fun `convertCustomModePermissions - fileRegex produces object permission`() { + val groups: List = listOf(Pair("edit", mapOf("fileRegex" to "\\.md$"))) + val perm = LegacyMigrationConverters.convertCustomModePermissions(groups) + val editPerm = perm["edit"]?.jsonObject + assertNotNull(editPerm) + assertEquals("allow", editPerm["\\.md$"]?.jsonPrimitive?.content) + assertEquals("deny", editPerm["*"]?.jsonPrimitive?.content) + } + + // ----------------------------------------------------------------------- + // Auto-approval + // ----------------------------------------------------------------------- + + @Test + fun `convertAutoApproval - master allow with no command lists writes scalar allow`() { + val settings = LegacySettings( + autoApprovalEnabled = true, + allowedCommands = emptyList(), + deniedCommands = emptyList(), + alwaysAllowReadOnly = null, alwaysAllowReadOnlyOutsideWorkspace = null, + alwaysAllowWrite = null, alwaysAllowExecute = null, + alwaysAllowMcp = null, alwaysAllowModeSwitch = null, alwaysAllowSubtasks = null, + language = null, autocomplete = null, + ) + val sel = MigrationAutoApprovalSelections(commandRules = true, readPermission = false, writePermission = false, executePermission = false, mcpPermission = false, taskPermission = false) + val conv = LegacyMigrationConverters.convertAutoApproval(settings, sel) + assertNotNull(conv.config) + assertEquals("allow", conv.config!!["permission"]?.jsonPrimitive?.content) + } + + @Test + fun `convertAutoApproval - command allow deny lists write bash rules`() { + val settings = LegacySettings( + autoApprovalEnabled = true, + allowedCommands = listOf("git", "npm run"), + deniedCommands = listOf("rm"), + alwaysAllowReadOnly = null, alwaysAllowReadOnlyOutsideWorkspace = null, + alwaysAllowWrite = null, alwaysAllowExecute = null, + alwaysAllowMcp = null, alwaysAllowModeSwitch = null, alwaysAllowSubtasks = null, + language = null, autocomplete = null, + ) + val sel = MigrationAutoApprovalSelections(commandRules = true, readPermission = false, writePermission = false, executePermission = false, mcpPermission = false, taskPermission = false) + val conv = LegacyMigrationConverters.convertAutoApproval(settings, sel) + val perm = conv.config!!["permission"]?.jsonObject + val bash = perm?.get("bash")?.jsonObject + assertNotNull(bash) + assertEquals("allow", bash["git *"]?.jsonPrimitive?.content) + assertEquals("allow", bash["npm run *"]?.jsonPrimitive?.content) + assertEquals("deny", bash["rm *"]?.jsonPrimitive?.content) + } + + @Test + fun `convertAutoApproval - read permission`() { + val settings = LegacySettings(null, null, null, alwaysAllowReadOnly = true, alwaysAllowReadOnlyOutsideWorkspace = true, null, null, null, null, null, null, null) + val sel = MigrationAutoApprovalSelections(false, readPermission = true, false, false, false, false) + val conv = LegacyMigrationConverters.convertAutoApproval(settings, sel) + val perm = conv.config!!["permission"]?.jsonObject!! + assertEquals("allow", perm["read"]?.jsonPrimitive?.content) + assertEquals("allow", perm["external_directory"]?.jsonPrimitive?.content) + } + + @Test + fun `convertAutoApproval - write permission`() { + val settings = LegacySettings(null, null, null, null, null, alwaysAllowWrite = true, null, null, null, null, null, null) + val sel = MigrationAutoApprovalSelections(false, false, writePermission = true, false, false, false) + val conv = LegacyMigrationConverters.convertAutoApproval(settings, sel) + assertEquals("allow", conv.config!!["permission"]?.jsonObject?.get("edit")?.jsonPrimitive?.content) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt new file mode 100644 index 00000000000..438bea00d9b --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationDetectionTest.kt @@ -0,0 +1,272 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for LegacyMigrationEngine.detect() using the production file-backed store. + */ +class LegacyMigrationDetectionTest { + + private fun engine(configure: LegacySettingsFileFixture.() -> Unit = {}): Pair { + val fixture = LegacySettingsFileFixture().apply(configure) + val store = fixture.store() + val backend = NoopLegacyMigrationBackend() + return LegacyMigrationEngine(store, backend) to fixture + } + + // ----------------------------------------------------------------------- + // Providers from legacy secret JSON + // ----------------------------------------------------------------------- + + @Test + fun `detect - providers from legacy secret JSON`() { + val (eng, _) = engine { + providerProfiles = """ + { + "currentApiConfigName": "anthropic-profile", + "apiConfigs": { + "anthropic-profile": { + "apiProvider": "anthropic", + "apiKey": "sk-ant-abc123", + "apiModelId": "claude-3-5-sonnet-20241022" + } + } + } + """.trimIndent() + } + val result = eng.detect() + assertEquals(1, result.providers.size) + val p = result.providers[0] + assertEquals("anthropic-profile", p.profileName) + assertEquals("anthropic", p.provider) + assertTrue(p.hasApiKey) + assertTrue(p.supported) + assertEquals("Anthropic", p.newProviderName) + assertTrue(result.hasData) + } + + @Test + fun `detect - unsupported provider flagged as unsupported`() { + val (eng, _) = engine { + providerProfiles = """ + { + "currentApiConfigName": "my-glama", + "apiConfigs": { + "my-glama": { "apiProvider": "glama", "apiKey": "x" } + } + } + """.trimIndent() + } + val result = eng.detect() + assertEquals(1, result.providers.size) + assertFalse(result.providers[0].supported) + } + + @Test + fun `detect - unknown provider flagged as unsupported`() { + val (eng, _) = engine { + providerProfiles = """ + { + "currentApiConfigName": "mystery", + "apiConfigs": { + "mystery": { "apiProvider": "mystery-provider-x", "apiKey": "k" } + } + } + """.trimIndent() + } + val p = eng.detect().providers[0] + assertFalse(p.supported) + assertNull(p.newProviderName) + } + + @Test + fun `detect - no api key means hasApiKey false`() { + val (eng, _) = engine { + providerProfiles = """ + { + "currentApiConfigName": "empty", + "apiConfigs": { + "empty": { "apiProvider": "anthropic" } + } + } + """.trimIndent() + } + assertFalse(eng.detect().providers[0].hasApiKey) + } + + // ----------------------------------------------------------------------- + // MCP servers + // ----------------------------------------------------------------------- + + @Test + fun `detect - MCP servers from JSON`() { + val (eng, _) = engine { + mcpSettings = """ + { + "mcpServers": { + "my-tool": { "command": "npx", "args": ["-y", "my-tool"] }, + "remote-tool": { "type": "sse", "url": "https://example.com/mcp", "disabled": true } + } + } + """.trimIndent() + } + val result = eng.detect() + assertEquals(2, result.mcpServers.size) + val local = result.mcpServers.find { it.name == "my-tool" }!! + assertEquals("stdio", local.type) + assertNull(local.disabled) + val remote = result.mcpServers.find { it.name == "remote-tool" }!! + assertEquals("sse", remote.type) + assertEquals(true, remote.disabled) + } + + // ----------------------------------------------------------------------- + // Custom modes + // ----------------------------------------------------------------------- + + @Test + fun `detect - custom modes from JSON`() { + val (eng, _) = engine { + customModes = """ + { + "customModes": [ + { "slug": "my-mode", "name": "My Mode", "roleDefinition": "You are helpful.", "groups": ["read", "edit"] } + ] + } + """.trimIndent() + } + val result = eng.detect() + assertEquals(1, result.customModes.size) + assertEquals("my-mode", result.customModes[0].slug) + assertEquals("My Mode", result.customModes[0].name) + assertNull(result.customModes[0].nativeSlug) + } + + @Test + fun `detect - custom modes from YAML`() { + val (eng, _) = engine { + customModes = """ +customModes: + - slug: yaml-mode + name: YAML Mode + roleDefinition: | + You are a YAML assistant. + groups: + - read + - edit +""".trimIndent() + } + val result = eng.detect() + assertEquals(1, result.customModes.size) + assertEquals("yaml-mode", result.customModes[0].slug) + } + + @Test + fun `detect - native mode slug not included in custom modes`() { + val (eng, _) = engine { + customModes = """ + { + "customModes": [ + { "slug": "code", "name": "Code Custom", "roleDefinition": "modified", "groups": [] } + ] + } + """.trimIndent() + } + val result = eng.detect() + // "code" is a native slug — it should appear as a modified native mode under "code-custom" + val withNative = result.customModes.filter { it.nativeSlug != null } + assertEquals(1, withNative.size) + assertEquals("code-custom", withNative[0].slug) + assertEquals("code", withNative[0].nativeSlug) + } + + // ----------------------------------------------------------------------- + // Settings + // ----------------------------------------------------------------------- + + @Test + fun `detect - settings from global state keys`() { + val (eng, _) = engine { + globalState["kilo-code.autoApprovalEnabled"] = JsonPrimitive("true") + globalState["alwaysAllowReadOnly"] = JsonPrimitive("true") + globalState["kilo-code.language"] = JsonPrimitive("en") + } + val result = eng.detect() + assertNotNull(result.settings) + assertEquals(true, result.settings!!.autoApprovalEnabled) + assertEquals(true, result.settings!!.alwaysAllowReadOnly) + assertEquals("en", result.settings!!.language) + } + + // ----------------------------------------------------------------------- + // Sessions + // ----------------------------------------------------------------------- + + @Test + fun `detect - sessions listed only when conversation exists`() { + val (eng, _) = engine { + taskHistory = """[ + {"id": "task-1", "task": "Fix bug", "workspace": "/tmp/project", "ts": 1700000000000}, + {"id": "task-2", "task": "Add feature", "workspace": "/tmp/project", "ts": 1700000001000} + ]""".trimIndent() + conversations["task-1"] = """[{"role":"user","content":"Fix the bug"}]""" + // task-2 has no conversation file — should not appear + } + val result = eng.detect() + assertEquals(1, result.sessions.size) + assertEquals("task-1", result.sessions[0].id) + assertEquals("Fix bug", result.sessions[0].title) + } + + @Test + fun `detect - hasData false when nothing present`() { + val (eng, _) = engine {} + assertFalse(eng.detect().hasData) + } + + // ----------------------------------------------------------------------- + // Default model + // ----------------------------------------------------------------------- + + @Test + fun `detect - default model resolved from active profile`() { + val (eng, _) = engine { + providerProfiles = """ + { + "currentApiConfigName": "openai-profile", + "apiConfigs": { + "openai-profile": { + "apiProvider": "openai-native", + "openAiNativeApiKey": "sk-x", + "apiModelId": "gpt-4o" + } + } + } + """.trimIndent() + } + val result = eng.detect() + assertNotNull(result.defaultModel) + assertEquals("gpt-4o", result.defaultModel!!.model) + assertEquals("OpenAI", result.defaultModel!!.provider) + } + + // ----------------------------------------------------------------------- + // Status + // ----------------------------------------------------------------------- + + @Test + fun `mark and status round-trip`() { + val (eng, store) = engine {} + assertNull(eng.status()) + eng.mark(LegacyMigrationStatus.Completed) + assertEquals(LegacyMigrationStatus.Completed, eng.status()) + store.refresh() + assertEquals(LegacyMigrationStatus.Completed, store.migrationStatus) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationHttpBackendTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationHttpBackendTest.kt new file mode 100644 index 00000000000..eaf359c2602 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationHttpBackendTest.kt @@ -0,0 +1,127 @@ +package ai.kilocode.backend.migration + +import ai.kilocode.backend.cli.KiloBackendHttpClients +import ai.kilocode.backend.testing.MockCliServer +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * HTTP adapter tests using MockCliServer. + */ +class LegacyMigrationHttpBackendTest { + + private fun withServer(block: (MockCliServer, LegacyMigrationHttpBackend) -> Unit) { + val server = MockCliServer() + val port = server.start() + val client = KiloBackendHttpClients.api(server.password) + val backend = LegacyMigrationHttpBackend(client, "http://127.0.0.1:$port") + try { + block(server, backend) + } finally { + KiloBackendHttpClients.shutdown(client) + server.close() + } + } + + // ----------------------------------------------------------------------- + // Auth + // ----------------------------------------------------------------------- + + @Test + fun `setAuth sends PUT to auth endpoint with provider ID`() { + withServer { server, backend -> + val auth = buildJsonObject { + put("type", "api") + put("key", "sk-ant-test") + } + backend.setAuth("anthropic", auth) + assertEquals(1, server.requestCount("/auth/anthropic")) + assertEquals(auth.toString(), server.lastAuthPutBody) + } + } + + // ----------------------------------------------------------------------- + // Global config + // ----------------------------------------------------------------------- + + @Test + fun `updateGlobalConfig sends PATCH to global config endpoint`() { + withServer { server, backend -> + val patch = buildJsonObject { put("model", "anthropic/claude-3-5-sonnet-20241022") } + // MockCliServer responds 200 for /global/config + backend.updateGlobalConfig(patch) + assertEquals(1, server.requestCount("/global/config")) + } + } + + // ----------------------------------------------------------------------- + // Session existence + // ----------------------------------------------------------------------- + + @Test + fun `sessionExists returns true for known session`() { + withServer { server, backend -> + server.sessionGetStatus = 200 + // MockCliServer returns 200 for GET /session/ses_test + assertTrue(backend.sessionExists("ses_test")) + } + } + + @Test + fun `sessionExists returns false for unknown session`() { + withServer { server, backend -> + server.sessionGetStatus = 404 + assertFalse(backend.sessionExists("ses_nonexistent")) + } + } + + // ----------------------------------------------------------------------- + // Session import + // ----------------------------------------------------------------------- + + @Test + fun `importProject posts to kilocode session-import project endpoint`() { + withServer { server, backend -> + val project = buildJsonObject { + put("id", "prj_test") + put("worktree", "/tmp") + put("sandboxes", kotlinx.serialization.json.JsonArray(emptyList())) + put("timeCreated", 0L) + put("timeUpdated", 0L) + } + // MockCliServer returns 404 for unknown paths; we verify the exception + val ex = runCatching { backend.importProject(project) }.exceptionOrNull() + // Should fail with 404 since MockCliServer doesn't handle this path + assertNotNull(ex) + } + } + + @Test + fun `importSession posts to kilocode session-import session endpoint`() { + withServer { _, backend -> + val session = buildJsonObject { + put("id", "ses_migrated_abcdef") + put("projectID", "prj_test") + put("slug", "old-task-id") + put("directory", "/tmp") + put("title", "Test") + put("version", "v2") + put("timeCreated", 0L) + put("timeUpdated", 0L) + } + val ex = runCatching { backend.importSession(session) }.exceptionOrNull() + assertNotNull(ex) + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private fun assertNotNull(actual: Any?) = kotlin.test.assertNotNull(actual) +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt new file mode 100644 index 00000000000..63cdb287389 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationOrchestrationTest.kt @@ -0,0 +1,296 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for LegacyMigrationEngine.migrate() orchestration and progress sink behavior. + */ +class LegacyMigrationOrchestrationTest { + + private fun setup(configure: LegacySettingsFileFixture.() -> Unit = {}): Triple { + val fixture = LegacySettingsFileFixture().apply(configure) + val store = fixture.store() + val backend = NoopLegacyMigrationBackend() + return Triple(LegacyMigrationEngine(store, backend), fixture, backend) + } + + private fun noSelections() = LegacyMigrationSelections( + providers = emptyList(), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = false, + settings = MigrationSettingsSelections( + autoApproval = MigrationAutoApprovalSelections(false, false, false, false, false, false), + language = false, + autocomplete = false, + ), + ) + + // ----------------------------------------------------------------------- + // Provider migration + // ----------------------------------------------------------------------- + + @Test + fun `migrate - provider writes auth to backend`() { + val (eng, _, backend) = setup { + providerProfiles = """ + { + "currentApiConfigName": "p", + "apiConfigs": { + "p": { "apiProvider": "anthropic", "apiKey": "sk-ant-x" } + } + } + """.trimIndent() + } + val sel = noSelections().copy(providers = listOf("p")) + val report = eng.migrate(sel) + assertEquals(1, report.items.size) + assertEquals(MigrationItemStatus.success, report.items[0].status) + assertEquals(1, backend.authCalls.size) + assertEquals("anthropic", backend.authCalls[0].first) + assertEquals("api", backend.authCalls[0].second["type"]?.jsonPrimitive?.content) + } + + @Test + fun `migrate - missing profile produces error result`() { + val (eng, _, _) = setup {} + val sel = noSelections().copy(providers = listOf("nonexistent")) + val report = eng.migrate(sel) + assertEquals(MigrationItemStatus.error, report.items[0].status) + } + + // ----------------------------------------------------------------------- + // MCP migration + // ----------------------------------------------------------------------- + + @Test + fun `migrate - MCP servers write config batch`() { + val (eng, _, backend) = setup { + mcpSettings = """ + { + "mcpServers": { + "tool1": { "command": "node", "args": ["tool.js"] }, + "tool2": { "type": "sse", "url": "https://example.com/mcp" } + } + } + """.trimIndent() + } + val sel = noSelections().copy(mcpServers = listOf("tool1", "tool2")) + val report = eng.migrate(sel) + assertEquals(2, report.items.filter { it.category == MigrationItemCategory.mcpServer && it.status == MigrationItemStatus.success }.size) + assertEquals(1, backend.configCalls.size) // one batched patch + val mcp = backend.configCalls[0]["mcp"]?.jsonObject + assertTrue(mcp?.containsKey("tool1") == true) + assertTrue(mcp?.containsKey("tool2") == true) + } + + // ----------------------------------------------------------------------- + // Custom mode migration + // ----------------------------------------------------------------------- + + @Test + fun `migrate - custom mode writes agent config`() { + val (eng, _, backend) = setup { + customModes = """ + { + "customModes": [ + { "slug": "my-agent", "name": "My Agent", "roleDefinition": "You are my agent.", "groups": ["read"] } + ] + } + """.trimIndent() + } + val sel = noSelections().copy(customModes = listOf("my-agent")) + val report = eng.migrate(sel) + assertEquals(MigrationItemStatus.success, report.items[0].status) + val patch = backend.configCalls.find { it.containsKey("agent") } + assertNotNull(patch) + assertTrue(patch!!["agent"]?.jsonObject?.containsKey("my-agent") == true) + } + + // ----------------------------------------------------------------------- + // Session migration + // ----------------------------------------------------------------------- + + @Test + fun `migrate - session imports project, session, messages, parts`() { + val (eng, _, backend) = setup { + taskHistory = """[{"id":"t1","task":"Do task","workspace":"/tmp","ts":1000}]""" + conversations["t1"] = """[ + {"role":"user","content":"Fix this","ts":1000}, + {"role":"assistant","content":"Sure","ts":1001} + ]""" + } + val sel = noSelections().copy(sessions = listOf(MigrationSessionSelection("t1"))) + val report = eng.migrate(sel) + assertEquals(MigrationItemStatus.success, report.items.find { it.category == MigrationItemCategory.session }?.status) + assertEquals(1, backend.projectCalls.size) + assertEquals(1, backend.sessionCalls.size) + assertEquals(2, backend.messageCalls.size) + } + + @Test + fun `migrate - duplicate session is silently skipped`() { + val (eng, _, backend) = setup { + taskHistory = """[{"id":"t1","task":"Test"}]""" + conversations["t1"] = """[{"role":"user","content":"hello"}]""" + } + val sessionId = ai.kilocode.backend.migration.session.LegacySessionIds.createSessionId("t1") + backend.existingSessionIds = setOf(sessionId) + val sel = noSelections().copy(sessions = listOf(MigrationSessionSelection("t1"))) + val items = mutableListOf() + val sessions = mutableListOf() + val report = eng.migrate(sel, sink(items, sessions)) + assertNull(report.items.find { it.category == MigrationItemCategory.session }) + assertEquals(emptyList(), items) + assertEquals(emptyList(), sessions) + assertEquals(0, backend.projectCalls.size) + assertEquals(0, backend.sessionCalls.size) + } + + @Test + fun `migrate - backend session skipped skips child imports`() { + val (eng, _, backend) = setup { + taskHistory = """[{"id":"t1","task":"Test"}]""" + conversations["t1"] = """[{"role":"user","content":"hello"}]""" + } + backend.sessionImportSkipped = true + val sel = noSelections().copy(sessions = listOf(MigrationSessionSelection("t1"))) + val items = mutableListOf() + val sessions = mutableListOf() + val report = eng.migrate(sel, sink(items, sessions)) + assertNull(report.items.find { it.category == MigrationItemCategory.session }) + assertEquals(0, backend.messageCalls.size) + assertEquals(0, backend.partCalls.size) + assertEquals(listOf(MigrationItemProgressStatus.migrating), items.map { it.status }) + assertEquals(listOf(MigrationSessionPhase.preparing, MigrationSessionPhase.storing, MigrationSessionPhase.summary), sessions.map { it.phase }) + } + + @Test + fun `migrate - child import failure produces warning result`() { + val (eng, _, backend) = setup { + taskHistory = """[{"id":"t1","task":"Test"}]""" + conversations["t1"] = """[{"role":"user","content":"hello"}]""" + } + backend.messageError = RuntimeException("message failed") + val items = mutableListOf() + val report = eng.migrate(noSelections().copy(sessions = listOf(MigrationSessionSelection("t1"))), itemSink(items)) + val item = report.items.single { it.category == MigrationItemCategory.session } + assertEquals(MigrationItemStatus.warning, item.status) + assertEquals("message failed", item.message) + assertEquals(MigrationItemProgressStatus.warning, items.last().status) + } + + @Test + fun `migrate - missing session conversation emits terminal error progress`() { + val (eng, _, _) = setup { + taskHistory = """[{"id":"t1","task":"Test"}]""" + } + val sel = noSelections().copy(sessions = listOf(MigrationSessionSelection("t1"))) + val items = mutableListOf() + eng.migrate(sel, itemSink(items)) + assertEquals(listOf(MigrationItemProgressStatus.migrating, MigrationItemProgressStatus.error), items.map { it.status }) + assertEquals("Conversation file not found", items[1].message) + } + + @Test + fun `migrate - autocomplete settings report success`() { + val (eng, _, _) = setup { + globalState["ghostServiceSettings"] = kotlinx.serialization.json.JsonObject( + mapOf( + "enableAutoTrigger" to JsonPrimitive(true), + "enableSmartInlineTaskKeybinding" to JsonPrimitive(true), + "enableChatAutocomplete" to JsonPrimitive(true), + ) + ) + } + val sel = noSelections().copy(settings = noSelections().settings.copy(autocomplete = true)) + val report = eng.migrate(sel) + val item = report.items.single { it.item == "Autocomplete settings" } + assertEquals(MigrationItemStatus.success, item.status) + assertEquals(null, item.message) + } + + // ----------------------------------------------------------------------- + // Progress sink ordering + // ----------------------------------------------------------------------- + + @Test + fun `migrate - progress sink called for each item`() { + val (eng, _, _) = setup { + providerProfiles = """{"currentApiConfigName":"p","apiConfigs":{"p":{"apiProvider":"anthropic","apiKey":"k"}}}""" + } + val items = mutableListOf() + val sink = object : LegacyMigrationSink { + override fun item(progress: LegacyMigrationItemProgress) { items.add(progress) } + override fun session(progress: LegacyMigrationSessionProgress) = Unit + } + eng.migrate(noSelections().copy(providers = listOf("p")), sink) + assertEquals(2, items.size) // migrating + success + assertEquals(MigrationItemProgressStatus.migrating, items[0].status) + assertEquals(MigrationItemProgressStatus.success, items[1].status) + } + + @Test + fun `report - hasErrors is true when any item errors`() { + val report = LegacyMigrationReport( + listOf( + LegacyMigrationResultItem("a", MigrationItemCategory.provider, MigrationItemStatus.success), + LegacyMigrationResultItem("b", MigrationItemCategory.provider, MigrationItemStatus.error), + ) + ) + assertTrue(report.hasErrors) + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + @Test + fun `cleanup - legacy settings file target deletes file`() { + val (eng, fixture, _) = setup { + providerProfiles = """{"currentApiConfigName":"p","apiConfigs":{}}""" + } + val report = eng.cleanup(LegacyCleanupTargets(legacySettingsFile = true)) + assertEquals(listOf("legacySettingsFile"), report.cleaned) + assertEquals(emptyList(), report.errors) + assertFalse(fixture.exists()) + } + + @Test + fun `cleanup - data target preserves legacy settings file`() { + val (eng, fixture, _) = setup { + providerProfiles = """{"currentApiConfigName":"p","apiConfigs":{}}""" + } + val report = eng.cleanup(LegacyCleanupTargets(providerProfiles = true)) + assertEquals(listOf("providerProfiles"), report.cleaned) + assertEquals(emptyList(), report.errors) + assertTrue(fixture.exists()) + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private fun assertNotNull(actual: Any?) = kotlin.test.assertNotNull(actual) + + private fun itemSink(items: MutableList) = object : LegacyMigrationSink { + override fun item(progress: LegacyMigrationItemProgress) { items.add(progress) } + override fun session(progress: LegacyMigrationSessionProgress) = Unit + } + + private fun sink( + items: MutableList, + sessions: MutableList, + ) = object : LegacyMigrationSink { + override fun item(progress: LegacyMigrationItemProgress) { items.add(progress) } + override fun session(progress: LegacyMigrationSessionProgress) { sessions.add(progress) } + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationSessionTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationSessionTest.kt new file mode 100644 index 00000000000..af66ef0d8c7 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacyMigrationSessionTest.kt @@ -0,0 +1,203 @@ +package ai.kilocode.backend.migration + +import ai.kilocode.backend.migration.session.LegacySessionIds +import ai.kilocode.backend.migration.session.LegacySessionParser +import ai.kilocode.backend.migration.session.LegacySessionParts +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for session ID generation, parsing, and part conversion. + */ +class LegacyMigrationSessionTest { + + // ----------------------------------------------------------------------- + // Deterministic IDs matching VS Code formulas + // ----------------------------------------------------------------------- + + @Test + fun `sessionId matches VS Code formula`() { + // VS Code: ses_migrated_${sha1(id).take(26)} + val id = "1234567890" + val expected = "ses_migrated_${sha1(id).take(26)}" + assertEquals(expected, LegacySessionIds.createSessionId(id)) + } + + @Test + fun `messageId matches VS Code formula`() { + val id = "abc-task" + val index = 3 + val expected = "msg_migrated_${sha1("$id:$index").take(26)}" + assertEquals(expected, LegacySessionIds.createMessageId(id, index)) + } + + @Test + fun `partId matches VS Code formula`() { + val id = "task-x" + val index = 1 + val part = 2 + val expected = "prt_migrated_${sha1("$id:$index:$part").take(26)}" + assertEquals(expected, LegacySessionIds.createPartId(id, index, part)) + } + + @Test + fun `projectId uses hash of worktree`() { + val path = "/home/user/project" + assertEquals(sha1(path), LegacySessionIds.createProjectId(path)) + } + + // ----------------------------------------------------------------------- + // Task wrapper stripping + // ----------------------------------------------------------------------- + + @Test + fun `cleanLegacyTaskText strips task wrapper`() { + val input = "Do the thing..." + assertEquals("Do the thing", LegacySessionParts.cleanLegacyTaskText(input)) + } + + @Test + fun `cleanLegacyTaskText returns empty for pure environment details`() { + val input = "some context" + assertEquals("", LegacySessionParts.cleanLegacyTaskText(input)) + } + + @Test + fun `isEnvironmentDetails matches environment_details block`() { + assertTrue(LegacySessionParts.isEnvironmentDetails("foo")) + assertFalse(LegacySessionParts.isEnvironmentDetails("Hello world")) + } + + // ----------------------------------------------------------------------- + // Reasoning preserved + // ----------------------------------------------------------------------- + + @Test + fun `reasoning_content extracted`() { + val entry = ai.kilocode.backend.migration.session.LegacyApiMessage( + role = "assistant", + content = listOf(mapOf("type" to "text", "text" to "Hi")), + ts = 0L, + isSummary = null, + id = null, + type = null, + text = null, + reasoning_content = " I think therefore I am ", + reasoning_details = null, + ) + val reasoning = LegacySessionParts.extractReasoningText(entry) + assertEquals("I think therefore I am", reasoning) + } + + @Test + fun `reasoning_details extracted from text field`() { + val entry = ai.kilocode.backend.migration.session.LegacyApiMessage( + role = "assistant", + content = listOf(), + ts = null, + isSummary = null, id = null, type = null, text = null, + reasoning_content = null, + reasoning_details = listOf(mapOf("type" to "thinking", "text" to "Let me think")), + ) + assertEquals("Let me think", LegacySessionParts.extractReasoningText(entry)) + } + + // ----------------------------------------------------------------------- + // ERROR text marked as ignored + // ----------------------------------------------------------------------- + + @Test + fun `isLegacySystemErrorText detects ERROR prefix`() { + assertTrue(LegacySessionParts.isLegacySystemErrorText("[ERROR] something went wrong")) + assertFalse(LegacySessionParts.isLegacySystemErrorText("Normal text")) + } + + @Test + fun `toText marks ERROR parts as ignored`() { + val part = LegacySessionParts.toText("p1", "m1", "s1", 0L, "[ERROR] failed") + val data = part["data"]!! + assertEquals("true", data.jsonObject["ignored"]?.jsonPrimitive?.content) + } + + // ----------------------------------------------------------------------- + // Feedback extraction + // ----------------------------------------------------------------------- + + @Test + fun `getFeedbackText extracts feedback block`() { + val content = "Some text\nThis is user feedback" + assertEquals("This is user feedback", LegacySessionParts.getFeedbackText(content)) + } + + @Test + fun `getFeedbackText returns null when no feedback block`() { + assertNull(LegacySessionParts.getFeedbackText("No feedback here")) + } + + // ----------------------------------------------------------------------- + // Full session parsing + // ----------------------------------------------------------------------- + + @Test + fun `parseSession produces project and session payloads`() { + val item = LegacyHistoryItem( + id = "task-abc", + task = "Do something", + workspace = "/tmp/project", + ts = 1700000000000L, + mode = "code", + rootTaskId = null, parentTaskId = null, + ) + val conv = """[ + {"role":"user","content":"Hello","ts":1700000000000}, + {"role":"assistant","content":"World","ts":1700000001000} + ]""" + val parsed = LegacySessionParser.parseSession("task-abc", conv, item) + + assertEquals(LegacySessionIds.createSessionId("task-abc"), parsed.session["id"]?.jsonPrimitive?.content) + assertEquals("task-abc", parsed.session["slug"]?.jsonPrimitive?.content) + assertEquals("Do something", parsed.session["title"]?.jsonPrimitive?.content) + assertEquals("v2", parsed.session["version"]?.jsonPrimitive?.content) + assertEquals(2, parsed.messages.size) + assertEquals("user", parsed.messages[0]["data"]?.jsonObject?.get("role")?.jsonPrimitive?.content) + assertEquals("assistant", parsed.messages[1]["data"]?.jsonObject?.get("role")?.jsonPrimitive?.content) + } + + @Test + fun `parseSession only migrates user and assistant messages`() { + val conv = """[ + {"role":"user","content":"Hi"}, + {"role":"system","content":"You are an assistant"}, + {"role":"assistant","content":"Hello"} + ]""" + val parsed = LegacySessionParser.parseSession("task-x", conv) + assertEquals(2, parsed.messages.size) + } + + // ----------------------------------------------------------------------- + // Tool use / result merge + // ----------------------------------------------------------------------- + + @Test + fun `thereIsNoToolResult returns true when no matching result`() { + val conv = listOf( + ai.kilocode.backend.migration.session.LegacyApiMessage("user", "text", null, null, null, null, null, null, null), + ) + assertTrue(LegacySessionParts.thereIsNoToolResult(conv, "call-id-1")) + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private fun sha1(value: String): String = LegacySessionIds.hash(value) + + private fun assertNull(actual: String?) { + kotlin.test.assertNull(actual) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt new file mode 100644 index 00000000000..81e1fe78b66 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/migration/LegacySettingsFileFixture.kt @@ -0,0 +1,48 @@ +package ai.kilocode.backend.migration + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.nio.file.Files + +internal class LegacySettingsFileFixture { + private val file = Files.createTempDirectory("kilo-legacy-migration").resolve("legacy-settings.json").toFile() + var migrationStatus: LegacyMigrationStatus? = null + var providerProfiles: String? = null + val oauthSecrets: MutableMap = mutableMapOf() + var mcpSettings: String? = null + var customModes: String? = null + var customModePrompts: String? = null + var autocomplete: String? = null + val globalState: MutableMap = mutableMapOf() + var taskHistory: String? = null + val conversations: MutableMap = mutableMapOf() + + fun store(): LegacyMigrationStore { + flush() + return LegacySettingsFileMigrationStore(file) + } + + fun refresh() { + migrationStatus = LegacySettingsFileMigrationStore(file).status() + } + + fun exists() = file.exists() + + private fun flush() { + val root = mutableMapOf() + migrationStatus?.let { root["migrationStatus"] = JsonPrimitive(it.name) } + providerProfiles?.let { root["providerProfiles"] = JsonPrimitive(it) } + if (oauthSecrets.isNotEmpty()) root["oauth"] = JsonObject(oauthSecrets.mapValues { JsonPrimitive(it.value) }) + mcpSettings?.let { root["mcpSettings"] = JsonPrimitive(it) } + customModes?.let { root["customModes"] = JsonPrimitive(it) } + customModePrompts?.let { root["customModePrompts"] = JsonPrimitive(it) } + autocomplete?.let { root["autocomplete"] = JsonPrimitive(it) } + if (globalState.isNotEmpty()) root["globalState"] = JsonObject(globalState) + taskHistory?.let { root["taskHistory"] = JsonPrimitive(it) } + if (conversations.isNotEmpty()) root["conversations"] = JsonObject(conversations.mapValues { JsonPrimitive(it.value) }) + file.parentFile.mkdirs() + file.writeText(Json.encodeToString(JsonObject.serializer(), JsonObject(root))) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt new file mode 100644 index 00000000000..a091bc255ef --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/provider/KiloBackendProviderSettingsManagerTest.kt @@ -0,0 +1,284 @@ +package ai.kilocode.backend.provider + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import kotlinx.coroutines.async +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.runBlocking +import kotlinx.coroutines.withTimeout +import java.util.concurrent.CountDownLatch +import kotlin.system.measureTimeMillis +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KiloBackendProviderSettingsManagerTest { + + private val mock = MockCliServer() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + @Test + fun `disconnecting available catalog provider returns error without mutation`() = runBlocking { + mock.providers = """{ + "all":[{"id":"cloudflare-ai-gateway","name":"Cloudflare AI Gateway","source":"custom","models":{}}], + "default":{}, + "connected":[], + "failed":[] + }""".trimIndent() + mock.providerAuth = """{"cloudflare-ai-gateway":[{"type":"api","label":"API key"}]}""" + val manager = manager() + + mock.resetCounts() + val result = manager.disconnect(ProviderDisconnectDto("/test", "cloudflare-ai-gateway")) + + assertEquals("Provider is not connected.", result.error) + assertNull(mock.lastConfigPatchBody) + assertNull(mock.lastAuthDeletePath) + assertEquals(0, mock.requestCount("/global/dispose")) + } + + @Test + fun `disconnecting openai compatible custom provider deletes config and auth`() = runBlocking { + mock.config = """{ + "model":"test/model", + "provider":{ + "local-openai":{"name":"Local OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://localhost:11434"}} + } + }""".trimIndent() + mock.providers = """{ + "all":[{"id":"local-openai","name":"Local OpenAI","source":"config","models":{}}], + "default":{}, + "connected":["local-openai"], + "failed":[] + }""".trimIndent() + val manager = manager() + + mock.resetCounts() + val result = manager.disconnect(ProviderDisconnectDto("/test", "local-openai")) + + assertNull(result.error) + assertContains(mock.lastConfigPatchBody.orEmpty(), "\"local-openai\":null") + assertNull(mock.lastWorkspaceConfigPatchBody) + assertEquals("/auth/local-openai", mock.lastAuthDeletePath) + assertEquals(1, mock.requestCount("/global/dispose")) + } + + @Test + fun `state marks provider config scopes`() = runBlocking { + mock.config = """{ + "provider":{ + "global-openai":{"name":"Global OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://global.test"}}, + "overridden-openai":{"name":"Global Override","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://global.test"}} + }, + "disabled_providers":["global-disabled"], + "enabled_providers":["global-enabled"] + }""".trimIndent() + mock.workspaceConfig = """{ + "provider":{ + "global-openai":{"name":"Global OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://global.test"}}, + "overridden-openai":{"name":"Workspace Override","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://workspace.test"}}, + "workspace-openai":{"name":"Workspace OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"https://workspace.test"}} + }, + "disabled_providers":["global-disabled","workspace-disabled"], + "enabled_providers":["global-enabled","workspace-enabled"] + }""".trimIndent() + val manager = manager() + + val state = manager.state("/test") + + assertEquals("global", state.config["global-openai"]?.scope) + assertEquals("workspace", state.config["overridden-openai"]?.scope) + assertEquals("workspace", state.config["workspace-openai"]?.scope) + assertEquals(listOf("global"), state.disabledScopes["global-disabled"]) + assertEquals(listOf("workspace"), state.disabledScopes["workspace-disabled"]) + assertEquals(listOf("global"), state.enabledScopes["global-enabled"]) + assertEquals(listOf("workspace"), state.enabledScopes["workspace-enabled"]) + } + + @Test + fun `disconnecting workspace openai compatible custom provider patches workspace config`() = runBlocking { + mock.workspaceConfig = """{ + "provider":{ + "local-openai":{"name":"Local OpenAI","npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://localhost:11434"}} + } + }""".trimIndent() + mock.providers = """{ + "all":[{"id":"local-openai","name":"Local OpenAI","source":"config","models":{}}], + "default":{}, + "connected":["local-openai"], + "failed":[] + }""".trimIndent() + val manager = manager() + + mock.resetCounts() + val result = manager.disconnect(ProviderDisconnectDto("/test project", "local-openai")) + + assertNull(result.error) + assertNull(mock.lastConfigPatchBody) + assertEquals("/config?directory=%2Ftest+project", mock.lastWorkspaceConfigPatchPath) + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"local-openai\":null") + assertEquals("/auth/local-openai", mock.lastAuthDeletePath) + assertEquals(1, mock.requestCount("/global/dispose")) + } + + @Test + fun `disconnecting workspace configured provider patches workspace disabled providers`() = runBlocking { + mock.config = """{"disabled_providers":["global-disabled"]}""" + mock.workspaceConfig = """{ + "provider":{"anthropic":{"name":"Anthropic","npm":"@ai-sdk/anthropic"}}, + "disabled_providers":["global-disabled","workspace-disabled"] + }""".trimIndent() + mock.providers = """{ + "all":[{"id":"anthropic","name":"Anthropic","source":"config","models":{}}], + "default":{}, + "connected":[], + "failed":[] + }""".trimIndent() + val manager = manager() + + mock.resetCounts() + val result = manager.disconnect(ProviderDisconnectDto("/test", "anthropic")) + + assertNull(result.error) + assertNull(mock.lastConfigPatchBody) + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"anthropic\"") + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-disabled\"") + assertFalse(mock.lastWorkspaceConfigPatchBody.orEmpty().contains("global-disabled")) + assertEquals(1, mock.requestCount("/global/dispose")) + } + + @Test + fun `enabling workspace disabled provider patches workspace disabled providers`() = runBlocking { + mock.config = """{"disabled_providers":["global-disabled"]}""" + mock.workspaceConfig = """{"disabled_providers":["global-disabled","workspace-disabled","anthropic"]}""" + mock.providers = """{ + "all":[{"id":"anthropic","name":"Anthropic","source":"config","models":{}}], + "default":{}, + "connected":[], + "failed":[] + }""".trimIndent() + val manager = manager() + + mock.resetCounts() + val result = manager.enable(ProviderEnableDto("/test", "anthropic")) + + assertNull(result.error) + assertNull(mock.lastConfigPatchBody) + assertContains(mock.lastWorkspaceConfigPatchBody.orEmpty(), "\"workspace-disabled\"") + assertFalse(mock.lastWorkspaceConfigPatchBody.orEmpty().contains("anthropic")) + assertFalse(mock.lastWorkspaceConfigPatchBody.orEmpty().contains("global-disabled")) + assertEquals(1, mock.requestCount("/global/dispose")) + } + + @Test + fun `disconnecting kilo gateway returns error without logout`() = runBlocking { + mock.providers = """{ + "all":[{"id":"kilo","name":"Kilo Gateway","source":"custom","models":{}}], + "default":{}, + "connected":["kilo"], + "failed":[] + }""".trimIndent() + val manager = manager() + + mock.resetCounts() + val result = manager.disconnect(ProviderDisconnectDto("/test", "kilo")) + + assertEquals("Kilo Gateway cannot be disconnected from provider settings.", result.error) + assertFalse(result.profileCleared) + assertNull(mock.lastAuthDeletePath) + assertEquals(0, mock.requestCount("/auth/kilo")) + assertEquals(0, mock.requestCount("/global/dispose")) + } + + @Test + fun `state waits through dispose triggered reload`() = runBlocking { + mock.providers = """{ + "all":[{"id":"openai","name":"OpenAI","source":"custom","models":{}}], + "default":{}, + "connected":["openai"], + "failed":[] + }""".trimIndent() + val app = app() + val manager = KiloBackendProviderSettingsManager(app) + assertTrue(mock.awaitSseConnection()) + val gate = CountDownLatch(1) + mock.responseGate = gate + + try { + mock.pushEvent("global.disposed", "{}") + withTimeout(5_000) { + app.appState.first { it is KiloAppState.Loading } + } + + val state = async { manager.state("/test") } + delay(200) + assertFalse(state.isCompleted) + + gate.countDown() + val result = withTimeout(10_000) { state.await() } + assertEquals(listOf("openai"), result.connected) + assertEquals(1, result.providers.size) + } finally { + mock.responseGate = null + gate.countDown() + } + } + + @Test + fun `awaitReady returns immediately when ready`() = runBlocking { + val app = app() + + val elapsed = measureTimeMillis { + app.awaitReady() + } + + assertTrue(elapsed < 500, "awaitReady should not wait when already ready, elapsed=${elapsed}ms") + } + + @Test + fun `awaitReady fails fast when disconnected`() = runBlocking { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog()) + + val elapsed = measureTimeMillis { + assertFailsWith { + app.awaitReady() + } + } + + assertTrue(elapsed < 500, "awaitReady should fail fast when disconnected, elapsed=${elapsed}ms") + } + + private suspend fun manager(): KiloBackendProviderSettingsManager { + return KiloBackendProviderSettingsManager(app()) + } + + private suspend fun app(): KiloBackendAppService { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog()) + app.connect() + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + return app + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapperTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapperTest.kt new file mode 100644 index 00000000000..dfccc531876 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapperTest.kt @@ -0,0 +1,44 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.workspace.ModelInfo +import ai.kilocode.backend.workspace.ProviderData +import ai.kilocode.backend.workspace.ProviderInfo +import kotlin.test.Test +import kotlin.test.assertTrue + +class KiloWorkspaceDtoMapperTest { + + @Test + fun `providers preserve prompt training disclosure`() { + val model = ModelInfo( + id = "paid", + name = "Paid", + attachment = false, + reasoning = false, + temperature = false, + toolCall = true, + free = false, + status = null, + recommendedIndex = null, + variants = emptyList(), + limit = null, + mayTrainOnYourPrompts = true, + ) + val data = ProviderData( + providers = listOf( + ProviderInfo( + id = "kilo", + name = "Kilo", + source = "api", + models = mapOf(model.id to model), + ), + ), + connected = listOf("kilo"), + defaults = emptyMap(), + ) + + val result = KiloWorkspaceDtoMapper.providers(data) + + assertTrue(result.providers.single().models.getValue("paid").mayTrainOnYourPrompts) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt new file mode 100644 index 00000000000..673220ad08d --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt @@ -0,0 +1,150 @@ +package ai.kilocode.backend.rpc + +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WorkspacePathScopingTest { + // Derive an absolute, OS-portable base from the real home dir so the test runs identically on + // Windows, macOS, and Linux (no hardcoded POSIX "/home/..." literals). + private val base: Path = Path.of(System.getProperty("user.home")).resolve("kilo-scope-test").normalize() + + private fun at(vararg segments: String): Path = segments.fold(base) { acc, s -> acc.resolve(s) } + + @Test + fun `in-base file returns forward-slash relative path`() { + assertEquals("src/A.kt", relativeWithinBase(base, at("src", "A.kt"))) + } + + @Test + fun `nested file returns nested relative path`() { + assertEquals("a/b/c.kt", relativeWithinBase(base, at("a", "b", "c.kt"))) + } + + @Test + fun `base itself is rejected as blank`() { + assertNull(relativeWithinBase(base, base)) + } + + @Test + fun `sibling directory outside base is rejected`() { + assertNull(relativeWithinBase(base, base.resolveSibling("other").resolve("A.kt"))) + } + + @Test + fun `parent directory is rejected`() { + assertNull(relativeWithinBase(base, base.parent)) + } + + @Test + fun `traversal that escapes base is rejected after normalization`() { + assertNull(relativeWithinBase(base, base.resolve("..").resolve("secret").resolve("A.kt"))) + } + + @Test + fun `traversal that stays inside base is kept after normalization`() { + assertEquals("src/A.kt", relativeWithinBase(base, base.resolve("x").resolve("..").resolve("src").resolve("A.kt"))) + } + + @Test + fun `prefix sibling is not treated as inside base`() { + val sibling = base.resolveSibling(base.fileName.toString() + "-2") + assertNull(relativeWithinBase(base, sibling.resolve("A.kt"))) + } + + @Test + fun `normalizes encoded file URLs`() { + val path = base.resolve("dir with spaces").resolve("A.kt") + val url = path.toUri().toString() + "?query#fragment" + + assertEquals(path.normalize().toString(), normalizeWorkspacePath(url)) + } + + @Test + fun `normalizes escaped relative paths`() { + assertEquals("src/A.kt", normalizeWorkspacePath("src%2Ftmp%2F..%2FA.kt")) + } + + @Test + fun `rejects blank and invalid paths`() { + assertNull(normalizeWorkspacePath(" ")) + assertNull(normalizeWorkspacePath("file://%")) + } + + @Test + fun `git availability detects temp repository`() { + val dir = repo() ?: return + try { + assertTrue(workspaceGitAvailable(dir)) + } finally { + delete(dir) + } + } + + @Test + fun `git changes returns capped large diff without blocking`() = runBlocking { + val dir = repo() ?: return@runBlocking + try { + val file = dir.resolve("large.txt") + file.writeText("base\n") + git(dir, "add", "large.txt") + git(dir, "commit", "-m", "base") + file.writeText((1..40_000).joinToString("\n") { "line-$it" } + "\n") + + val diff = assertNotNull(KiloWorkspaceRpcApiImpl().gitChanges(dir.toString())) + assertTrue(diff.startsWith("diff --git")) + assertEquals(200_000, diff.length) + } finally { + delete(dir) + } + } + + @Test + fun `git changes returns null outside git repositories`() = runBlocking { + val dir = Files.createTempDirectory("kilo-non-git") + try { + assertNull(KiloWorkspaceRpcApiImpl().gitChanges(dir.toString())) + } finally { + delete(dir) + } + } + + private fun repo(): Path? { + if (!gitInstalled()) return null + val dir = Files.createTempDirectory("kilo-git") + git(dir, "init") + git(dir, "config", "user.email", "test@example.com") + git(dir, "config", "user.name", "Test User") + return dir + } + + private fun gitInstalled(): Boolean { + return try { + ProcessBuilder("git", "--version").start().waitFor() == 0 + } catch (_: Exception) { + false + } + } + + private fun git(dir: Path, vararg args: String) { + val proc = ProcessBuilder(listOf("git") + args) + .directory(dir.toFile()) + .redirectErrorStream(true) + .start() + val out = proc.inputStream.bufferedReader().readText() + val code = proc.waitFor() + assertEquals(0, code, out) + } + + private fun delete(dir: Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetryTest.kt new file mode 100644 index 00000000000..1a9a5be5a07 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetryTest.kt @@ -0,0 +1,76 @@ +package ai.kilocode.backend.telemetry + +import ai.kilocode.backend.cli.KiloBackendHttpClients +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import java.util.concurrent.TimeUnit + +class KiloBackendTelemetryTest { + @Test + fun `capture posts to telemetry endpoint with auth`() = runBlocking { + val server = MockWebServer() + server.enqueue(MockResponse().setBody("{}")) + server.start() + val http = KiloBackendHttpClients.api("secret") + try { + KiloBackendTelemetry().capture(http, server.port, "Test Event", mapOf("source" to "test")) + + val req = server.takeRequest() + assertEquals("/telemetry/capture", req.path) + assertTrue(req.getHeader("Authorization")?.startsWith("Basic ") == true) + val body = req.body.readUtf8() + assertTrue(body.contains("\"event\":\"Test Event\"")) + assertTrue(!body.contains("JetBrains")) + assertTrue(body.contains("\"platform\":\"jetbrains\"")) + assertTrue(!body.contains("appName")) + assertTrue(body.contains("source")) + } finally { + KiloBackendHttpClients.shutdown(http) + server.shutdown() + } + } + + @Test + fun `set enabled posts to telemetry endpoint`() = runBlocking { + val server = MockWebServer() + server.enqueue(MockResponse().setBody("{}")) + server.start() + val http = KiloBackendHttpClients.api("secret") + try { + KiloBackendTelemetry().setEnabled(http, server.port, true) + + val req = server.takeRequest() + assertEquals("/telemetry/setEnabled", req.path) + assertTrue(req.body.readUtf8().contains("enabled")) + } finally { + KiloBackendHttpClients.shutdown(http) + server.shutdown() + } + } + + @Test + fun `capture failure does not throw`() = runBlocking { + KiloBackendTelemetry().capture(null, 0, "Test Event", emptyMap()) + } + + @Test + fun `dev mode does not post capture`() = runBlocking { + System.setProperty("idea.plugin.in.sandbox.mode", "true") + val server = MockWebServer() + server.start() + val http = KiloBackendHttpClients.api("secret") + try { + KiloBackendTelemetry().capture(http, server.port, "Test Event", emptyMap()) + + assertEquals(null, server.takeRequest(100, TimeUnit.MILLISECONDS)) + } finally { + System.clearProperty("idea.plugin.in.sandbox.mode") + KiloBackendHttpClients.shutdown(http) + server.shutdown() + } + } +} 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 885b327be8d..aa29477ff96 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 @@ -13,6 +13,10 @@ import ai.kilocode.backend.cli.CliServer class FakeCliServer(private val mock: MockCliServer) : CliServer { override var forceExtract = false + var stopCount = 0 + private set + var disposeCount = 0 + private set override fun process(): Process? = null @@ -23,11 +27,13 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer { /** Shutdown the server socket but keep the mock alive for restart. */ override fun stop() { + stopCount++ mock.shutdown() } /** Final cleanup. */ override fun dispose() { + disposeCount++ mock.close() } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index bdde10d818f..f9380d9c897 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -31,12 +31,14 @@ class MockCliServer : AutoCloseable { // Configurable REST responses — can be changed between requests @Volatile var health = """{"healthy":true,"version":"1.0.0"}""" @Volatile var config = """{"model":"test/model"}""" + @Volatile var workspaceConfig = """{}""" @Volatile var warnings = "[]" @Volatile var notifications = "[]" @Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}""" @Volatile var path = """{"home":"/tmp","state":"${createTempDirectory("kilo-model-state").toAbsolutePath()}","config":"/tmp","worktree":"/tmp","directory":"/tmp"}""" @Volatile var profileStatus = 200 @Volatile var configStatus = 200 + @Volatile var workspaceConfigStatus = 200 @Volatile var warningsStatus = 200 @Volatile var notificationsStatus = 200 @@ -45,17 +47,26 @@ class MockCliServer : AutoCloseable { @Volatile var authorizeStatus = 200 @Volatile var callbackStatus = 200 @Volatile var authRemoveStatus = 200 + @Volatile var authPutStatus = 200 + @Volatile var disposeStatus = 200 @Volatile var organizationSetStatus = 200 @Volatile var lastAuthorizeBody: String? = null @Volatile var lastCallbackBody: String? = null + @Volatile var lastAuthPutBody: String? = null + @Volatile var lastAuthDeletePath: String? = null + @Volatile var lastConfigPatchBody: String? = null + @Volatile var lastWorkspaceConfigPatchPath: String? = null + @Volatile var lastWorkspaceConfigPatchBody: String? = null @Volatile var lastOrganizationSetBody: String? = null // Project-scoped REST responses @Volatile var providers = """{"all":[],"default":{},"connected":[],"failed":[]}""" + @Volatile var providerAuth = "{}" @Volatile var agents = "[]" @Volatile var commands = "[]" @Volatile var skills = "[]" @Volatile var providersStatus = 200 + @Volatile var providerAuthStatus = 200 @Volatile var agentsStatus = 200 @Volatile var commandsStatus = 200 @Volatile var skillsStatus = 200 @@ -82,6 +93,14 @@ class MockCliServer : AutoCloseable { @Volatile var summarizeStatus = 200 @Volatile var lastSummarizePath: String? = null @Volatile var lastSummarizeBody: String? = null + @Volatile var promptStatus = 200 + @Volatile var promptResponse = "true" + @Volatile var lastPromptPath: String? = null + @Volatile var lastPromptBody: String? = null + @Volatile var enhanced = """{"text":"Enhanced prompt"}""" + @Volatile var enhanceStatus = 200 + @Volatile var lastEnhancePath: String? = null + @Volatile var lastEnhanceBody: String? = null @Volatile var sessionRenameStatus = 200 @Volatile var sessionRenameResponse = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"Renamed","version":"1.0.0","time":{"created":1000,"updated":2000}}""" @Volatile var lastSessionRenamePath: String? = null @@ -94,12 +113,45 @@ class MockCliServer : AutoCloseable { /** Optional gate for REST responses; SSE stays unblocked so the app can enter Loading. */ @Volatile var responseGate: CountDownLatch? = null + /** Optional gate for config warnings only. */ + @Volatile var warningsGate: CountDownLatch? = null + /** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */ private val counts = ConcurrentHashMap() + private val requests = Object() + private val streams = Object() + private val sse = AtomicInteger(0) + + val sseConnectionCount: Int + get() = sse.get() /** Return the number of requests received for [path] (bare, no query). */ fun requestCount(path: String): Int = counts[path]?.get() ?: 0 + fun awaitRequestCount(path: String, target: Int, timeout: Long = 5_000): Boolean { + val end = System.currentTimeMillis() + timeout + synchronized(requests) { + while (requestCount(path) < target) { + val wait = end - System.currentTimeMillis() + if (wait <= 0) return false + requests.wait(wait) + } + return true + } + } + + fun awaitSseConnections(target: Int, timeout: Long = 5_000): Boolean { + val end = System.currentTimeMillis() + timeout + synchronized(streams) { + while (sse.get() < target) { + val wait = end - System.currentTimeMillis() + if (wait <= 0) return false + streams.wait(wait) + } + return true + } + } + @Volatile var lastExperimentalSessionPath: String? = null /** Reset all request counters. */ @@ -124,7 +176,8 @@ class MockCliServer : AutoCloseable { // Clean up any previous instance shutdownServer() - sseLatch = CountDownLatch(1) + val latch = CountDownLatch(1) + sseLatch = latch sseConnected = CountDownLatch(1) sseWriter = null @@ -215,19 +268,33 @@ class MockCliServer : AutoCloseable { val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream())) val bare = path.substringBefore("?") + val latch = sseLatch // Track request counts counts.computeIfAbsent(bare) { AtomicInteger(0) }.incrementAndGet() + synchronized(requests) { requests.notifyAll() } // Optional delay for race condition testing val delay = responseDelay if (delay > 0) Thread.sleep(delay) if (bare != "/global/event") responseGate?.await() + if (bare.startsWith("/config/warnings")) warningsGate?.await() when { path == "/global/health" -> respond(output, 200, health) - path == "/global/config" -> respond(output, configStatus, config) + bare == "/global/config" && method == "GET" -> respond(output, configStatus, config) + bare == "/global/config" && method == "PATCH" -> { + lastConfigPatchBody = body + respond(output, configStatus, config) + } + bare == "/global/dispose" && method == "POST" -> respond(output, disposeStatus, "true") path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) + bare == "/config" && method == "PATCH" -> { + lastWorkspaceConfigPatchPath = path + lastWorkspaceConfigPatchBody = body + respond(output, workspaceConfigStatus, workspaceConfig) + } + bare == "/config" -> respond(output, workspaceConfigStatus, workspaceConfig) path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications) path.startsWith("/kilo/profile") && method == "GET" -> { if (profileStatus == 401) { @@ -245,15 +312,21 @@ class MockCliServer : AutoCloseable { respond(output, callbackStatus, "true") } bare.matches(Regex("/auth/[^/]+")) && method == "DELETE" -> { + lastAuthDeletePath = bare respond(output, authRemoveStatus, "true") } + bare.matches(Regex("/auth/[^/]+")) && method == "PUT" -> { + lastAuthPutBody = body + respond(output, authPutStatus, "true") + } bare == "/kilo/organization" && method == "POST" -> { lastOrganizationSetBody = body respond(output, organizationSetStatus, "true") } - path == "/global/event" -> handleSse(output) + path == "/global/event" -> handleSse(output, latch) path == "/path" -> respond(output, 200, this.path) bare == "/provider" -> respond(output, providersStatus, providers) + bare == "/provider/auth" -> respond(output, providerAuthStatus, providerAuth) bare == "/agent" -> respond(output, agentsStatus, agents) bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) @@ -273,11 +346,11 @@ class MockCliServer : AutoCloseable { bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) - bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" -> + bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" -> respond(output, sessionGetStatus, sessionCreate) - bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" -> + bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" -> respond(output, sessionDeleteStatus, "true") - bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> { + bare.matches(Regex("/session/ses_[^/]+")) && method == "PATCH" -> { lastSessionRenamePath = path lastSessionRenameBody = body lastSessionRenameMethod = method @@ -288,6 +361,16 @@ class MockCliServer : AutoCloseable { lastSummarizeBody = body respond(output, summarizeStatus, summarizeResponse) } + bare.matches(Regex("/session/ses_[^/]+/prompt_async")) && method == "POST" -> { + lastPromptPath = path + lastPromptBody = body + respond(output, promptStatus, promptResponse) + } + bare == "/enhance-prompt" && method == "POST" -> { + lastEnhancePath = path + lastEnhanceBody = body + respond(output, enhanceStatus, enhanced) + } else -> respond(output, 404, """{"error":"Not found"}""") } } catch (_: SocketException) { @@ -315,7 +398,7 @@ class MockCliServer : AutoCloseable { writer.flush() } - private fun handleSse(writer: BufferedWriter) { + private fun handleSse(writer: BufferedWriter, latch: CountDownLatch) { writer.write("HTTP/1.1 200 OK\r\n") writer.write("Content-Type: text/event-stream\r\n") writer.write("Cache-Control: no-cache\r\n") @@ -323,8 +406,10 @@ class MockCliServer : AutoCloseable { writer.write("\r\n") writer.flush() sseWriter = writer + sse.incrementAndGet() + synchronized(streams) { streams.notifyAll() } sseConnected.countDown() // Block until SSE is closed or server shuts down - sseLatch.await() + latch.await() } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt index 1219a20fbd8..b9efae14924 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt @@ -7,31 +7,51 @@ import ai.kilocode.log.KiloLog */ class TestLog : KiloLog { private val items = mutableListOf() + private val lock = Object() val messages: List - get() = synchronized(items) { items.toList() } + get() = synchronized(lock) { items.toList() } override var isDebugEnabled: Boolean = true + fun awaitMessage(timeout: Long = 5_000, predicate: (String) -> Boolean): Boolean { + val end = System.currentTimeMillis() + timeout + synchronized(lock) { + while (items.none(predicate)) { + val wait = end - System.currentTimeMillis() + if (wait <= 0) return false + lock.wait(wait) + } + return true + } + } + override fun debug(block: () -> String) { if (!isDebugEnabled) return val msg = block() - synchronized(items) { items.add("DEBUG: $msg") } + add("DEBUG: $msg") println("[test] DEBUG: $msg") } override fun info(msg: String) { - synchronized(items) { items.add("INFO: $msg") } + add("INFO: $msg") println("[test] INFO: $msg") } override fun warn(msg: String, t: Throwable?) { - synchronized(items) { items.add("WARN: $msg") } + add("WARN: $msg") println("[test] WARN: $msg") t?.printStackTrace() } override fun error(msg: String, t: Throwable?) { - synchronized(items) { items.add("ERROR: $msg") } + add("ERROR: $msg") System.err.println("[test] ERROR: $msg") t?.printStackTrace() } + + private fun add(msg: String) { + synchronized(lock) { + items.add(msg) + lock.notifyAll() + } + } } 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 6decdb82e85..c03221e7ac8 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 @@ -296,11 +296,14 @@ class KiloBackendWorkspaceTest { @Test fun `agents response filters hidden and subagent`() = runBlocking { + mock.providers = PROVIDERS_JSON mock.agents = """[ {"name":"code","mode":"primary","permission":[],"options":{}}, {"name":"helper","mode":"subagent","permission":[],"options":{}}, {"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}} ]""" + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON val app = setup() val ws = ready(app) diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 099d0212123..8b8a1bf0efd 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -25,6 +25,8 @@ import java.io.File * Replaced with `kotlinx.serialization.json.JsonElement`. * 10. JsonElement query parameters — anyOf query params generate empty * `if (param != null) {}` blocks. Emit the primitive JSON text instead. + * 11. Missing model imports — references to models the generator did not emit + * are replaced with JsonElement. */ abstract class FixGeneratedApiTask : DefaultTask() { @get:OutputDirectory @@ -35,6 +37,7 @@ abstract class FixGeneratedApiTask : DefaultTask() { val root = generated.get().asFile fixEmptyWrappers(root) fixAnyOfUnionWrappers(root) + fixMissingModels(root) fixJsonElementQueryParams(root) root.walkTopDown().filter { it.extension == "kt" }.forEach { fix(it) } } @@ -142,6 +145,26 @@ abstract class FixGeneratedApiTask : DefaultTask() { if (fixed != text) api.writeText(fixed) } + private fun fixMissingModels(root: File) { + val models = File(root, "ai/kilocode/jetbrains/api/model") + if (!models.isDirectory) return + + val imports = Regex("""import ai\.kilocode\.jetbrains\.api\.model\.(\w+)\n""") + root.walkTopDown().filter { it.extension == "kt" }.forEach { file -> + val missing = mutableSetOf() + val text = imports.replace(file.readText()) { m -> + val name = m.groupValues[1] + if (File(models, "$name.kt").isFile) return@replace m.value + missing.add(name) + "" + } + val fixed = missing.fold(text) { acc, name -> + acc.replace(Regex("""\b$name\b"""), "kotlinx.serialization.json.JsonElement") + } + if (fixed != text || missing.isNotEmpty()) file.writeText(fixed) + } + } + private fun fix(file: File) { var text = file.readText() var changed = false diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt index dddc659f8fa..dc1501154f7 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt @@ -26,8 +26,8 @@ import javax.inject.Inject * 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. + * 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() { diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index af2d807656c..890853a9c22 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -1,7 +1,10 @@ +import org.jetbrains.changelog.Changelog import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.tasks.InstrumentCodeTask import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget +import java.time.LocalDate group = "ai.kilocode.jetbrains" @@ -31,6 +34,51 @@ fun checked(value: String): String { return value } +data class Release(val major: Int, val minor: Int, val patch: Int, val rc: Int?) : Comparable { + val stable = rc == null + val base get() = if (stable) this else Release(major, minor, patch, null) + val text = listOfNotNull("$major.$minor.$patch", rc?.let { "rc.$it" }).joinToString("-") + + override fun compareTo(other: Release): Int { + val cmp = compareValuesBy(this, other, Release::major, Release::minor, Release::patch) + if (cmp != 0) return cmp + return compareValues(rc ?: Int.MAX_VALUE, other.rc ?: Int.MAX_VALUE) + } +} + +fun release(value: String): Release? { + val match = Regex("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-rc\\.(\\d+))?$").matchEntire(value) ?: return null + return Release( + match.groupValues[1].toInt(), + match.groupValues[2].toInt(), + match.groupValues[3].toInt(), + match.groupValues[4].takeIf { it.isNotEmpty() }?.toInt(), + ) +} + +fun releases(): List { + val heading = Regex("^## \\[(.+?)](?: - .*)?$|^## ([^\\[]\\S*)$") + return file("CHANGELOG.md").readLines() + .mapNotNull { line -> + val match = heading.matchEntire(line.trim()) ?: return@mapNotNull null + release(match.groupValues[1].ifEmpty { match.groupValues[2] }) + } + .distinctBy { it.text } +} + +fun selected(value: String): List { + val current = release(value) ?: return emptyList() + val entries = releases() + val rcs = if (current.stable) emptyList() else entries + .filter { !it.stable && it.base == current.base && it <= current } + .sortedDescending() + val stables = entries + .filter { it.stable && if (current.stable) it <= current else it < current.base } + .sortedDescending() + .take(5) + return (rcs + stables).map { it.text } +} + fun gitTag(): String? { val text = providers.exec { commandLine("git", "tag", "--points-at", "HEAD") @@ -39,12 +87,13 @@ fun gitTag(): String? { } val release = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false).get() -val ver = if (release) checked( - gitTag()?.removePrefix("jetbrains/v") - ?: error("Missing JetBrains plugin version. Publish builds must run from a jetbrains/v tag."), -) else checked(gitTag()?.removePrefix("jetbrains/v") ?: "0.0.0-dev") +val override = providers.gradleProperty("kilo.version").orNull?.trim()?.takeIf { it.isNotEmpty() } +val prop = providers.gradleProperty("kilo.jetbrains.version").orNull?.trim()?.takeIf { it.isNotEmpty() } +val tag = gitTag()?.removePrefix("jetbrains/v") +val ver = override?.let(::checked) ?: prop?.let(::checked) ?: if (release) checked( + tag ?: error("Missing JetBrains plugin version. Publish builds must set kilo.jetbrains.version or run from a jetbrains/v tag."), +) else checked(tag ?: "0.0.0-dev") -val notes = providers.gradleProperty("kilo.changeNotes").orElse("Release candidate build.") val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default") val splitPort = providers.gradleProperty("kilo.splitModeServerPort").orNull?.let(::port) ?: fallback() val isolated = providers.gradleProperty("kilo.dev.storage.isolated").map { it.toBoolean() }.orElse(false) @@ -59,12 +108,38 @@ plugins { id("java") alias(libs.plugins.intellij.platform) alias(libs.plugins.detekt) + alias(libs.plugins.changelog) alias(libs.plugins.kotlin) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.compose.compiler) apply false } +changelog { + version = ver + path = file("CHANGELOG.md").canonicalPath + header = provider { "[${version.get()}] - ${LocalDate.now()}" } + unreleasedTerm = "[Unreleased]" + keepUnreleasedSection = true + repositoryUrl = "https://github.com/Kilo-Org/kilocode" + groups = listOf("Added", "Changed", "Fixed", "Removed", "Security") + combinePreReleases = false +} + +val notes = providers.gradleProperty("kilo.changeNotes").orElse( + provider { + val versions = selected(ver).filter { changelog.has(it) } + if (versions.isNotEmpty()) return@provider versions.joinToString("\n") { item -> + changelog.renderItem( + changelog.get(item).withHeader(true).withEmptySections(false), + Changelog.OutputType.HTML, + ) + } + val item = if (changelog.has(ver)) changelog.get(ver) else changelog.getUnreleased() + changelog.renderItem(item.withHeader(false).withEmptySections(false), Changelog.OutputType.HTML) + }, +) + subprojects { apply(plugin = "org.jetbrains.intellij.platform.module") apply(plugin = "io.gitlab.arturbosch.detekt") @@ -145,6 +220,10 @@ intellijPlatform { } tasks { + withType { + enabled = false + } + runIdeBackend { splitModeServerPort.set(splitPort) dependsOn(":backend:prepareLocalCli") diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index c0b996948a7..5db3dee6fcf 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.gradle.api.tasks.Copy plugins { alias(libs.plugins.rpc) @@ -30,6 +31,35 @@ dependencies { testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") } +val providerIcons = tasks.register("generateProviderIcons") { + val src = layout.projectDirectory.dir("../../ui/src/assets/icons/provider") + val out = layout.buildDirectory.dir("generated/provider-icons/icons/providers") + from(src) { + include("*.svg") + filter { line: String -> line.replace("currentColor", "#6E6E6E") } + } + into(out) +} + +val providerIconsDark = tasks.register("generateProviderIconsDark") { + val src = layout.projectDirectory.dir("../../ui/src/assets/icons/provider") + val out = layout.buildDirectory.dir("generated/provider-icons/icons/providers") + from(src) { + include("*.svg") + rename { name -> name.removeSuffix(".svg") + "_dark.svg" } + filter { line: String -> line.replace("currentColor", "#CED0D6") } + } + into(out) +} + +sourceSets.main { + resources.srcDir(layout.buildDirectory.dir("generated/provider-icons")) +} + +tasks.processResources { + dependsOn(providerIcons, providerIconsDark) +} + tasks.test { // BasePlatformTestCase uses JUnit 3 test naming (test prefix), // discovered by the vintage engine via JUnit Platform diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt new file mode 100644 index 00000000000..d2bc692d61a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt @@ -0,0 +1,24 @@ +package ai.kilocode.client + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager + +object KiloNotifications { + private const val GROUP = "Kilo Code" + + fun error(title: String, content: String? = null) { + val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } + error(project, title, content) + } + + fun error(project: Project?, title: String, content: String? = null) { + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP) + ?.createNotification(title, content ?: "", NotificationType.ERROR) + ?: Notification(GROUP, title, content ?: "", NotificationType.ERROR) + notification.notify(project) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index c126b91f2cd..b0b640d3ea8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -3,9 +3,10 @@ package ai.kilocode.client import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionSidePanelManager +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.log.KiloLog -import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project @@ -14,7 +15,6 @@ import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -27,15 +27,22 @@ import kotlinx.coroutines.withContext * completes. */ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { - - companion object { - private val LOG = KiloLog.create(KiloToolWindowFactory::class.java) + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + project.service().create(toolWindow) } +} - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { +private val LOG = KiloLog.create(KiloToolWindowFactory::class.java) + +@Service(Service.Level.PROJECT) +internal class KiloToolWindowSetupService( + private val project: Project, + private val cs: CoroutineScope, +) { + fun create(toolWindow: ToolWindow) { + val start = System.currentTimeMillis() try { val workspaces = service() - val cs = CoroutineScope(SupervisorJob()) val hint = project.basePath ?: "" cs.launch { @@ -44,8 +51,13 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { withContext(Dispatchers.Main) { setup(project, toolWindow, workspace) } + Telemetry.send("Tool Window Opened", mapOf( + "projectResolved" to dir.isNotBlank().toString(), + "durationMs" to (System.currentTimeMillis() - start).toString(), + )) } } catch (e: Exception) { + Telemetry.send("Tool Window Setup Failed", mapOf("stage" to "create", "errorClass" to e::class.java.name)) LOG.error("Failed to create Kilo tool window content", e) } } @@ -64,12 +76,14 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { toolWindow.contentManager.setSelectedContent(content) manager.newSession() - val toolbar = ActionManager.getInstance().getAction("Kilo.ToolWindowToolbar") - if (toolbar is ActionGroup) { - val actions = toolbar.getChildren(null).toList() - toolWindow.setTitleActions(actions) - } + val actions = listOfNotNull( + ActionManager.getInstance().getAction("Kilo.NewSession"), + ActionManager.getInstance().getAction("Kilo.History"), + ActionManager.getInstance().getAction("Kilo.Settings"), + ) + toolWindow.setTitleActions(actions) } catch (e: Exception) { + Telemetry.send("Tool Window Setup Failed", mapOf("stage" to "setup", "errorClass" to e::class.java.name)) LOG.error("Failed to set up Kilo tool window content", e) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt index 3db258f8cce..0cee2523d55 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt @@ -41,7 +41,11 @@ class DeleteSessionAction : AnAction() { else KiloBundle.message("history.delete.confirm.message.multiple", items.size) - if (!confirm(e.project, msg)) return + controller.requestDelete(items.size) + if (!confirm(e.project, msg)) { + controller.cancelDelete(items.size) + return + } items.forEach { controller.delete(it) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt index 92d662e7ae5..9a82e36a1ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.telemetry.Telemetry import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -13,6 +14,7 @@ class HistoryAction : AnAction( AllIcons.Vcs.History, ), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("History Opened", mapOf("surface" to "tool_window")) e.getData(SessionManager.KEY)?.showHistory() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt index b7f6ba5d12f..d3f12fb4089 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.actions +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnAction @@ -22,6 +23,7 @@ class KiloSettingsAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { val component = e.inputEvent?.component ?: return val group = ActionManager.getInstance().getAction(GROUP_ID) as? ActionGroup ?: return + Telemetry.send("Settings Opened", mapOf("surface" to "tool_window")) JBPopupFactory.getInstance() .createActionGroupPopup( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt index dbe8aefcd6c..385f45e45a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.telemetry.Telemetry import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -13,6 +14,7 @@ class NewSessionAction : AnAction( AllIcons.General.Add, ), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("New Session Clicked", mapOf("surface" to "tool_window")) e.getData(SessionManager.KEY)?.newSession() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt new file mode 100644 index 00000000000..3a9d9cbe718 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt @@ -0,0 +1,74 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.rpc.dto.ConfigTargetDto +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 + +abstract class ConfigAction( + private val open: String, + private val create: String, + text: String, + description: String, +) : AnAction(text, description, null), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + protected fun text(target: ConfigTargetDto?): String { + val key = if (target?.exists == false) create else open + return KiloBundle.message(key, target?.displayPath ?: "...") + } + + protected fun failed() { + KiloNotifications.error(KiloBundle.message("action.Kilo.OpenConfig.failed")) + } +} + +class OpenLocalConfigAction : ConfigAction( + open = "action.Kilo.OpenLocalConfig.text", + create = "action.Kilo.CreateLocalConfig.text", + text = KiloBundle.message("action.Kilo.OpenLocalConfig.text", "..."), + description = KiloBundle.message("action.Kilo.OpenLocalConfig.description"), +) { + override fun update(e: AnActionEvent) { + val dir = directory(e) + e.presentation.isEnabled = dir != null + e.presentation.text = text(dir?.let { service().localConfig[it] }) + } + + override fun actionPerformed(e: AnActionEvent) { + val dir = directory(e) ?: return + Telemetry.send("Config Opened", mapOf("surface" to "tool_window", "scope" to "local")) + service().openLocalConfig(dir) { ok -> + if (!ok) failed() + } + } + + private fun directory(e: AnActionEvent): String? { + return e.getData(SessionManager.WORKSPACE_KEY)?.directory ?: e.project?.basePath + } +} + +class OpenGlobalConfigAction : ConfigAction( + open = "action.Kilo.OpenGlobalConfig.text", + create = "action.Kilo.CreateGlobalConfig.text", + text = KiloBundle.message("action.Kilo.OpenGlobalConfig.text", "..."), + description = KiloBundle.message("action.Kilo.OpenGlobalConfig.description"), +) { + override fun update(e: AnActionEvent) { + e.presentation.text = text(service().globalConfig) + } + + override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("Config Opened", mapOf("surface" to "tool_window", "scope" to "global")) + service().openGlobalConfig { ok -> + if (!ok) failed() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt new file mode 100644 index 00000000000..1f298ee8c70 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.KiloSettingsSelection +import ai.kilocode.client.telemetry.Telemetry +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.ProjectManager +import java.util.function.Predicate + +class OpenSettingsAction : DumbAwareAction( + KiloBundle.message("action.Kilo.OpenSettings.text"), + KiloBundle.message("action.Kilo.OpenSettings.description"), + null, +) { + override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("Settings Opened", mapOf("surface" to "tool_window")) + val project = e.project ?: ProjectManager.getInstance().defaultProject + val target = KiloSettingsSelection.target(project) + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == target + }, + null, + ) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index 7eab2842c67..e6d982347ac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.app.KiloAppService -import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service @@ -9,11 +9,11 @@ import com.intellij.openapi.project.DumbAware class ReinstallKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("CLI Reinstall Clicked", mapOf("surface" to "settings")) service().reinstallAsync() } override fun update(e: AnActionEvent) { - val status = service().state.value.status - e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING + e.presentation.isEnabled = true } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt index aa40c15fb41..dd2a617b219 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt @@ -39,9 +39,22 @@ class RenameSessionAction : AnAction() { val item = selection.selectedLocal.singleOrNull() ?: return val current = title(item) - val newTitle = input(e.project, current)?.trim() ?: return + controller.requestRename() + val raw = input(e.project, current) + if (raw == null) { + controller.cancelRename("cancelled") + return + } + val newTitle = raw.trim() - if (newTitle.isBlank() || newTitle == current) return + if (newTitle.isBlank()) { + controller.cancelRename("blank") + return + } + if (newTitle == current) { + controller.cancelRename("unchanged") + return + } controller.rename(item, newTitle) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index 335cf611f0f..93d356c78d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.app.KiloAppService -import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service @@ -9,11 +9,11 @@ import com.intellij.openapi.project.DumbAware class RestartKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("CLI Restart Clicked", mapOf("surface" to "settings")) service().restartAsync() } override fun update(e: AnActionEvent) { - val status = service().state.value.status - e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING + e.presentation.isEnabled = true } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt index 83cb7cd9025..d7c82f3cb16 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt @@ -2,10 +2,10 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.telemetry.Telemetry import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.remoting.ActionRemoteBehaviorSpecification import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil @@ -22,9 +22,10 @@ class ShowProfileAction : DumbAwareAction( KiloBundle.message("action.Kilo.ShowProfile.text"), KiloBundle.message("action.Kilo.ShowProfile.description"), AllIcons.General.User, -), ActionRemoteBehaviorSpecification.Frontend { +) { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("Profile Settings Opened", mapOf("surface" to "tool_window")) ShowSettingsUtil.getInstance().showSettingsDialog( e.project, Predicate { cfg: Configurable -> 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 4918660bc25..d18532b7394 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto @@ -214,6 +215,25 @@ class KiloAppService internal constructor( } } + suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto? = try { + LOG.info("config update: sending RPC ${summary(patch)}") + val next = call { updateConfig(patch) } + _state.value = next + LOG.info("config update: RPC completed ${summary(patch)}") + next + } catch (e: Exception) { + LOG.warn("config update failed ${summary(patch)}", e) + null + } + + fun updateConfigAsync( + patch: ConfigPatchDto, + done: (KiloAppStateDto?) -> Unit, + ): Job = cs.launch { + val state = updateConfig(patch) + done(state) + } + private fun setModelState(state: ModelStateDto) { _models.value = state _favorites.value = state.favorite @@ -292,3 +312,8 @@ class KiloAppService internal constructor( _state.value = current.copy(profile = profile, progress = progress) } } + +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/app/KiloProviderService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloProviderService.kt new file mode 100644 index 00000000000..5d9a3678cb2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloProviderService.kt @@ -0,0 +1,85 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.app + +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.KiloProviderRpcApi +import ai.kilocode.rpc.dto.CustomModelFetchDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ProviderActionResultDto +import ai.kilocode.rpc.dto.ProviderConnectDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto +import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto +import ai.kilocode.rpc.dto.ProviderOAuthReadyDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeout + +@Service(Service.Level.APP) +class KiloProviderService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloProviderRpcApi?, +) { + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = KiloLog.create(KiloProviderService::class.java) + private const val RPC_TIMEOUT_MS = 20_000L + internal const val OAUTH_RPC_TIMEOUT_MS = 90_000L + } + + private suspend fun call(name: String, timeoutMs: Long = RPC_TIMEOUT_MS, block: suspend KiloProviderRpcApi.() -> T): T { + val start = System.currentTimeMillis() + LOG.info("provider settings rpc $name: start") + val api = rpc + return try { + val result = withTimeout(timeoutMs) { + if (api != null) block(api) else durable { block(KiloProviderRpcApi.getInstance()) } + } + LOG.info("provider settings rpc $name: completed durationMs=${System.currentTimeMillis() - start}") + result + } catch (e: Exception) { + LOG.warn("provider settings rpc $name: failed durationMs=${System.currentTimeMillis() - start}", e) + throw e + } + } + + suspend fun state(directory: String): ProviderSettingsDto = try { + call("state dir=$directory") { state(directory) } + } catch (e: Exception) { + LOG.warn("provider settings lookup failed for directory=$directory", e) + ProviderSettingsDto(errors = listOf(LoadErrorDto(resource = "providers", detail = e.message))) + } + + suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto = action(input.directory) { connect(input) } + suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto = call("authorize provider=${input.providerId}", OAUTH_RPC_TIMEOUT_MS) { authorize(input) } + suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto = action(input.directory, OAUTH_RPC_TIMEOUT_MS) { callback(input) } + suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto = action(input.directory) { disconnect(input) } + suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto = action(input.directory) { enable(input) } + suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto = action(input.directory) { saveCustom(input) } + suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto = call("fetch custom models") { fetchCustomModels(input) } + + private suspend fun action(directory: String, timeoutMs: Long = RPC_TIMEOUT_MS, block: suspend KiloProviderRpcApi.() -> ProviderActionResultDto): ProviderActionResultDto { + LOG.info("provider settings action: start dir=$directory") + val result = try { + call("action dir=$directory", timeoutMs, block) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("provider settings action failed for directory=$directory", e) + return ProviderActionResultDto(state(directory), error = e.message) + } + service().reload(directory) + service().refreshProfileAsync() + LOG.info("provider settings action: completed dir=$directory") + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 4f2fdcf05f9..41a054592cb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -4,6 +4,7 @@ package ai.kilocode.client.app import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -85,6 +87,11 @@ class KiloSessionService internal constructor( } } + internal fun activity(): Map = + statuses.value + .filterValues { it.type == "busy" } + .mapValues { SessionActivityKind.RUNNING } + suspend fun list(dir: String): SessionListDto { val result = call { list(dir) } _sessions.value = result.sessions @@ -149,6 +156,9 @@ class KiloSessionService internal constructor( // ------ Chat ops (explicit session ID) ------ + suspend fun enhancePrompt(dir: String, text: String): String = + call { enhancePrompt(dir, text) } + /** Send a prompt to a session. */ suspend fun prompt(id: String, dir: String, dto: PromptDto) { val meta = if (LOG.isDebugEnabled) { @@ -161,6 +171,12 @@ class KiloSessionService internal constructor( LOG.info("${ChatLogSummary.sid(id)} kind=prompt ok=true") } + suspend fun command(id: String, dir: String, command: String, args: String, dto: PromptDto) { + LOG.info("${ChatLogSummary.sid(id)} kind=command command=$command parts=${dto.parts.size}") + call { command(id, dir, command, args, dto) } + LOG.info("${ChatLogSummary.sid(id)} kind=command ok=true") + } + /** Abort ongoing processing for a session. */ suspend fun abort(id: String, dir: String) { call { abort(id, dir) } @@ -176,6 +192,9 @@ class KiloSessionService internal constructor( call { messages(id, dir) } .also { LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } } + suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = + call { attachmentPart(id, dir, message, part, key) } + /** Subscribe to streaming chat events for a session. */ fun events(id: String, dir: String): Flow { val api = rpc diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index b63964f745d..3b9558bdbd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -3,12 +3,18 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flow @@ -38,6 +44,11 @@ class KiloWorkspaceService internal constructor( } private val workspaces = ConcurrentHashMap() + internal val localConfig = ConcurrentHashMap() + + @Volatile + internal var globalConfig: ConfigTargetDto? = null + private set // ------ RPC helpers ------ @@ -98,4 +109,99 @@ class KiloWorkspaceService internal constructor( } } } + + suspend fun models(directory: String): ModelsWorkspaceDto { + return try { + call { this.models(directory) } + } catch (e: Exception) { + LOG.warn("models settings lookup failed for directory=$directory", e) + ModelsWorkspaceDto(errors = listOf(LoadErrorDto(resource = "models", detail = e.message))) + } + } + + suspend fun files(directory: String, path: String): List { + return try { + call { files(directory, path) } + } catch (e: Exception) { + LOG.warn("workspace file lookup failed for directory=$directory path=$path", e) + emptyList() + } + } + + suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto { + return try { + call { searchFiles(directory, query, limit) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("workspace file search failed for directory=$directory query=$query", e) + FileSearchResultDto() + } + } + + suspend fun gitChanges(directory: String): String? { + return try { + call { gitChanges(directory) } + } catch (e: Exception) { + LOG.warn("git changes lookup failed for directory=$directory", e) + null + } + } + + suspend fun openPath(directory: String, path: String): Boolean { + val match = files(directory, path).firstOrNull() ?: return false + return try { + call { openFile(match.path) } + } catch (e: Exception) { + LOG.warn("workspace file open failed for path=${match.path}", e) + false + } + } + + suspend fun localConfigTarget(directory: String): ConfigTargetDto? { + return try { + val target = call { this.localConfigTarget(directory) } + localConfig[directory] = target + target + } catch (e: Exception) { + LOG.warn("local config lookup failed for directory=$directory", e) + localConfig[directory] + } + } + + suspend fun globalConfigTarget(): ConfigTargetDto? { + return try { + val target = call { this.globalConfigTarget() } + globalConfig = target + target + } catch (e: Exception) { + LOG.warn("global config lookup failed", e) + globalConfig + } + } + + fun openLocalConfig(directory: String, done: (Boolean) -> Unit) { + cs.launch { + val ok = try { + call { this.openLocalConfig(directory) } + } catch (e: Exception) { + LOG.warn("local config open failed for directory=$directory", e) + false + } + done(ok) + } + } + + fun openGlobalConfig(done: (Boolean) -> Unit) { + cs.launch { + val ok = try { + call { this.openGlobalConfig() } + } catch (e: Exception) { + LOG.warn("global config open failed", e) + false + } + done(ok) + } + } + } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/autocomplete/KiloAutocompleteSettingsService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/autocomplete/KiloAutocompleteSettingsService.kt new file mode 100644 index 00000000000..81f2762fe06 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/autocomplete/KiloAutocompleteSettingsService.kt @@ -0,0 +1,40 @@ +package ai.kilocode.client.autocomplete + +import ai.kilocode.rpc.dto.LegacyAutocompleteSettingsDto +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service(Service.Level.APP) +@State( + name = "KiloAutocompleteSettings", + storages = [Storage("kiloAutocompleteSettings.xml")], +) +class KiloAutocompleteSettingsService : PersistentStateComponent { + + data class State( + var enableAutoTrigger: Boolean? = null, + var enableSmartInlineTaskKeybinding: Boolean? = null, + var enableChatAutocomplete: Boolean? = null, + ) + + private var state = State() + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + fun applyLegacy(settings: LegacyAutocompleteSettingsDto) { + settings.enableAutoTrigger?.let { state.enableAutoTrigger = it } + settings.enableSmartInlineTaskKeybinding?.let { state.enableSmartInlineTaskKeybinding = it } + settings.enableChatAutocomplete?.let { state.enableChatAutocomplete = it } + } + + companion object { + fun getInstance(): KiloAutocompleteSettingsService = service() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt new file mode 100644 index 00000000000..0ebe5e89320 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt @@ -0,0 +1,374 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.migration + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.autocomplete.KiloAutocompleteSettingsService +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.LegacyAutocompleteSettingsDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Interface exposed to session UI components. + */ +interface MigrationUiController { + val state: StateFlow + fun check() + fun start(selections: MigrationUiSelections) + fun skip() + fun finish() +} + +/** + * App-level service that manages migration wizard state shared across all session UIs. + * + * Detects and runs legacy migration via [KiloMigrationRpcApi]. + * All Swing interactions must happen on EDT; service coroutines run off EDT. + */ +@Service(Service.Level.APP) +class KiloMigrationService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloMigrationRpcApi?, + appState: StateFlow?, + private val autocomplete: ((LegacyAutocompleteSettingsDto) -> Unit)?, +) : MigrationUiController { + + /** Platform constructor — resolves RPC lazily. */ + constructor(cs: CoroutineScope) : this(cs, null, service().state, null) + + internal constructor(cs: CoroutineScope, rpc: KiloMigrationRpcApi?) : this(cs, rpc, null, null) + + internal constructor( + cs: CoroutineScope, + rpc: KiloMigrationRpcApi?, + appState: StateFlow?, + ) : this(cs, rpc, appState, null) + + companion object { + private val LOG = KiloLog.create(KiloMigrationService::class.java) + + fun getInstance(): KiloMigrationService = service() + } + + private val _state = MutableStateFlow(MigrationUiState.Hidden) + override val state: StateFlow = _state.asStateFlow() + + private val migrating = AtomicBoolean(false) + private val migrateJob = AtomicReference(null) + private val lastSelections = AtomicReference(null) + + init { + if (appState != null) { + cs.launch { appState.collect(::onAppState) } + } + } + + // ------ RPC helper ------ + + private suspend fun call(block: suspend KiloMigrationRpcApi.() -> T): T { + val api = rpc + return if (api != null) block(api) else durable { block(KiloMigrationRpcApi.getInstance()) } + } + + // ------ MigrationUiController ------ + + override fun check() = Unit + + /** + * Start migration for the given user selections. + */ + override fun start(selections: MigrationUiSelections) { + val current = _state.value as? MigrationUiState.Needed ?: return + if (!migrating.compareAndSet(false, true)) { + LOG.info("Migration wizard: start ignored because migration is already running") + return + } + LOG.info("Migration wizard: user started migration ${selectionSummary(selections)}") + telemetry("Migration Started", selectionProps(selections)) + lastSelections.set(selections) + + val dto = MigrationSelectionBuilder.toDto(selections) + val initialProgress = buildInitialProgress(selections, current.detection) + applyAutocomplete(selections, current.detection) + + _state.value = current.copy( + phase = MigrationUiPhase.migrating, + running = true, + progress = initialProgress, + sessionProgress = null, + sessionSummary = SessionMigrationSummary(), + results = emptyList(), + ) + + val job = cs.launch { + try { + val flow = try { + call { migrate(dto) } + } catch (e: Exception) { + telemetry("Migration Failed", mapOf("itemCount" to initialProgress.size.toString(), "errorCount" to "1", "stage" to "start")) + LOG.warn("migration start failed", e) + finishWithError(e.message ?: "Migration failed") + return@launch + } + flow.collect { event -> handleEvent(event) } + } finally { + migrating.set(false) + } + } + migrateJob.set(job) + } + + /** + * Skip migration — marks status and hides for all observers. + */ + override fun skip() { + LOG.info("Migration wizard: user chose skip") + val current = _state.value as? MigrationUiState.Needed + if (current != null) telemetry("Migration Skipped", detectionProps(current.detection)) + cs.launch { + try { + call { skip() } + } catch (e: Exception) { + LOG.warn("migration skip failed", e) + } + _state.value = MigrationUiState.Hidden + } + } + + /** + * Finalize migration — marks completed/completed_with_errors and hides. + */ + override fun finish() { + val current = _state.value as? MigrationUiState.Needed ?: run { + LOG.info("Migration wizard: finish requested while hidden") + _state.value = MigrationUiState.Hidden + return + } + val hasErrors = current.results.any { it.status == MigrationItemStatusDto.error } + val status = if (hasErrors) LegacyMigrationStatusDto.completed_with_errors else LegacyMigrationStatusDto.completed + val selections = lastSelections.get() + LOG.info("Migration wizard: user finished migration status=$status results=${current.results.size} errors=${current.results.count { it.status == MigrationItemStatusDto.error }}") + telemetry("Migration Finished", mapOf("status" to status.name, "cleanupRequested" to (selections?.keepLegacySettingsFile == false).toString())) + cs.launch { + try { + call { finalize(status) } + if (selections?.keepLegacySettingsFile == false) { + call { cleanup(cleanupTargets()) } + } + } catch (e: Exception) { + LOG.warn("migration finalize failed", e) + } + _state.value = MigrationUiState.Hidden + } + } + + // ------ Internal event handling ------ + + private fun handleEvent(event: LegacyMigrationEventDto) { + val current = _state.value as? MigrationUiState.Needed ?: return + when (event) { + is LegacyMigrationEventDto.Item -> { + val p = event.progress + LOG.info("Migration wizard: item progress item=${p.item} status=${p.status} message=${p.message}") + val updated = current.progress.map { + if (it.item == p.item) it.copy(status = p.status, message = p.message) else it + } + _state.value = current.copy(progress = updated) + } + is LegacyMigrationEventDto.Session -> { + val sp = event.progress + val phase = sp.phase + LOG.info("Migration wizard: session progress phase=$phase session=${sp.session?.id} error=${sp.error}") + + // Update session summary buckets + val summary = when (phase) { + MigrationSessionPhaseDto.done -> { + val item = LegacyMigrationResultItemDto( + item = sp.session?.id ?: "", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session, + status = MigrationItemStatusDto.success, + ) + current.sessionSummary.copy(imported = current.sessionSummary.imported + item) + } + MigrationSessionPhaseDto.error -> { + val item = LegacyMigrationResultItemDto( + item = sp.session?.id ?: "", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session, + status = MigrationItemStatusDto.error, + message = sp.error, + ) + current.sessionSummary.copy(errored = current.sessionSummary.errored + item) + } + else -> current.sessionSummary + } + _state.value = current.copy(sessionProgress = sp, sessionSummary = summary) + } + is LegacyMigrationEventDto.Complete -> { + val items = event.items + val hasErrors = items.any { it.status == MigrationItemStatusDto.error } + val phase = if (hasErrors) MigrationUiPhase.error else MigrationUiPhase.done + val progress = if (hasErrors) current.progress else finishSilentProgress(current.progress) + LOG.info("Migration wizard: migration complete phase=$phase items=${items.size} errors=${items.count { it.status == MigrationItemStatusDto.error }}") + if (hasErrors) { + telemetry("Migration Failed", mapOf("itemCount" to items.size.toString(), "errorCount" to items.count { it.status == MigrationItemStatusDto.error }.toString(), "stage" to "complete")) + } else { + telemetry("Migration Completed", mapOf("itemCount" to items.size.toString(), "sessionImportedCount" to current.sessionSummary.imported.size.toString())) + } + _state.value = current.copy( + running = false, + phase = phase, + progress = progress, + results = items, + ) + } + is LegacyMigrationEventDto.Error -> { + LOG.warn("Migration wizard: migration error message=${event.message}") + telemetry("Migration Failed", mapOf("itemCount" to current.progress.size.toString(), "errorCount" to "1", "stage" to "event")) + finishWithError(event.message) + } + } + } + + private fun onAppState(state: KiloAppStateDto) { + val migration = state.migration + if (state.status == KiloAppStatusDto.MIGRATION_REQUIRED && migration != null) { + val current = _state.value + if (current is MigrationUiState.Needed && current.detection == migration && current.phase != MigrationUiPhase.selecting) return + LOG.info("Migration wizard: showing because backend requires migration ${detectionSummary(migration)}") + telemetry("Migration Shown", detectionProps(migration)) + _state.value = MigrationUiState.Needed(migration) + return + } + if (migrating.get()) return + if (_state.value !is MigrationUiState.Hidden) { + LOG.info("Migration wizard: hiding because backend status=${state.status}") + } + _state.value = MigrationUiState.Hidden + } + + private fun finishWithError(msg: String) { + val current = _state.value as? MigrationUiState.Needed ?: return + val errItem = LegacyMigrationResultItemDto( + item = "Migration", + category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.settings, + status = MigrationItemStatusDto.error, + message = msg, + ) + _state.value = current.copy( + running = false, + phase = MigrationUiPhase.error, + results = listOf(errItem), + ) + } + + private fun applyAutocomplete( + selections: MigrationUiSelections, + detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto, + ) { + if (!selections.settings.autocomplete) return + val settings = detection.settings?.autocomplete ?: return + val apply = autocomplete ?: { KiloAutocompleteSettingsService.getInstance().applyLegacy(it) } + apply(settings) + } + + private fun selectionSummary(selections: MigrationUiSelections): String = + "providers=${selections.providers.size}:${selections.providers.joinToString(",")} mcp=${selections.mcpServers.size}:${selections.mcpServers.joinToString(",")} modes=${selections.customModes.size}:${selections.customModes.joinToString(",")} sessions=${selections.sessions.size} model=${selections.defaultModel} settings=${settingsSummary(selections.settings)} keepFile=${selections.keepLegacySettingsFile}" + + private fun cleanupTargets() = LegacyCleanupTargetsDto( + providerProfiles = true, + mcpSettings = true, + customModes = true, + globalState = true, + taskHistory = true, + legacySettingsFile = true, + ) + + private fun settingsSummary(settings: MigrationSettingsUiSelections): String = + "commandRules=${settings.autoApproval.commandRules},read=${settings.autoApproval.readPermission},write=${settings.autoApproval.writePermission},execute=${settings.autoApproval.executePermission},mcp=${settings.autoApproval.mcpPermission},task=${settings.autoApproval.taskPermission},language=${settings.language},autocomplete=${settings.autocomplete}" + + private fun detectionSummary(detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto): String = + "providers=${detection.providers.size} mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size} model=${detection.defaultModel != null} settings=${detection.settings != null}" + + private fun detectionProps(detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto): Map = mapOf( + "settings" to (detection.settings != null).toString(), + "providers" to detection.providers.size.toString(), + "mcpServers" to detection.mcpServers.size.toString(), + "customModes" to detection.customModes.size.toString(), + "sessions" to detection.sessions.size.toString(), + "defaultModel" to (detection.defaultModel != null).toString(), + ) + + private fun selectionProps(selections: MigrationUiSelections): Map = mapOf( + "providers" to selections.providers.size.toString(), + "mcpServers" to selections.mcpServers.size.toString(), + "customModes" to selections.customModes.size.toString(), + "sessions" to selections.sessions.size.toString(), + "defaultModel" to selections.defaultModel.toString(), + "keepLegacySettingsFile" to selections.keepLegacySettingsFile.toString(), + ) + + private fun telemetry(event: String, props: Map) { + Telemetry.send(event, props) + } + + private fun buildInitialProgress( + selections: MigrationUiSelections, + detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto, + ): List { + val list = mutableListOf() + for (id in selections.providers) { + list.add(MigrationItemUiProgress(id, MigrationItemCategoryDto.provider)) + } + for (name in selections.mcpServers) { + list.add(MigrationItemUiProgress(name, MigrationItemCategoryDto.mcpServer)) + } + for (slug in selections.customModes) { + val info = detection.customModes.find { it.slug == slug } + list.add(MigrationItemUiProgress(info?.name ?: slug, MigrationItemCategoryDto.customMode)) + } + for (id in selections.sessions) { + list.add(MigrationItemUiProgress(id, MigrationItemCategoryDto.session)) + } + if (selections.defaultModel) { + list.add(MigrationItemUiProgress("Default model", MigrationItemCategoryDto.defaultModel)) + } + // Settings sub-items + val ap = selections.settings.autoApproval + if (ap.commandRules) list.add(MigrationItemUiProgress("Command rules", MigrationItemCategoryDto.settings)) + if (ap.readPermission) list.add(MigrationItemUiProgress("Read permission", MigrationItemCategoryDto.settings)) + if (ap.writePermission) list.add(MigrationItemUiProgress("Write permission", MigrationItemCategoryDto.settings)) + if (ap.executePermission) list.add(MigrationItemUiProgress("Execute permission", MigrationItemCategoryDto.settings)) + if (ap.mcpPermission) list.add(MigrationItemUiProgress("MCP permission", MigrationItemCategoryDto.settings)) + if (ap.taskPermission) list.add(MigrationItemUiProgress("Task permission", MigrationItemCategoryDto.settings)) + if (selections.settings.language) list.add(MigrationItemUiProgress("Language preference", MigrationItemCategoryDto.settings)) + if (selections.settings.autocomplete) list.add(MigrationItemUiProgress("Autocomplete settings", MigrationItemCategoryDto.settings)) + return list + } + + private fun finishSilentProgress(items: List) = items.map { + if (it.status == MigrationItemProgressStatusDto.migrating) it.copy(status = MigrationItemProgressStatusDto.success) + else it + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationSelectionBuilder.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationSelectionBuilder.kt new file mode 100644 index 00000000000..9ca47aa77b9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationSelectionBuilder.kt @@ -0,0 +1,86 @@ +package ai.kilocode.client.migration + +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.MigrationAutoApprovalSelectionsDto +import ai.kilocode.rpc.dto.MigrationSessionSelectionDto +import ai.kilocode.rpc.dto.MigrationSettingsSelectionsDto + +/** + * Builds default preselections from detection data, mirroring VS Code behavior. + * Also converts UI selections to wire DTOs for RPC. + */ +object MigrationSelectionBuilder { + + /** + * Build default selections mirroring VS Code preselection logic: + * - Providers: supported providers with API keys + * - MCP: all if any servers exist + * - Modes: all if any custom modes exist + * - Sessions: all if any sessions exist + * - Default model: if present + * - Auto-approval: subfields if corresponding data exists + * - Language: if present + * - Autocomplete: if present + */ + fun defaults(detection: LegacyMigrationDetectionDto): MigrationUiSelections { + val providers = detection.providers + .filter { it.supported && it.hasApiKey } + .map { it.profileName } + val mcpServers = if (detection.mcpServers.isNotEmpty()) detection.mcpServers.map { it.name } else emptyList() + val customModes = if (detection.customModes.isNotEmpty()) detection.customModes.map { it.slug } else emptyList() + val sessions = if (detection.sessions.isNotEmpty()) detection.sessions.map { it.id } else emptyList() + val defaultModel = detection.defaultModel != null + + val settings = detection.settings + val ap = MigrationAutoApprovalUiSelections( + commandRules = settings?.let { + !it.allowedCommands.isNullOrEmpty() || !it.deniedCommands.isNullOrEmpty() + } ?: false, + readPermission = settings?.alwaysAllowReadOnly != null || settings?.alwaysAllowReadOnlyOutsideWorkspace != null, + writePermission = settings?.alwaysAllowWrite != null, + executePermission = settings?.alwaysAllowExecute != null, + mcpPermission = settings?.alwaysAllowMcp != null, + taskPermission = settings?.alwaysAllowModeSwitch != null || settings?.alwaysAllowSubtasks != null, + ) + val settingsSel = MigrationSettingsUiSelections( + autoApproval = ap, + language = !settings?.language.isNullOrEmpty(), + autocomplete = settings?.autocomplete != null, + ) + + return MigrationUiSelections( + providers = providers, + mcpServers = mcpServers, + customModes = customModes, + sessions = sessions, + defaultModel = defaultModel, + settings = settingsSel, + keepLegacySettingsFile = true, + ) + } + + /** + * Convert UI selections into the wire DTO, taking only supported+apiKey providers. + */ + fun toDto(selections: MigrationUiSelections): LegacyMigrationSelectionsDto = LegacyMigrationSelectionsDto( + providers = selections.providers, + mcpServers = selections.mcpServers, + customModes = selections.customModes, + sessions = selections.sessions.map { MigrationSessionSelectionDto(it) }, + defaultModel = selections.defaultModel, + settings = MigrationSettingsSelectionsDto( + autoApproval = MigrationAutoApprovalSelectionsDto( + commandRules = selections.settings.autoApproval.commandRules, + readPermission = selections.settings.autoApproval.readPermission, + writePermission = selections.settings.autoApproval.writePermission, + executePermission = selections.settings.autoApproval.executePermission, + mcpPermission = selections.settings.autoApproval.mcpPermission, + taskPermission = selections.settings.autoApproval.taskPermission, + ), + language = selections.settings.language, + autocomplete = selections.settings.autocomplete, + ), + keepLegacySettingsFile = selections.keepLegacySettingsFile, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationUiState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationUiState.kt new file mode 100644 index 00000000000..9950a0fa94d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/MigrationUiState.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.migration + +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto + +// --------------------------------------------------------------------------- +// User selections for UI +// --------------------------------------------------------------------------- + +data class MigrationAutoApprovalUiSelections( + val commandRules: Boolean = false, + val readPermission: Boolean = false, + val writePermission: Boolean = false, + val executePermission: Boolean = false, + val mcpPermission: Boolean = false, + val taskPermission: Boolean = false, +) + +data class MigrationSettingsUiSelections( + val autoApproval: MigrationAutoApprovalUiSelections = MigrationAutoApprovalUiSelections(), + val language: Boolean = false, + val autocomplete: Boolean = false, +) + +data class MigrationUiSelections( + val providers: List = emptyList(), + val mcpServers: List = emptyList(), + val customModes: List = emptyList(), + val sessions: List = emptyList(), + val defaultModel: Boolean = false, + val settings: MigrationSettingsUiSelections = MigrationSettingsUiSelections(), + val keepLegacySettingsFile: Boolean = true, +) + +// --------------------------------------------------------------------------- +// Progress tracking per item +// --------------------------------------------------------------------------- + +data class MigrationItemUiProgress( + val item: String, + val category: MigrationItemCategoryDto, + val status: MigrationItemProgressStatusDto = MigrationItemProgressStatusDto.migrating, + val message: String? = null, +) + +// --------------------------------------------------------------------------- +// Session summary buckets +// --------------------------------------------------------------------------- + +data class SessionMigrationSummary( + val imported: List = emptyList(), + val errored: List = emptyList(), +) + +// --------------------------------------------------------------------------- +// Migration phase for the overall UI +// --------------------------------------------------------------------------- + +enum class MigrationUiPhase { + /** Wizard showing selection checkboxes. */ + selecting, + /** Migration is running. */ + migrating, + /** Migration finished with no errors. */ + done, + /** Migration finished with errors. */ + error, +} + +// --------------------------------------------------------------------------- +// Top-level shared state emitted by KiloMigrationService +// --------------------------------------------------------------------------- + +sealed class MigrationUiState { + /** Migration overlay should not be shown. */ + object Hidden : MigrationUiState() + + /** Migration data was detected; show the wizard. */ + data class Needed( + val detection: LegacyMigrationDetectionDto, + val phase: MigrationUiPhase = MigrationUiPhase.selecting, + val running: Boolean = false, + val progress: List = emptyList(), + val sessionProgress: LegacyMigrationSessionProgressDto? = null, + val sessionSummary: SessionMigrationSummary = SessionMigrationSummary(), + val results: List = emptyList(), + ) : MigrationUiState() +} + +// --------------------------------------------------------------------------- +// Derived helpers on state +// --------------------------------------------------------------------------- + +fun MigrationItemProgressStatusDto.toResultStatus(): MigrationItemStatusDto? = when (this) { + MigrationItemProgressStatusDto.success -> MigrationItemStatusDto.success + MigrationItemProgressStatusDto.warning -> MigrationItemStatusDto.warning + MigrationItemProgressStatusDto.error -> MigrationItemStatusDto.error + MigrationItemProgressStatusDto.migrating -> null +} + +/** Derive group-level status from item progress entries in a category. */ +fun groupStatus(items: List): MigrationItemProgressStatusDto { + if (items.any { it.status == MigrationItemProgressStatusDto.error }) return MigrationItemProgressStatusDto.error + if (items.any { it.status == MigrationItemProgressStatusDto.warning }) return MigrationItemProgressStatusDto.warning + if (items.all { it.status == MigrationItemProgressStatusDto.success }) return MigrationItemProgressStatusDto.success + if (items.any { it.status == MigrationItemProgressStatusDto.migrating }) return MigrationItemProgressStatusDto.migrating + return MigrationItemProgressStatusDto.migrating +} + +/** True if the session summary phase is currently showing. */ +fun LegacyMigrationSessionProgressDto.isSummary(): Boolean = + phase == MigrationSessionPhaseDto.summary diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationItemRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationItemRow.kt new file mode 100644 index 00000000000..aff437d14be --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationItemRow.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.MigrationItemUiProgress +import ai.kilocode.client.migration.MigrationUiPhase +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.CardLayout +import java.awt.Dimension +import javax.swing.JPanel + +/** + * A single row in the migration item list. + * Shows a checkbox in [MigrationUiPhase.selecting] or a status icon otherwise. + */ +class MigrationItemRow( + private val label: String, + private val category: MigrationItemCategoryDto, +) : BorderLayoutPanel() { + + private val check = JBCheckBox() + private val statusIcon = MigrationStatusIcon() + private val nameLabel = JBLabel(label) + private val messageLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + border = JBUI.Borders.emptyLeft(UiStyle.Gap.sm()) + } + private val leading = LeadingSlot(check, statusIcon) + + private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) + .next(leading) + .next(nameLabel) + .next(messageLabel) + + var selected: Boolean + get() = check.isSelected + set(v) { check.isSelected = v } + + var onSelectionChanged: ((Boolean) -> Unit)? = null + + init { + isOpaque = false + border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) + + check.isOpaque = false + check.addActionListener { onSelectionChanged?.invoke(check.isSelected) } + statusIcon.update(MigrationItemProgressStatusDto.migrating) + + addToCenter(row) + } + + fun updatePhase(phase: MigrationUiPhase) { + leading.display(phase == MigrationUiPhase.selecting) + } + + fun updateProgress(progress: MigrationItemUiProgress?) { + if (progress == null) { + statusIcon.update(MigrationItemProgressStatusDto.migrating) + messageLabel.text = null + return + } + statusIcon.update(progress.status) + messageLabel.text = progress.message + } + + private class LeadingSlot( + private val check: JBCheckBox, + icon: MigrationStatusIcon, + ) : JPanel(CardLayout()) { + + private var size: Dimension? = null + + init { + isOpaque = false + add(check, SELECT) + add(icon.align(HAlign.CENTER, VAlign.CENTER), STATUS) + } + + fun display(selecting: Boolean) { + (layout as CardLayout).show(this, if (selecting) SELECT else STATUS) + } + + override fun updateUI() { + size = null + super.updateUI() + } + + override fun getMinimumSize(): Dimension = stableSize() + + override fun getPreferredSize(): Dimension = stableSize() + + override fun getMaximumSize(): Dimension = stableSize() + + private fun stableSize(): Dimension { + val cached = size + if (cached != null) return Dimension(cached) + + val dim = check.preferredSize + size = Dimension(dim) + return Dimension(dim) + } + + private companion object { + const val SELECT = "select" + const val STATUS = "status" + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt new file mode 100644 index 00000000000..376462df630 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationOverlayPanel.kt @@ -0,0 +1,54 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.MigrationUiSelections +import ai.kilocode.client.migration.MigrationUiState +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.ui.components.JBPanel +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.BorderLayout +import javax.swing.JComponent + +/** + * Outer container for the migration wizard rendered inside the blocker layer. + * + * Wraps [MigrationWizardPanel] in the blocker layer. + * Build once; call [update] on every state change. + */ +class MigrationOverlayPanel : JBPanel(BorderLayout()) { + + private val wizard = MigrationWizardPanel() + + var onSkip: (() -> Unit)? + get() = wizard.onSkip + set(v) { wizard.onSkip = v } + + var onStart: ((MigrationUiSelections) -> Unit)? + get() = wizard.onStart + set(v) { wizard.onStart = v } + + var onDone: (() -> Unit)? + get() = wizard.onDone + set(v) { wizard.onDone = v } + + var onContinueFromError: (() -> Unit)? + get() = wizard.onContinueFromError + set(v) { wizard.onContinueFromError = v } + + init { + withBackground(UiStyle.Colors.bg()) + add(wizard.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) + } + + @RequiresEdt + fun update(state: MigrationUiState.Needed) { + wizard.update(state) + revalidate() + repaint() + } + + @RequiresEdt + fun preferredFocusComponent(): JComponent = wizard.preferredFocusComponent() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationStatusIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationStatusIcon.kt new file mode 100644 index 00000000000..ad7c5bd4993 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationStatusIcon.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import com.intellij.icons.AllIcons +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.JBLabel +import javax.swing.Icon + +/** Shows an animated spinner (migrating), success, warning, or error icon. */ +class MigrationStatusIcon : JBLabel() { + + fun update(status: MigrationItemProgressStatusDto) { + icon = iconFor(status) + } + + private fun iconFor(status: MigrationItemProgressStatusDto): Icon = when (status) { + MigrationItemProgressStatusDto.migrating -> AnimatedIcon.Default() + MigrationItemProgressStatusDto.success -> AllIcons.General.InspectionsOK + MigrationItemProgressStatusDto.warning -> AllIcons.General.Warning + MigrationItemProgressStatusDto.error -> AllIcons.General.Error + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt new file mode 100644 index 00000000000..f2d1346ac7a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/ui/MigrationWizardPanel.kt @@ -0,0 +1,233 @@ +package ai.kilocode.client.migration.ui + +import ai.kilocode.client.migration.MigrationItemUiProgress +import ai.kilocode.client.migration.MigrationSelectionBuilder +import ai.kilocode.client.migration.MigrationSettingsUiSelections +import ai.kilocode.client.migration.MigrationUiPhase +import ai.kilocode.client.migration.MigrationUiSelections +import ai.kilocode.client.migration.MigrationUiState +import ai.kilocode.client.migration.groupStatus +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.BorderLayout +import java.awt.Component +import javax.swing.JComponent +import javax.swing.JPanel + +private const val ACTION_SKIP = "skip" +private const val ACTION_MIGRATE = "migrate" +private const val ACTION_DONE = "done" +private const val ACTION_CONTINUE = "continue" + +/** + * Migration selection wizard. + * + * Build once; call [update] for every state change. Does not rebuild the component tree. + */ +class MigrationWizardPanel : JPanel(BorderLayout()) { + + // ------ Callbacks ------ + var onSkip: (() -> Unit)? = null + var onStart: ((MigrationUiSelections) -> Unit)? = null + var onDone: (() -> Unit)? = null + var onContinueFromError: (() -> Unit)? = null + + // ------ Migrate screen row state ------ + private val rows = mutableMapOf() + private val settingsRow = MigrationItemRow(KiloBundle.message("migration.row.settings"), MigrationItemCategoryDto.settings) + private val providerRow = MigrationItemRow(KiloBundle.message("migration.row.providers"), MigrationItemCategoryDto.provider) + private val mcpRow = MigrationItemRow(KiloBundle.message("migration.row.mcp"), MigrationItemCategoryDto.mcpServer) + private val modesRow = MigrationItemRow(KiloBundle.message("migration.row.modes"), MigrationItemCategoryDto.customMode) + private val sessionsRow = MigrationItemRow(KiloBundle.message("migration.row.sessions"), MigrationItemCategoryDto.session) + private val modelRow = MigrationItemRow(KiloBundle.message("migration.row.model"), MigrationItemCategoryDto.defaultModel) + + private val question = BaseQuestionView() + private val keepBox = JBCheckBox(KiloBundle.message("migration.keep_legacy_settings"), true) + + private val emptyLabel = JBLabel(KiloBundle.message("migration.empty")).apply { + foreground = UiStyle.Colors.weak() + } + + private var detection: LegacyMigrationDetectionDto? = null + private var selections = MigrationUiSelections() + private var phase = MigrationUiPhase.selecting + private var running = false + + init { + isOpaque = false + + rows[MigrationItemCategoryDto.provider] = providerRow + rows[MigrationItemCategoryDto.mcpServer] = mcpRow + rows[MigrationItemCategoryDto.customMode] = modesRow + rows[MigrationItemCategoryDto.session] = sessionsRow + rows[MigrationItemCategoryDto.defaultModel] = modelRow + rows[MigrationItemCategoryDto.settings] = settingsRow + + for (row in rows.values) { + row.onSelectionChanged = { _ -> updateMigrateButtonEnabled() } + } + + question.setHeader( + KiloBundle.message("migration.migrate.title"), + KiloBundle.message("migration.migrate.subtitle"), + ) + question.setContent(buildContent()) + question.setActions( + listOf( + BaseQuestionView.Action(ACTION_SKIP, KiloBundle.message("migration.button.skip"), primary = false) { + onSkip?.invoke() + }, + BaseQuestionView.Action(ACTION_MIGRATE, KiloBundle.message("migration.button.migrate"), primary = true) { + onStart?.invoke(currentSelections()) + }, + BaseQuestionView.Action(ACTION_DONE, KiloBundle.message("migration.button.done"), primary = true) { + onDone?.invoke() + }, + BaseQuestionView.Action(ACTION_CONTINUE, KiloBundle.message("migration.button.continue"), primary = true) { + onContinueFromError?.invoke() + }, + ), + ) + question.setActionLeft(keepBox) + + add(question, BorderLayout.CENTER) + updateButtons(MigrationUiPhase.selecting, running = false) + } + + // ------ Public update ------ + + @RequiresEdt + fun update(state: MigrationUiState.Needed) { + val det = state.detection + // Detection and default selections are set once on first update and are stable for the + // lifetime of a single wizard session. The service never re-detects mid-session. + if (detection == null || detection != det) { + detection = det + selections = MigrationSelectionBuilder.defaults(det) + applyDefaults(det) + } + + phase = state.phase + running = state.running + + // Update row visibility based on what data exists + providerRow.isVisible = det.providers.any { it.supported } + mcpRow.isVisible = det.mcpServers.isNotEmpty() + modesRow.isVisible = det.customModes.isNotEmpty() + sessionsRow.isVisible = det.sessions.isNotEmpty() + modelRow.isVisible = det.defaultModel != null + settingsRow.isVisible = det.settings != null + emptyLabel.isVisible = !det.hasData + + // Update phase for all rows + for (row in rows.values) { + row.updatePhase(phase) + } + + // Update progress for each row category + updateRowProgress(MigrationItemCategoryDto.provider, state.progress) + updateRowProgress(MigrationItemCategoryDto.mcpServer, state.progress) + updateRowProgress(MigrationItemCategoryDto.customMode, state.progress) + updateRowProgress(MigrationItemCategoryDto.session, state.progress) + updateRowProgress(MigrationItemCategoryDto.defaultModel, state.progress) + updateRowProgress(MigrationItemCategoryDto.settings, state.progress) + + updateButtons(phase, running) + updateMigrateButtonEnabled() + question.revalidate() + question.repaint() + revalidate() + repaint() + } + + @RequiresEdt + fun preferredFocusComponent(): JComponent = question.preferredActionComponent(ACTION_MIGRATE) + + internal fun keepLegacySettingsFileSelectedForTest() = keepBox.isSelected + + // ------ Internal helpers ------ + + private fun applyDefaults(det: LegacyMigrationDetectionDto) { + val defaults = MigrationSelectionBuilder.defaults(det) + providerRow.selected = defaults.providers.isNotEmpty() + mcpRow.selected = defaults.mcpServers.isNotEmpty() + modesRow.selected = defaults.customModes.isNotEmpty() + sessionsRow.selected = defaults.sessions.isNotEmpty() + modelRow.selected = defaults.defaultModel + settingsRow.selected = defaults.settings.autoApproval.commandRules || + defaults.settings.autoApproval.readPermission || + defaults.settings.autoApproval.writePermission || + defaults.settings.autoApproval.executePermission || + defaults.settings.autoApproval.mcpPermission || + defaults.settings.autoApproval.taskPermission || + defaults.settings.language || + defaults.settings.autocomplete + keepBox.isSelected = defaults.keepLegacySettingsFile + } + + private fun updateRowProgress(category: MigrationItemCategoryDto, items: List) { + val row = rows[category] ?: return + val categoryItems = items.filter { it.category == category } + if (categoryItems.isEmpty()) { + row.updateProgress(null) + return + } + val status = groupStatus(categoryItems) + row.updateProgress(MigrationItemUiProgress(category.name, category, status)) + } + + private fun updateButtons(phase: MigrationUiPhase, running: Boolean) { + question.setActionVisible(ACTION_SKIP, phase == MigrationUiPhase.selecting) + question.setActionVisible(ACTION_MIGRATE, phase == MigrationUiPhase.selecting || phase == MigrationUiPhase.migrating) + question.setActionText( + ACTION_MIGRATE, + if (running) KiloBundle.message("migration.button.migrating") else KiloBundle.message("migration.button.migrate"), + ) + question.setActionVisible(ACTION_DONE, phase == MigrationUiPhase.done) + question.setActionVisible(ACTION_CONTINUE, phase == MigrationUiPhase.error) + keepBox.isVisible = phase == MigrationUiPhase.selecting + } + + private fun updateMigrateButtonEnabled() { + val any = rows.values.any { it.isVisible && it.selected } + question.setActionEnabled(ACTION_MIGRATE, any && phase == MigrationUiPhase.selecting && !running) + } + + private fun currentSelections(): MigrationUiSelections { + val det = detection ?: return MigrationUiSelections(keepLegacySettingsFile = keepBox.isSelected) + val providers = if (providerRow.selected) det.providers.filter { it.supported && it.hasApiKey }.map { it.profileName } else emptyList() + val mcpServers = if (mcpRow.selected) det.mcpServers.map { it.name } else emptyList() + val modes = if (modesRow.selected) det.customModes.map { it.slug } else emptyList() + val sessions = if (sessionsRow.selected) det.sessions.map { it.id } else emptyList() + val defaults = MigrationSelectionBuilder.defaults(det) + return MigrationUiSelections( + providers = providers, + mcpServers = mcpServers, + customModes = modes, + sessions = sessions, + defaultModel = modelRow.selected, + settings = if (settingsRow.selected) defaults.settings else MigrationSettingsUiSelections(), + keepLegacySettingsFile = keepBox.isSelected, + ) + } + + private fun buildContent(): JComponent { + return Stack.vertical(gap = UiStyle.Gap.xs()).apply { + alignmentX = Component.LEFT_ALIGNMENT + } + .next(emptyLabel) + .next(providerRow) + .next(mcpRow) + .next(modesRow) + .next(sessionsRow) + .next(modelRow) + .next(settingsRow) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloBundle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloBundle.kt index 821b03f8ec4..26efda9948d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloBundle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloBundle.kt @@ -9,4 +9,9 @@ object KiloBundle : DynamicBundle(BUNDLE) { fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { return getMessage(key, *params) } + + fun optional(key: String): String? { + if (!containsKey(key)) return null + return getMessage(key) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt new file mode 100644 index 00000000000..d721fc7cc2f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloFrontendDynamicPluginListener.kt @@ -0,0 +1,51 @@ +package ai.kilocode.client.plugin + +import ai.kilocode.KiloPlugin +import ai.kilocode.client.session.ui.attachment.unregisterAttachmentEditorKind +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.log.KiloLog +import com.intellij.ide.plugins.DynamicPluginListener +import com.intellij.ide.plugins.IdeaPluginDescriptor +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.wm.ToolWindowManager +import javax.swing.SwingUtilities + +class KiloFrontendDynamicPluginListener : DynamicPluginListener { + override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) { + if (pluginDescriptor.pluginId != KiloPlugin.id) return + KiloFrontendUnloadCleanup.cleanup(isUpdate) + } +} + +object KiloFrontendUnloadCleanup { + private val log = KiloLog.create(KiloFrontendUnloadCleanup::class.java) + + fun cleanup(isUpdate: Boolean) { + log.info("Cleaning up Kilo frontend for plugin unload (isUpdate=$isUpdate)") + runEdt { + ProjectManager.getInstance().openProjects.forEach { project -> + if (project.isDisposed) return@forEach + ToolWindowManager.getInstance(project).getToolWindow("Kilo Code") + ?.contentManager + ?.removeAllContents(true) + val editors = FileEditorManager.getInstance(project).openFiles + .filter { it.fileSystem === KiloVirtualFileSystem.getInstance() } + editors.forEach { file -> FileEditorManager.getInstance(project).closeFile(file) } + } + } + unregisterAttachmentEditorKind() + service().clear() + KiloVirtualFileSystem.getInstance().clear() + } + + private fun runEdt(block: () -> Unit) { + if (SwingUtilities.isEventDispatchThread()) { + block() + return + } + SwingUtilities.invokeAndWait(block) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt new file mode 100644 index 00000000000..f410fbc3c34 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -0,0 +1,17 @@ +package ai.kilocode.client.plugin + +import com.intellij.ide.util.PropertiesComponent + +object KiloPluginSettings { + private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove" + + fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) + + fun setAutoApprove(value: Boolean) { + PropertiesComponent.getInstance().setValue(AUTO_APPROVE_KEY, value.toString()) + } + + internal fun unsetAutoApprove() { + PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt new file mode 100644 index 00000000000..d1c9aeff9f6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import java.awt.Color + +enum class SessionActivityKind { + RUNNING, + LOGIN_REQUIRED, + PERMISSION, + PLAN, + QUESTION, + ; + + fun label(): String = when (this) { + RUNNING -> KiloBundle.message("session.part.tool.running") + LOGIN_REQUIRED -> KiloBundle.message("history.badge.loginRequired") + PERMISSION -> KiloBundle.message("history.badge.permission") + PLAN -> KiloBundle.message("history.badge.plan") + QUESTION -> KiloBundle.message("history.badge.question") + } + + fun bg(): Color = when (this) { + RUNNING -> UiStyle.Colors.runningBadgeBg() + LOGIN_REQUIRED, PERMISSION, PLAN, QUESTION -> UiStyle.Colors.activityBadgeBg() + } + + fun fg(): Color = when (this) { + RUNNING -> UiStyle.Colors.runningBadgeFg() + LOGIN_REQUIRED, PERMISSION, PLAN, QUESTION -> UiStyle.Colors.activityBadgeFg() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 62d8ec94074..08f2f575193 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -1,11 +1,13 @@ package ai.kilocode.client.session +import ai.kilocode.client.app.Workspace import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.actionSystem.DataKey interface SessionManager { companion object { val KEY = DataKey.create("ai.kilocode.client.session.SessionManager") + val WORKSPACE_KEY = DataKey.create("ai.kilocode.client.session.Workspace") } fun newSession() @@ -14,6 +16,12 @@ interface SessionManager { fun openSession(ref: SessionRef) + fun activity(): Map = emptyMap() + + fun titles(): Map = emptyMap() + + fun activityChanged() {} + fun openSession(session: SessionDto) { openSession(SessionRef.Local(session)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 118c2e87d24..f89e60583db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -5,6 +5,10 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.history.HistoryController import ai.kilocode.client.session.history.HistoryPanel +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.util.UiTimer +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.application.ApplicationManager @@ -12,7 +16,9 @@ import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.cancel import java.awt.BorderLayout import javax.swing.JComponent @@ -21,21 +27,31 @@ import javax.swing.JPanel class SessionSidePanelManager( private val project: Project, private val root: Workspace, - private val create: (Project, Workspace, SessionManager, SessionRef?) -> SessionUi = { project, workspace, manager, ref -> - service().create(project, workspace, manager, ref) - }, + private val create: (Project, Workspace, SessionManager, SessionRef?, UiTimerSource) -> SessionUi = + { project, workspace, manager, ref, timers -> + service().create(project, workspace, manager, ref, timers) + }, private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, + private val status: () -> Map = { project.service().activity() }, private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null, + private val timers: UiTimerSource = UiTimers, + private val request: (JComponent) -> Unit = { focus -> + ApplicationManager.getApplication().invokeLater({ + IdeFocusManager.getInstance(project).requestFocusInProject(focus, project) + }, ModalityState.defaultModalityState()) + }, ) : SessionManager, Disposable { val component: JPanel = object : JPanel(BorderLayout()), DataProvider { override fun getData(dataId: String): Any? { if (SessionManager.KEY.`is`(dataId)) return this@SessionSidePanelManager + if (SessionManager.WORKSPACE_KEY.`is`(dataId)) return root return null } } private val opened = mutableMapOf() private val all = mutableSetOf() + private val activeTimers = mutableMapOf() private var current: SessionUi? = null private var latest: SessionUi? = null private var panel: JComponent? = null @@ -46,7 +62,7 @@ class SessionSidePanelManager( val active = current if (active?.blank == true) return register(active) - show(create(project, root, this, null)) + show(create(project, root, this, null, timers)) } override fun openSession(ref: SessionRef) { @@ -59,15 +75,41 @@ class SessionSidePanelManager( existing } else create(ref) } + if (current === ui) return + Telemetry.send("Session Opened", mapOf("source" to ref.type.name.lowercase(), "sessionId" to ref.id)) show(ui) } + @RequiresEdt + override fun activity(): Map { + val base = status() + val live = all.mapNotNull { ui -> + val id = ui.id ?: return@mapNotNull null + val kind = ui.activityKind() ?: return@mapNotNull null + id to kind + }.toMap() + return base + live + } + + @RequiresEdt + override fun titles(): Map = all.mapNotNull { ui -> + val id = ui.id ?: return@mapNotNull null + val title = ui.title() ?: return@mapNotNull null + id to title + }.toMap() + + @RequiresEdt + override fun activityChanged() { + (panel as? HistoryPanel)?.syncActivity() + current?.syncActivity() + } + private fun create(ref: SessionRef): SessionUi { val workspace = when (ref) { is SessionRef.Local -> ref.session?.directory?.let(resolve) ?: root is SessionRef.Cloud -> root } - return create(project, workspace, this, ref).also { + return create(project, workspace, this, ref, timers).also { all.add(it) opened[ref.key] = it val local = (ref as? SessionRef.Local)?.session?.id @@ -76,13 +118,14 @@ class SessionSidePanelManager( } override fun showHistory() { - register(current) - release(current) + val active = current + register(active) + release(active) val cached = panel val view = cached ?: createHistory().also { panel = it } if (cached != null && view is HistoryPanel) view.refresh() if (current == null && component.componentCount == 1 && component.getComponent(0) === view) { - focusHistory(view) + focus((view as? HistoryPanel)?.defaultFocusedComponent) return } current = null @@ -90,14 +133,12 @@ class SessionSidePanelManager( component.add(view, BorderLayout.CENTER) component.revalidate() component.repaint() - focusHistory(view) + focus((view as? HistoryPanel)?.defaultFocusedComponent) } - private fun focusHistory(view: JComponent) { - val focus = (view as? HistoryPanel)?.defaultFocusedComponent ?: return - ApplicationManager.getApplication().invokeLater({ - IdeFocusManager.getInstance(project).requestFocusInProject(focus, project) - }, ModalityState.defaultModalityState()) + private fun focus(component: JComponent?) { + val focus = component ?: return + request(focus) } private fun createHistory(): JComponent { @@ -113,7 +154,7 @@ class SessionSidePanelManager( deleted = this::removeSession, ) Disposer.register(this) { cs.cancel() } - return HistoryPanel(this, controller, nav = this::back, manager = this).component + return HistoryPanel(this, controller, nav = this::back, manager = this, timers = timers).component } private fun back() { @@ -128,14 +169,11 @@ class SessionSidePanelManager( private fun removeSession(id: String) { val ui = opened.remove(id) ?: return - opened.entries.removeIf { it.value === ui } - all.remove(ui) - if (current === ui) current = null - if (latest === ui) latest = null - Disposer.dispose(ui) + disposeUi(ui) } private fun show(ui: SessionUi) { + cancel(ui) all.add(ui) register(ui) latest = ui @@ -146,6 +184,7 @@ class SessionSidePanelManager( component.add(ui, BorderLayout.CENTER) component.revalidate() component.repaint() + focus(ui.defaultFocusedComponent) } private fun register(ui: SessionUi?) { @@ -155,16 +194,43 @@ class SessionSidePanelManager( private fun release(ui: SessionUi?) { if (ui == null) return - if (ui.cacheKey != null) { - register(ui) + if (ui.cacheKey == null) { + disposeUi(ui) return } + register(ui) + schedule(ui) + } + + private fun disposeUi(ui: SessionUi) { + cancel(ui) + opened.entries.removeIf { it.value === ui } all.remove(ui) + if (current === ui) current = null + if (latest === ui) latest = null Disposer.dispose(ui) } + private fun schedule(ui: SessionUi) { + cancel(ui) + val delay = Registry.intValue("kilo.session.inactive.disposeTimeoutMs").coerceAtLeast(0) + val timer = timers.timer(delay, repeats = false) { + activeTimers.remove(ui) + if (ui === current || ui !in all) return@timer + disposeUi(ui) + } + activeTimers[ui] = timer + timer.start() + } + + private fun cancel(ui: SessionUi) { + activeTimers.remove(ui)?.stop() + } + override fun dispose() { val items = all.toList() + activeTimers.values.forEach { it.stop() } + activeTimers.clear() opened.clear() all.clear() current = null 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 c06995e0ccd..bce1c8e2eb5 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 @@ -2,21 +2,40 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.migration.KiloMigrationService +import ai.kilocode.client.migration.MigrationUiController +import ai.kilocode.client.migration.MigrationUiState +import ai.kilocode.client.migration.ui.MigrationOverlayPanel +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.client.session.ui.ConnectionPanel -import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider +import ai.kilocode.client.session.ui.prompt.MentionAction import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.SlashAction +import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts import ai.kilocode.client.session.ui.account.SessionAccountOverlay +import ai.kilocode.client.session.ui.SessionDropOverlay import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind +import ai.kilocode.client.session.ui.attachment.attachmentParams +import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind +import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.selection.SessionContextMenu +import ai.kilocode.client.session.ui.selection.SessionHoverCopyOverlay +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS @@ -26,28 +45,50 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView +import ai.kilocode.client.settings.KiloSettingsConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers +import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.log.ChatLogSummary +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog +import com.intellij.ide.BrowserUtil +import com.intellij.ide.TextCopyProvider +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.ide.ui.LafManagerListener +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager -import com.intellij.openapi.Disposable import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project +import com.intellij.openapi.progress.runBlockingCancellable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry +import com.intellij.util.concurrency.annotations.RequiresEdt import java.util.function.Predicate import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.BorderLayout -import javax.swing.BoxLayout +import java.awt.event.HierarchyEvent +import java.net.URI +import java.nio.file.Path import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.UIManager /** * Top-level session UI composition root. @@ -64,14 +105,20 @@ class SessionUi( ref: SessionRef? = null, displayMs: Long = SessionController.DISPLAY_DELAY_MS, private val manager: SessionManager? = null, -) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget { + private val workspaces: KiloWorkspaceService = service(), + private val migration: MigrationUiController = service(), + private val timers: UiTimerSource = UiTimers, +) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget, UiDataProvider { companion object { private val LOG = KiloLog.create(SessionUi::class.java) + private const val HIDE_MS = 120 } private val project = project private val app = app + private val sessions = sessions + private val workspace = workspace private var opening = ref != null private var pending = false private var loaded: Boolean? = null @@ -82,7 +129,13 @@ class SessionUi( ?: EVENT_FLUSH_MS private val controller = SessionController( - this, ref, sessions, workspace, app, cs, comp = this, + parent = this, + ref = ref, + sessions = sessions, + workspace = workspace, + app = app, + cs = cs, + comp = this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), displayMs = displayMs, @@ -91,11 +144,18 @@ class SessionUi( afterUpdate = { if (!opening) scroll.followBottom(it) }, loaded = ::onSessionLoaded, openProfileAction = ::openProfileSettings, + timers = timers, ) private lateinit var root: SessionRootPanel private lateinit var account: SessionAccountOverlay + private lateinit var drop: SessionDropOverlay + private lateinit var overlay: SessionHoverCopyOverlay + private val hide = timers.timer(HIDE_MS, repeats = false) { + if (disposed || !this::drop.isInitialized) return@timer + drop.setActive(false) + } private lateinit var sessionContent: JPanel @@ -115,26 +175,46 @@ class SessionUi( private lateinit var connection: ConnectionPanel private lateinit var prompt: PromptPanel + private lateinit var completion: KiloPromptCompletionProvider private lateinit var load: LoadingPanel + private lateinit var migrationOverlay: MigrationOverlayPanel + private var empty: EmptySessionPanel? = null + private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() + private val selection = SessionSelection() + private val provider = object : TextCopyProvider() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun getTextLinesToCopy(): Collection? { + val text = selection.selectedText()?.takeIf { it.isNotEmpty() } ?: return null + return listOf(text) + } + } + private var editorTheme = style.editorScheme + private var colorTheme = UIManager.getLookAndFeel() + private var disposed = false init { buildUi() + Disposer.register(this, selection) scroll.show(body(controller.model.state)) bindUi() bindStyle() + bindMigration() applyStyle(style) onStateChanged(controller.model.state) loaded?.let(::finishOpen) } override fun addNotify() { + if (disposed) return super.addNotify() resumeOpen() } override fun doLayout() { super.doLayout() + if (disposed) return resumeOpen() } @@ -146,10 +226,58 @@ class SessionUi( internal fun currentStyle() = style - val defaultFocusedComponent: JComponent get() = prompt.defaultFocusedComponent + override fun uiDataSnapshot(sink: DataSink) { + sink[PlatformDataKeys.COPY_PROVIDER] = provider + } + + @RequiresEdt + internal fun activityKind(): SessionActivityKind? = when (val state = controller.model.state) { + is SessionState.Idle, + is SessionState.Loading, + is SessionState.Busy, + is SessionState.Retry, + is SessionState.Offline, + is SessionState.Error -> null + is SessionState.LoginRequired -> SessionActivityKind.LOGIN_REQUIRED + is SessionState.AwaitingPermission -> SessionActivityKind.PERMISSION + is SessionState.AwaitingQuestion -> + SessionActivityKind.PLAN.takeIf { state.question.items.any { it.planFollowup() } } ?: SessionActivityKind.QUESTION + } + + @RequiresEdt + internal fun title(): String? = controller.model.session?.title?.takeIf { it.isNotBlank() } + + @RequiresEdt + internal fun syncActivity() { + empty?.syncActivity() + } + + val defaultFocusedComponent: JComponent get() { + modalFocus?.invoke()?.let { return it } + return prompt.defaultFocusedComponent + } + + internal fun setModalContent(content: JComponent?, focus: (() -> JComponent)? = null) { + modalFocus = if (content == null) null else focus + root.setModalContent(content) + } private fun buildUi() { root = SessionRootPanel() + SessionContextMenu.install(root, this) + + migrationOverlay = MigrationOverlayPanel().apply { + onSkip = { migration.skip() } + onDone = { migration.finish() } + onContinueFromError = { migration.finish() } + onStart = { sel -> migration.start(sel) } + } + migrationOverlay.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), + ) account = SessionAccountOverlay( select = { org -> controller.selectOrganization(org) }, @@ -167,6 +295,11 @@ class SessionUi( ) } + overlay = SessionHoverCopyOverlay(root, this) + root.addOverlay(overlay) { pane, child -> + overlay.bounds(pane, child) + } + sessionContent = JPanel(BorderLayout()) blankBody = JPanel(BorderLayout()).apply { @@ -177,44 +310,96 @@ class SessionUi( progressBody = load question = QuestionView( project = project, - reply = { id, dto -> controller.replyQuestion(id, dto) }, + reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) }, reject = { id -> controller.rejectQuestion(id) }, - scroll = { scroll.followBottom(true) }, + follow = { scroll.following() }, + scroll = { scroll.followBottom(it) }, + selection = selection, ) permission = PermissionView( reply = { id, dto -> controller.replyPermission(id, dto) }, + selection = selection, + ) + login = LoginRequiredView( + openProfile = { controller.openProfile() }, + dismiss = { controller.dismissLoginRequired() }, + selection = selection, + ) + messageBody = SessionMessageListPanel( + controller.model, + this, + question, + permission, + login, + ::openFile, + ::openUrl, + selection, + ::openAttachment, + repo = workspace.directory, + resize = { anchor, fn -> scroll.preserve(anchor, fn) }, ) - login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) + scroll.onScroll = overlay::clear connection = ConnectionPanel(this, controller) + completion = KiloPromptCompletionProvider( + workspace = workspace, + service = workspaces, + actions = slashActions(), + mentions = mentionActions(), + scope = cs, + ) prompt = PromptPanel( project = project, - onSend = { text -> sendPrompt(text) }, + selection = selection, + onSend = { text, files -> sendPrompt(text, files) }, onAbort = { controller.abort() }, + onEnhance = controller::enhancePrompt, + onMentions = ::mentionParts, + completion = completion, ) + drop = SessionDropOverlay() + root.addOverlay(drop) { pane, _ -> + java.awt.Rectangle(0, 0, pane.width, pane.height) + } + root.overlay.setComponentZOrder(drop, 0) + prompt.onFileDrag = ::syncDrop + prompt.installFileDrop(root, "session-root") + // The visual overlay returns contains(false) so normal UI remains clickable. + // Registering it as a native DnD target makes IntelliJ resolve a null over-component. + sessionContent.add(header, BorderLayout.NORTH) sessionContent.add(scroll.component, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) - root.content.add(JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(connection) - add(prompt) - }, BorderLayout.SOUTH) + root.content.add(Stack.vertical().next(connection).next(prompt), BorderLayout.SOUTH) add(root, BorderLayout.CENTER) } private fun bindUi() { prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } - prompt.model.onSelect = { item -> controller.selectModel(item.provider, item.id) } + prompt.model.onSelect = { item -> + prompt.setAttachmentEnabled(item.attachment) + controller.selectModel(item.provider, item.id) + } prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) } prompt.onReset = { controller.clearModelOverride() } + prompt.onChange = { scroll.refresh() } + prompt.onAutoApproveToggle = { value -> + controller.setAutoApprove(value) + prompt.setAutoApprove(controller.autoApprove) + } + prompt.setAutoApprove(controller.autoApprove) prompt.model.favorites = { app.favorites.value } - prompt.model.onFavoriteToggle = { item -> app.toggleModelFavorite(item.provider, item.id) } + prompt.model.onFavoriteToggle = { item -> + Telemetry.send( + "Model Favorite Toggled", + mapOf("provider" to item.provider, "modelId" to item.id), + ) + app.toggleModelFavorite(item.provider, item.id) + } controller.addListener(this) { event -> when (event) { @@ -230,37 +415,56 @@ class SessionUi( }, m.agent) val items = m.models.map { ModelPicker.Item( - it.id, - it.display, - it.provider, - it.providerName, - it.recommendedIndex, - it.free, - it.variants, + id = it.id, + display = it.display, + provider = it.provider, + providerName = it.providerName, + recommendedIndex = it.recommendedIndex, + free = it.free, + byok = it.byok, + variants = it.variants, + attachment = it.attachment, + mayTrainOnYourPrompts = it.mayTrainOnYourPrompts, ) } val selected = m.model?.let { full -> items.firstOrNull { it.key == full }?.key } prompt.model.setItems(items, selected) + prompt.setAttachmentEnabled(items.firstOrNull { it.key == selected }?.attachment ?: true) prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant) prompt.setResetVisible(m.modelOverride) prompt.setReady(m.isReady()) + prompt.refreshHighlights() } is SessionControllerEvent.ViewChanged.ShowProgress -> { + empty = null scroll.show(progressBody) } is SessionControllerEvent.ViewChanged.ShowRecents -> { - val panel = EmptySessionPanel(this, controller, event.recents) { manager?.showHistory() } + val panel = EmptySessionPanel( + this, + controller, + event.recents, + history = { manager?.showHistory() }, + activity = { manager?.activity() ?: sessions.activity() }, + titles = { manager?.titles().orEmpty() }, + timers = timers, + ) + empty = panel scroll.show(panel.view) } is SessionControllerEvent.ViewChanged.ShowSession -> { + empty = null scroll.show(messageBody) } - is SessionControllerEvent.AppChanged, + is SessionControllerEvent.AppChanged -> { + prompt.setReady(controller.model.isReady()) + } + is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) } @@ -275,6 +479,8 @@ class SessionUi( when (event) { is SessionModelEvent.StateChanged -> onStateChanged(event.state) + is SessionModelEvent.SessionUpdated -> onSessionUpdated() + is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, @@ -288,7 +494,6 @@ class SessionUi( is SessionModelEvent.ContentRemoved, is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, - is SessionModelEvent.SessionUpdated, is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted, is SessionModelEvent.Cleared -> Unit @@ -296,15 +501,61 @@ class SessionUi( } } + @RequiresEdt + private fun syncDrop(value: Boolean) { + if (disposed) return + if (value) { + hide.stop() + drop.setActive(true) + return + } + hide.restart() + } + + private fun bindMigration() { + cs.launch { + migration.state.collect { state -> + withContext(Dispatchers.Main) { + applyMigrationState(state) + } + } + } + } + + @RequiresEdt + private fun applyMigrationState(state: MigrationUiState) { + when (state) { + is MigrationUiState.Hidden -> { + if (root.blocker.isVisible) LOG.info("Migration wizard: overlay hidden session=${id ?: cacheKey ?: "new"}") + setModalContent(null) + } + is MigrationUiState.Needed -> { + if (!root.blocker.isVisible) LOG.info("Migration wizard: overlay shown session=${id ?: cacheKey ?: "new"} phase=${state.phase}") + migrationOverlay.update(state) + setModalContent(migrationOverlay) { migrationOverlay.preferredFocusComponent() } + migrationOverlay.revalidate() + migrationOverlay.repaint() + } + } + } + private fun bindStyle() { + addHierarchyListener { event -> + if ((event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong()) == 0L) return@addHierarchyListener + if (!isShowing) return@addHierarchyListener + applyStyleIfThemeChanged() + } + val bus = ApplicationManager.getApplication().messageBus.connect(this) bus.subscribe(EditorColorsManager.TOPIC, EditorColorsListener { ApplicationManager.getApplication().invokeLater { + if (disposed) return@invokeLater applyStyle(SessionEditorStyle.current()) } }) bus.subscribe(LafManagerListener.TOPIC, LafManagerListener { ApplicationManager.getApplication().invokeLater { + if (disposed) return@invokeLater applyStyle(SessionEditorStyle.current()) } }) @@ -317,6 +568,7 @@ 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.Loading) return progressBody return blankBody @@ -343,30 +595,165 @@ class SessionUi( } } - private fun sendPrompt(text: String) { - if (text.isBlank()) return + private fun sendPrompt(text: String, files: List) { + if (text.isBlank() && files.isEmpty()) return + val parts = buildList { + text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } + addAll(files) + } LOG.debug { val agent = controller.model.agent ?: "none" val model = controller.model.model ?: "none" - "${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}" + "${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}" } - controller.prompt(text) prompt.clear() + val follow = scroll.atBottom() + val action = completion.clientAction(text) + if (action != null) { + action.action() + scroll.followBottom(follow) + return + } + val command = completion.serverCommand(text) + if (command != null) { + controller.command(command.first, command.second, files) + scroll.followBottom(follow) + return + } + controller.prompt(text, files) + scroll.followBottom(follow) + } + + private fun slashActions(): List { + val fns: Map Unit> = mapOf( + SlashAction.NEW to { manager?.newSession() }, + SlashAction.SESSIONS to { manager?.showHistory() }, + SlashAction.MODELS to { prompt.model.open() }, + SlashAction.AGENTS to { prompt.mode.open() }, + SlashAction.VARIANT to { prompt.reasoning.open() }, + SlashAction.COMPACT to { controller.compact() }, + SlashAction.SETTINGS to { openKiloSettings() }, + SlashAction.HELP to { BrowserUtil.browse("https://kilo.ai/docs") }, + ) + return SlashAction.ALL.map { spec -> bind(spec, fns.getValue(spec)) } + } + + private fun bind(spec: SlashAction.Spec, action: () -> Unit) = SlashAction( + spec.name, + KiloBundle.message(spec.descriptionKey), + spec.hints, + action, + ) + + private fun mentionActions(): List = MentionAction.ALL.map(::bind) + + private fun bind(spec: MentionAction.Spec) = MentionAction( + spec.name, + KiloBundle.message(spec.descriptionKey), + spec.hints, + spec.available, + ) + + private fun mentionParts(text: String): List = runBlockingCancellable { + val names = MentionAction.ALL.mapTo(mutableSetOf()) { it.name } + promptMentionParts( + text = text, + directory = workspace.directory, + reserved = names, + resolve = { path -> workspaces.files(workspace.directory, path).isNotEmpty() }, + gitChanges = { workspaces.gitChanges(workspace.directory) }, + ) + } + + private fun openFile(path: String) { + cs.launch { + workspaces.openPath(workspace.directory, path) + } + } + + private fun openUrl(url: String) { + BrowserUtil.browse(url) + } + + private fun openAttachment(messageId: String, item: FileAttachment) { + val url = item.url.takeIf { it.isNotBlank() } ?: run { + LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}") + return + } + LOG.info( + "kind=attachment-open session=${controller.id ?: "none"} message=$messageId part=${item.id} " + + "name=${attachmentName(item)} mime=${item.mime} url=${attachmentUrl(url)} dir=${workspace.directory}" + ) + if (isEmbeddedAttachment(url)) { + val id = controller.id ?: run { + LOG.info("kind=attachment-open skipped=true reason=missing-session message=$messageId part=${item.id} name=${attachmentName(item)}") + return + } + LOG.info("kind=attachment-open route=kilo-vfs session=$id message=$messageId part=${item.id} name=${attachmentName(item)}") + ensureAttachmentEditorKind() + project.service().open( + AttachmentEditorKind.ID, + attachmentParams(id, messageId, item, attachmentName(item), workspace.directory), + ) + return + } + val uri = runCatching { URI.create(url) }.getOrNull() ?: run { + LOG.info("kind=attachment-open skipped=true reason=invalid-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}") + return + } + if (uri.scheme == "file") { + val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run { + LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}") + return + } + LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path") + openFile(path) + return + } + LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}") + openUrl(url) + } + + private fun attachmentName(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } + ?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() } + ?: "attachment" + + private fun attachmentUrl(url: String): String { + val scheme = url.substringBefore(':', missingDelimiterValue = "none") + return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}" } private fun onStateChanged(state: SessionState) { + if (disposed) return prompt.setBusy(state.isBusy()) + load.setState(state) + scroll.setQuestionPending(questionPending(state)) + scroll.show(body(state)) + manager?.activityChanged() refresh() } + private fun onSessionUpdated() { + manager?.activityChanged() + } + private fun refresh() { + if (disposed) return scroll.refresh() root.revalidate() root.repaint() } override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return this.style = style + selection.applyStyle(style) + editorTheme = style.editorScheme + colorTheme = UIManager.getLookAndFeel() + background = style.editorBackground + root.content.background = style.editorBackground + sessionContent.background = style.editorBackground + blankBody.background = style.editorBackground load.applyStyle(style) header.applyStyle(style) prompt.applyStyle(style) @@ -374,6 +761,14 @@ class SessionUi( refresh() } + private fun applyStyleIfThemeChanged() { + if (disposed) return + val next = SessionEditorStyle.current() + val laf = UIManager.getLookAndFeel() + if (editorTheme === next.editorScheme && colorTheme == laf) return + applyStyle(next) + } + private fun openProfileSettings() { ShowSettingsUtil.getInstance().showSettingsDialog( project, @@ -384,7 +779,32 @@ class SessionUi( ) } - override fun dispose() {} + private fun openKiloSettings() { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == KiloSettingsConfigurable.ID + }, + { _: Configurable -> }, + ) + } + + override fun dispose() { + disposed = true + hide.stop() + modalFocus = null + empty = null + if (this::root.isInitialized) root.setModalContent(null) + removeAll() + } } private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + +private fun questionPending(state: SessionState): Boolean { + if (state !is SessionState.AwaitingQuestion) return false + return state.question.items.none { it.planFollowup() } +} + +private fun ai.kilocode.client.session.model.QuestionItem.planFollowup() = + questionKey == "plan.followup.question" || headerKey == "plan.followup.header" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index 52735be09d5..48ebfa2f5a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project @@ -19,6 +21,7 @@ class SessionUiFactory( workspace: Workspace, manager: SessionManager, ref: SessionRef? = null, + timers: UiTimerSource = UiTimers, ): SessionUi = SessionUi( project = project, workspace = workspace, @@ -27,6 +30,7 @@ class SessionUiFactory( cs = scope(), ref = ref, manager = manager, + timers = timers, ) fun scope(): CoroutineScope { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt index 623524e2c0a..960bf3b57a9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt @@ -1,18 +1,20 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager -import javax.swing.Timer internal class DelayedState( private val ms: Long, + private val timers: UiTimerSource = UiTimers, ) : Disposable { private val tick = when { ms <= 0 -> 1 ms > Int.MAX_VALUE -> Int.MAX_VALUE else -> ms.coerceAtMost(TICK_MS).toInt() } - private val timer = Timer(tick) { flush() } + private val timer = timers.timer(tick) { flush() } private val pending = mutableListOf>() @Volatile private var alive = true @@ -36,7 +38,7 @@ internal class DelayedState( } } - internal fun active() = timer.isRunning + internal fun active() = timer.isRunning() private fun apply(item: Pending) { if (!alive) return @@ -47,7 +49,7 @@ internal class DelayedState( private fun flush() { if (!alive) return - val now = System.currentTimeMillis() + val now = timers.now() for (item in pending.toList()) { if (item.due > now) continue apply(item) @@ -56,7 +58,7 @@ internal class DelayedState( } private fun due(): Long { - val now = System.currentTimeMillis() + val now = timers.now() return now + ms.coerceAtMost(Long.MAX_VALUE - now) } @@ -72,9 +74,6 @@ internal class DelayedState( override fun dispose() { alive = false cancel() - edt { - timer.stop() - } } private data class Pending( 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 a72f6fe55a3..53dfcde9504 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 @@ -17,8 +17,14 @@ import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.ToolCallRef +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -26,6 +32,8 @@ import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileStatusDto @@ -44,12 +52,16 @@ import com.intellij.openapi.application.ApplicationManager import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import java.awt.Component +import java.nio.file.Path /** * Session lifecycle orchestrator for a single session. @@ -79,14 +91,28 @@ class SessionController( private val afterUpdate: (Boolean) -> Unit = {}, private val loaded: (Boolean) -> Unit = {}, private val openProfileAction: () -> Unit = {}, + private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, + private val timers: UiTimerSource = UiTimers, ) : Disposable { private data class OrganizationTarget(val org: String?) + private data class Followup(val dir: String, val time: Long) + private data class Pref(val agent: String?, val model: String?, val variants: List, val variant: String?, val reset: Boolean) + private data class Dispatch( + val kind: String, + val source: String, + val text: String, + val props: Map, + val start: String, + val exists: Boolean, + ) companion object { private val LOG = KiloLog.create(SessionController::class.java) internal const val RECENT_LIMIT = 5 internal const val DISPLAY_DELAY_MS = 1_000L + private const val FOLLOWUP_TTL_MS = 30_000L + private const val FOLLOWUP_NEW_SESSION = "Start new session" } init { @@ -101,17 +127,23 @@ class SessionController( private val directory: String get() = workspace.directory private val updates = SessionUpdateQueue( parent, + cs, comp, flushMs, ::handle, condense, - ref != null + ref != null, + ::handleHidden, ) { sid ?: ref?.key ?: "pending" } private var disposed = false + private var enhancement = 0L + private val enhancements = mutableMapOf) -> Unit>() private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null + private var drainJob: Job? = null + private var creating: CompletableDeferred? = null private val childJobs: MutableMap = mutableMapOf() private val childIds: MutableSet = mutableSetOf() private var sessionLoadState: SessionLoadState = SessionLoadState.Idle @@ -119,15 +151,24 @@ class SessionController( private var viewState: SessionControllerEvent.ViewChanged? = null private var connectionState: SessionControllerEvent.ConnectionChanged? = null private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null - private val connectionDelay = DelayedState(displayMs) + private val connectionDelay = DelayedState(displayMs, timers) private var acctState: SessionControllerEvent.AccountOverlayChanged = SessionControllerEvent.AccountOverlayChanged.Hide private var acctAllowed = false private var lastProfile: ProfileDto? = null private var target: OrganizationTarget? = null private var loginRetry: PromptDto? = null + private var followup: Followup? = null + private var agentTime: Double? = null + private var prefModel: String? = null + private var prefAgent: String? = null + private var modelTime: Double? = null + private val snapshots = mutableMapOf() + + private data class PartKey(val messageId: String, val partId: String) val ready: Boolean get() = model.isReady() + val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession internal val id: String? get() = sid internal val refKey: String? get() = ref?.key @@ -170,34 +211,79 @@ class SessionController( updates.requestFlush(true) } - fun prompt(text: String) { + fun enhancePrompt(text: String, complete: (Result) -> Unit) { + assertEdt() + if (disposed) { + complete(Result.failure(CancellationException("Session controller disposed"))) + return + } + val id = ++enhancement + enhancements[id] = complete + capture("Prompt Enhance Clicked", mapOf("textLength" to bucket(text))) + cs.launch { + val result = try { + Result.success(sessions.enhancePrompt(directory, text)) + } catch (e: CancellationException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) + } + edt { + val callback = enhancements.remove(id) ?: return@edt + result.onSuccess { + capture("Prompt Enhanced", mapOf("textLength" to bucket(text))) + }.onFailure { e -> + if (e !is CancellationException) { + capture("Session Error", mapOf("context" to "enhance-prompt", "errorClass" to e::class.java.name)) + } + } + callback(result) + } + } + } + + fun prompt(text: String, files: List = emptyList()) { + assertEdt() + val start = sid ?: ref?.key ?: "pending" + val exists = sid != null + val dto = promptDto(text, files) + val props = promptProps(files) + LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" } + dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id -> + sessions.prompt(id, directory, dto) + } + } + + fun command(command: String, args: String, files: List = emptyList()) { assertEdt() val start = sid ?: ref?.key ?: "pending" - val dto = promptDto(text) - LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } + val exists = sid != null + val dto = promptDto("", files) + val props = promptProps(files) + LOG.debug { "${ChatLogSummary.sid(start)} kind=command command=$command args=${args.length} ${ChatLogSummary.dir(directory)}" } + dispatch(Dispatch("command", "command", args, props, start, exists)) { id -> + sessions.command(id, directory, command, args, dto) + } + } + + private fun dispatch(data: Dispatch, send: suspend (String) -> Unit) { + assertEdt() + capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf( + "source" to data.source, + "hasExistingSession" to data.exists.toString(), + "textLength" to bucket(data.text), + ) + data.props) showSession() + val pending = sid?.let { CompletableDeferred(it) } ?: session() cs.launch { try { - val id = sid ?: run { - val session = sessions.create(directory) - runEdt { - if (disposed) return@runEdt - ref = SessionRef.Local(session) - setRecentSessionsState(RecentsState.Idle) - updateModel { - model.setSession(session) - } - } - if (disposed) return@launch - val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" - LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") - subscribeEvents() - session.id - } - sessions.prompt(id, directory, dto) - LOG.debug { "${ChatLogSummary.sid(id)} kind=prompt dispatched=true" } + val id = pending.await() ?: return@launch + send(id) + capture("Conversation Message", sessionProps(id) + mapOf("source" to data.source, "hasExistingSession" to data.exists.toString()) + data.props) + LOG.debug { "${ChatLogSummary.sid(id)} kind=${data.kind} dispatched=true" } } catch (e: Exception) { - LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: start)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + capture("Session Error", sessionProps(sid ?: ref?.key ?: data.start) + mapOf("context" to data.kind, "errorClass" to e::class.java.name)) + LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: data.start)} kind=${data.kind} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { if (disposed) return@edt val msg = e.message ?: KiloBundle.message("session.error.prompt") @@ -209,20 +295,83 @@ class SessionController( } } + private fun session(): CompletableDeferred { + assertEdt() + val pending = creating + if (pending != null) return pending + val next = CompletableDeferred() + creating = next + cs.launch { + try { + next.complete(createSession()) + } catch (e: Exception) { + next.completeExceptionally(e) + } finally { + edt { + if (creating === next) creating = null + } + } + } + return next + } + + private suspend fun createSession(): String? { + val session = sessions.create(directory) + runEdt { + if (disposed) return@runEdt + ref = SessionRef.Local(session) + setRecentSessionsState(RecentsState.Idle) + updateModel { + model.setSession(session) + } + } + if (disposed) return null + val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" + LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") + capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains")) + runEdt { + if (disposed) return@runEdt + subscribeEvents() + } + return session.id + } + fun abort() { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=abort" } val id = sid ?: return + capture("Session Stop Clicked", sessionProps(id)) cs.launch { try { sessions.abort(id, directory) + capture("Session Stopped", sessionProps(id)) LOG.debug { "${ChatLogSummary.sid(id)} kind=abort ok=true" } } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "abort", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(id)} kind=abort dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } } + fun setAutoApprove(value: Boolean) { + assertEdt() + KiloPluginSettings.setAutoApprove(value) + capture("Auto Approve Toggled", mapOf("enabled" to value.toString())) + if (!value) { + drainJob?.cancel() + drainJob = null + return + } + val current = model.state + val skip = if (current is SessionState.AwaitingPermission) { + approve(current.permission) + setOf(current.permission.id) + } else { + emptySet() + } + drainAutoApprove(skip) + } + fun compact() { assertEdt() val id = sid ?: return @@ -234,8 +383,10 @@ class SessionController( cs.launch { try { sessions.compact(id, directory, sel) + capture("Context Condensed", sessionProps(id) + mapOf("provider" to sel.providerID, "modelId" to sel.modelID)) LOG.debug { "${ChatLogSummary.sid(id)} kind=compact ok=true" } } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "compact", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(id)} kind=compact dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { updateModel { @@ -251,6 +402,7 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" } + capture("Connection Retry Clicked", connectionProps()) setConnectionTargetState(SessionControllerEvent.ConnectionChanged.ShowConnecting) setVisibleConnectionState(SessionControllerEvent.ConnectionChanged.ShowConnecting) // App retry policy is backend-owned and may escalate from lightweight refresh to restart. @@ -271,6 +423,10 @@ class SessionController( fun selectAgent(name: String) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" } + agentTime = null + modelTime = null + prefModel = null + prefAgent = null cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) @@ -282,6 +438,7 @@ class SessionController( model.agent = name syncModelSelection() } + capture("Mode Switched", sessionProps() + mapOf("agent" to name)) } fun selectModel(provider: String, id: String) { @@ -290,9 +447,13 @@ class SessionController( val agent = model.agent ?: return val key = "$provider/$id" if (item(key) == null && model.workspace.providers != null) return + modelTime = null + prefModel = null + prefAgent = null app.selectModel(agent, provider, id) selectResolvedModel(key) model.modelOverride = model.defaultModel != model.model + capture("Model Selected", sessionProps() + mapOf("agent" to agent, "provider" to provider, "modelId" to id, "isOverride" to "true")) } fun clearModelOverride() { @@ -303,6 +464,7 @@ class SessionController( val auto = configModel(agent) ?: providerModel(agent) selectResolvedModel(auto) model.modelOverride = false + capture("Model Override Cleared", sessionProps() + mapOf("agent" to agent)) } fun selectVariant(value: String) { @@ -312,6 +474,7 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config variant=$key/$value" } app.selectVariant(key, value) model.variant = value + capture("Reasoning Variant Selected", sessionProps() + mapOf("model" to key, "variant" to value)) } // ------ permission / question resolution ------ @@ -319,13 +482,23 @@ class SessionController( fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } + val current = model.state as? SessionState.AwaitingPermission updatePermission(requestId, PermissionRequestState.RESPONDING) cs.launch { try { if (rules != null) sessions.savePermissionRules(requestId, directory, rules) sessions.replyPermission(requestId, directory, reply) + capture("Approval Answered", sessionProps() + mapOf( + "requestId" to requestId, + "tool" to (current?.permission?.name ?: "unknown"), + "reply" to reply.reply, + "hasRules" to (rules != null).toString(), + "hasDiffs" to (current?.permission?.meta?.fileDiffs?.isNotEmpty() == true).toString(), + "diffCount" to (current?.permission?.meta?.fileDiffs?.size ?: 0).toString(), + )) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId ok=true" } } catch (e: Exception) { + capture("Session Error", sessionProps() + mapOf("context" to "permission", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { updatePermission( @@ -338,6 +511,81 @@ class SessionController( } } + private fun approve(request: PermissionRequestDto) { + approve(request.id) { toPermission(request) } + } + + private fun approve(permission: Permission) { + approve(permission.id) { permission } + } + + private fun approve(id: String, restore: () -> Permission) { + assertEdt() + LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" } + cs.launch { + try { + if (!autoApprove) { + edt { + if (disposed) return@edt + model.setState(SessionState.AwaitingPermission(restore())) + } + return@launch + } + edt { + if (disposed) return@edt + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + sessions.replyPermission(id, directory, PermissionReplyDto("once")) + capture("Permission Auto Approved", sessionProps() + mapOf("tool" to restore().name, "source" to "single")) + LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id ok=true" } + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (disposed) return@edt + model.setState(SessionState.AwaitingPermission(restore().copy( + state = PermissionRequestState.ERROR, + message = e.message ?: KiloBundle.message("session.permission.error"), + ))) + } + } + } + } + + @RequiresEdt + private fun drainAutoApprove(skip: Set = emptySet()) { + assertEdt() + val id = sid ?: return + val ids = (childIds + id).toSet() + drainJob?.cancel() + drainJob = cs.launch { + try { + val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip } + val count = replyAll(permissions) + if (count == 0) return@launch + runEdt { + if (disposed) return@runEdt + val current = model.state + if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(id)} kind=permission-auto-drain dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + } + } + } + + private suspend fun replyAll(permissions: List): Int { + var count = 0 + for (request in permissions) { + if (!autoApprove) return count + sessions.replyPermission(request.id, directory, PermissionReplyDto("once")) + capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain")) + count++ + } + return count + } + private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { assertEdt() val current = model.state @@ -350,14 +598,28 @@ class SessionController( updateModel { model.setState(SessionState.AwaitingPermission(perm)) } } - fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + fun replyQuestion(requestId: String, answers: QuestionReplyDto, options: List> = answers.answers) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } + val current = model.state + followup = if (current is SessionState.AwaitingQuestion + && current.question.id == requestId + && options.any { labels -> labels.any { it.trim() == FOLLOWUP_NEW_SESSION } } + ) { + Followup(directory, System.currentTimeMillis()) + } else null + val follow = followup != null cs.launch { try { sessions.replyQuestion(requestId, directory, answers) + capture("Question Answered", sessionProps() + mapOf( + "requestId" to requestId, + "answerCount" to answers.answers.size.toString(), + "hasFollowupNewSession" to follow.toString(), + )) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { + edt { followup = null } LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } @@ -365,10 +627,12 @@ class SessionController( fun rejectQuestion(requestId: String) { assertEdt() + followup = null LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true" } cs.launch { try { sessions.rejectQuestion(requestId, directory) + capture("Question Rejected", sessionProps() + mapOf("requestId" to requestId)) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) @@ -445,14 +709,17 @@ class SessionController( .flatMap { provider -> provider.models.map { (id, info) -> ModelItem( - id, - info.name, - provider.id, - provider.name, - info.recommendedIndex, - info.free, - info.variants, - info.limit?.let { ModelLimitItem(it.context, it.input, it.output) }, + id = id, + display = info.name, + provider = provider.id, + providerName = provider.name, + recommendedIndex = info.recommendedIndex, + free = info.free, + byok = info.byok, + variants = info.variants, + limit = info.limit?.let { ModelLimitItem(it.context, it.input, it.output) }, + attachment = info.attachment, + mayTrainOnYourPrompts = info.mayTrainOnYourPrompts, ) } } @@ -488,7 +755,9 @@ class SessionController( if (disposed) return@runEdt if (sid != id) return@runEdt updateModel { + snapshots.clear() this@SessionController.model.loadHistory(items) + syncHistoryAgent(items) if (session != null) this@SessionController.model.setSession(session) } } @@ -535,7 +804,9 @@ class SessionController( ref = SessionRef.Local(session) setRecentSessionsState(RecentsState.Idle) updateModel { + snapshots.clear() this@SessionController.model.loadHistory(items) + syncHistoryAgent(items) this@SessionController.model.setSession(session) } } @@ -576,13 +847,12 @@ class SessionController( if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) } + @RequiresEdt private fun subscribeEvents() { + assertEdt() val id = sid ?: return LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" } - eventJob?.cancel() - childJobs.values.forEach { it.cancel() } - childJobs.clear() - childIds.clear() + cancelSubscriptions() eventJob = cs.launch { try { sessions.events(id, directory).collect { event -> @@ -599,7 +869,9 @@ class SessionController( } } + @RequiresEdt private fun subscribeChild(child: String) { + assertEdt() if (childJobs.containsKey(child)) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=true" } val job = cs.launch { @@ -616,17 +888,33 @@ class SessionController( childJobs[child] = job } + @RequiresEdt private fun trackChild(child: String) { + assertEdt() if (!childIds.add(child)) return subscribeChild(child) cs.launch { recoverChildPermissions(child) } } + @RequiresEdt + private fun cancelSubscriptions() { + assertEdt() + eventJob?.cancel() + eventJob = null + childJobs.values.forEach { it.cancel() } + childJobs.clear() + childIds.clear() + } + private suspend fun recoverChildPermissions(child: String) { try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } if (permissions.isEmpty()) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" } + if (autoApprove) { + replyAll(permissions) + return + } val last = toPermission(permissions.last()) runEdt { if (disposed) return@runEdt @@ -645,6 +933,17 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } val status = sessions.statuses.value[id] + if (permissions.isNotEmpty() && autoApprove) { + val count = replyAll(permissions) + if (count > 0) { + runEdt { + if (disposed) return@runEdt + if (sid != id) return@runEdt + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + return + } + } val branch = when { permissions.isNotEmpty() -> "permission" questions.isNotEmpty() -> "question" @@ -701,13 +1000,22 @@ class SessionController( when (event) { is ChatEventDto.MessageUpdated -> { val added = model.upsertMessage(event.info) + syncMessagePrefs(event.info) if (added) showSession() } is ChatEventDto.PartUpdated -> { partType = event.part.type tool = event.part.tool + val key = PartKey(event.part.messageID, event.part.id) + val prev = content(event.part.messageID, event.part.id) model.updateContent(event.part.messageID, event.part) + val next = content(event.part.messageID, event.part.id) + if (next != null && next != prev) { + snapshots[key] = next + } else { + snapshots.remove(key) + } if (model.state is SessionState.Busy) { model.setState(SessionState.Busy(status())) } @@ -716,11 +1024,13 @@ class SessionController( is ChatEventDto.PartDelta -> { if (event.field == "text") { - model.appendDelta(event.messageID, event.partID, event.delta) + val delta = glue(event.messageID, event.partID, event.delta) + if (delta.isNotEmpty()) model.appendDelta(event.messageID, event.partID, delta) } } is ChatEventDto.PartRemoved -> { + snapshots.remove(PartKey(event.messageID, event.partID)) model.removeContent(event.messageID, event.partID) } @@ -733,112 +1043,216 @@ class SessionController( is ChatEventDto.TurnClose -> { partType = null tool = null - // "completed" always transitions to idle. - // Other reasons: don't clobber a more specific terminal state (Error, - // AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close. + // Keep pending questions visible for follow-up flows that arrive just before close. val current = model.state + if (current is SessionState.AwaitingQuestion) return val clobberOk = event.reason == "completed" || current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline - if (clobberOk) model.setState(SessionState.Idle) + if (clobberOk) { + if (event.reason == "completed") capture("Task Completed", sessionProps(event.sessionID)) + model.setState(SessionState.Idle) + } } + is ChatEventDto.SessionCreated -> adoptFollowup(event.info) + is ChatEventDto.Error -> { - partType = null - tool = null - if (isPaidModelAuthRequired(event.error)) { - loginRetry = retryPrompt() - showSession() - model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) - } else { - val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - model.setState(SessionState.Error(msg, event.error?.type)) - } + capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) + error(event, true) } is ChatEventDto.MessageRemoved -> { + snapshots.keys.removeAll { it.messageId == event.messageID } model.removeMessage(event.messageID) } is ChatEventDto.PermissionAsked -> { - val perm = toPermission(event.request) - model.setState(SessionState.AwaitingPermission(perm)) + asked(event) } is ChatEventDto.PermissionReplied -> { - val current = model.state - if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) - } + replied(event) } is ChatEventDto.QuestionAsked -> { - model.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) + asked(event) } is ChatEventDto.QuestionReplied -> { - val current = model.state - if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) - } + replied(event) } is ChatEventDto.QuestionRejected -> { - val current = model.state - if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { - model.setState(SessionState.Idle) - } + rejected(event) } is ChatEventDto.SessionStatusChanged -> { - val state = when (event.status.type) { - "idle" -> { - val current = model.state - if (current is SessionState.LoginRequired) return - SessionState.Idle - } - "busy" -> { - val current = model.state - if (current is SessionState.Idle || current is SessionState.Error) - SessionState.Busy(KiloBundle.message("session.status.considering")) - else return // already in a more specific phase - } - "retry" -> SessionState.Retry( - message = event.status.message ?: "", - attempt = event.status.attempt ?: 0, - next = event.status.next ?: 0L, - ) - "offline" -> SessionState.Offline( - message = event.status.message ?: "", - requestId = event.status.requestID ?: "", - ) - else -> return - } - model.setState(state) + status(event.status) } is ChatEventDto.SessionUpdated -> model.setSession(event.session) is ChatEventDto.SessionIdle -> { - // Treat session.idle as an explicit signal to return to Idle. - // Only apply if we're not in a more specific non-terminal state. - val current = model.state - if (current !is SessionState.Error - && current !is SessionState.AwaitingPermission - && current !is SessionState.AwaitingQuestion - && current !is SessionState.LoginRequired - ) { - model.setState(SessionState.Idle) - } + idle() } - is ChatEventDto.SessionCompacted -> model.markCompacted() + is ChatEventDto.SessionCompacted -> { + capture("Context Condensed", sessionProps(event.sessionID)) + model.markCompacted() + } is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) } } + private fun glue(messageId: String, partId: String, delta: String): String { + if (delta.isEmpty()) return delta + val key = PartKey(messageId, partId) + val cur = snapshots[key] ?: return delta + val span = (minOf(cur.length, delta.length) downTo 1) + .firstOrNull { n -> cur.regionMatches(cur.length - n, delta, 0, n) } ?: 0 + if (span == delta.length) { + snapshots.remove(key) + return "" + } + snapshots.remove(key) + return delta.substring(span) + } + + private fun content(messageId: String, partId: String): String? = when (val content = model.content(messageId, partId)) { + is Text -> content.content.toString() + is Reasoning -> content.content.toString() + else -> null + } + + private fun handleHidden(event: ChatEventDto): Boolean = when (event) { + is ChatEventDto.Error, + is ChatEventDto.PermissionAsked, + is ChatEventDto.PermissionReplied, + is ChatEventDto.QuestionAsked, + is ChatEventDto.QuestionReplied, + is ChatEventDto.QuestionRejected, + is ChatEventDto.SessionStatusChanged, + is ChatEventDto.SessionUpdated, + is ChatEventDto.SessionIdle -> { + edt { + if (disposed) return@edt + updateModel { handleMetadata(event) } + } + true + } + else -> false + } + + private fun handleMetadata(event: ChatEventDto) { + LOG.debug { ChatLogSummary.event(event) } + when (event) { + is ChatEventDto.Error -> error(event, false) + is ChatEventDto.PermissionAsked -> asked(event) + is ChatEventDto.PermissionReplied -> replied(event) + is ChatEventDto.QuestionAsked -> asked(event) + is ChatEventDto.QuestionReplied -> replied(event) + is ChatEventDto.QuestionRejected -> rejected(event) + is ChatEventDto.SessionStatusChanged -> status(event.status) + is ChatEventDto.SessionUpdated -> model.setSession(event.session) + is ChatEventDto.SessionIdle -> idle() + else -> Unit + } + } + + private fun error(event: ChatEventDto.Error, reveal: Boolean) { + partType = null + tool = null + if (isPaidModelAuthRequired(event.error)) { + loginRetry = retryPrompt() + if (reveal) showSession() + capture("Account Overlay Shown", sessionProps(event.sessionID) + mapOf( + "surface" to "session", + "reason" to "paid_model_auth", + )) + model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) + return + } + val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, event.error?.type)) + } + + private fun asked(event: ChatEventDto.PermissionAsked) { + if (autoApprove) { + approve(event.request) + return + } + val perm = toPermission(event.request) + model.setState(SessionState.AwaitingPermission(perm)) + } + + private fun replied(event: ChatEventDto.PermissionReplied) { + val current = model.state + if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + + private fun asked(event: ChatEventDto.QuestionAsked) { + model.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) + } + + private fun replied(event: ChatEventDto.QuestionReplied) { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + + private fun rejected(event: ChatEventDto.QuestionRejected) { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { + model.setState(SessionState.Idle) + } + } + + private fun status(dto: SessionStatusDto) { + val state = when (dto.type) { + "idle" -> { + val current = model.state + if (current is SessionState.LoginRequired) return + SessionState.Idle + } + "busy" -> { + val current = model.state + if (current is SessionState.Idle || current is SessionState.Error) + SessionState.Busy(KiloBundle.message("session.status.considering")) + else return // already in a more specific phase + } + "retry" -> SessionState.Retry( + message = dto.message ?: "", + attempt = dto.attempt ?: 0, + next = dto.next ?: 0L, + ) + "offline" -> SessionState.Offline( + message = dto.message ?: "", + requestId = dto.requestID ?: "", + ) + else -> return + } + model.setState(state) + } + + private fun idle() { + // Treat session.idle as an explicit signal to return to Idle. + // Only apply if we're not in a more specific non-terminal state. + val current = model.state + if (current !is SessionState.Error + && current !is SessionState.AwaitingPermission + && current !is SessionState.AwaitingQuestion + && current !is SessionState.LoginRequired + ) { + model.setState(SessionState.Idle) + } + } + private fun retryPrompt(): PromptDto? { val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null return PromptDto( @@ -881,12 +1295,16 @@ class SessionController( } } - private fun promptDto(text: String): PromptDto { + private fun promptDto(text: String, files: List = emptyList()): PromptDto { val full = model.model val sel = full?.let(::parseModel) val variant = model.variant?.takeIf { it in model.variants } + val parts = buildList { + text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } + addAll(files) + } return PromptDto( - parts = listOf(PromptPartDto(type = "text", text = text)), + parts = parts, providerID = sel?.first, modelID = sel?.second, agent = model.agent, @@ -900,10 +1318,11 @@ class SessionController( val selected = selectedModel(agent, auto) model.defaultModel = auto selectResolvedModel(selected) - model.modelOverride = selected != auto + model.modelOverride = messageSelection(agent) == null && selected != auto } private fun selectedModel(agent: String, auto: String?): String? { + messageSelection(agent)?.let { return it.key } val saved = app.models.value.model[agent] val cfg = model.app.config if (cfg != null) return resolveModelSelection( @@ -949,12 +1368,84 @@ class SessionController( private fun item(key: String): ModelItem? = model.models.firstOrNull { it.key == key } + private fun messageSelection(agent: String): ModelSelectionDto? { + if (prefAgent != null && prefAgent != agent) return null + return valid(model.workspace.providers, prefModel?.let(::selection)) + } + private fun handle(events: List) { updateModel { for (event in events) handle(event) } } + private fun adoptFollowup(session: SessionDto) { + assertEdt() + val item = followup ?: return + if (System.currentTimeMillis() - item.time > FOLLOWUP_TTL_MS) { + followup = null + return + } + if (pathKey(item.dir) != pathKey(session.directory)) return + followup = null + open(SessionRef.Local(session)) + } + + private fun syncHistoryAgent(items: List) { + val before = model.prefs() + val agent = items + .map { it.info } + .filter { messageAgent(it) != null } + .maxByOrNull { it.time.created } + val msg = items + .map { it.info } + .filter { it.role == "user" && messageModel(it) != null } + .maxByOrNull { it.time.created } + agentTime = agent?.time?.created + modelTime = msg?.time?.created + messageAgent(agent)?.let { model.agent = it } + prefModel = messageModel(msg) + prefAgent = messageAgent(msg) ?: model.agent + syncModelSelection() + if (model.prefs() != before) fire(SessionControllerEvent.WorkspaceReady) + } + + private fun syncMessagePrefs(info: MessageDto) { + val before = model.prefs() + val agent = messageAgent(info) + val prior = agentTime + if (agent != null && (info.time.created >= (prior ?: Double.NEGATIVE_INFINITY))) { + agentTime = info.time.created + model.agent = agent + } + val key = messageModel(info) + val last = modelTime + if (info.role == "user" && key != null && (info.time.created >= (last ?: Double.NEGATIVE_INFINITY))) { + modelTime = info.time.created + prefModel = key + prefAgent = agent ?: model.agent + } + syncModelSelection() + if (model.prefs() != before) fire(SessionControllerEvent.WorkspaceReady) + } + + private fun messageAgent(info: MessageDto?): String? { + val agent = info?.agent?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (model.agents.isNotEmpty() && model.agents.none { it.name == agent }) return null + return agent + } + + private fun messageModel(info: MessageDto?): String? { + val msg = info ?: return null + val provider = msg.providerID?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val id = msg.modelID?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val key = "$provider/$id" + if (item(key) == null && model.workspace.providers != null) return null + return key + } + + private fun SessionModel.prefs(): Pref = Pref(agent, model, variants, variant, modelOverride) + private fun updateModel(block: () -> Unit) { assertEdt() if (disposed) return @@ -995,7 +1486,9 @@ class SessionController( cs.launch { try { app.setOrganization(org) + capture("Organization Switched", mapOf("target" to if (org == null) "personal" else "organization")) } catch (e: Exception) { + capture("Account Connect Failed", mapOf("stage" to "organization", "errorClass" to e::class.java.name)) LOG.warn("account switch failed org=$org message=${e.message}", e) edt { if (disposed) return@edt @@ -1008,13 +1501,71 @@ class SessionController( fun openProfile() { assertEdt() + capture("Profile Settings Opened", mapOf("surface" to "session_overlay")) openProfileAction() } + private fun capture(event: String, props: Map = emptyMap()) { + telemetry(event, props) + } + + private fun sessionProps(id: String? = sid): Map = buildMap { + id?.let { put("sessionId", it) } + if (ApplicationManager.getApplication().isDispatchThread) { + model.agent?.let { put("agent", it) } + model.model?.let { put("model", it) } + } + } + + private fun promptProps(files: List = emptyList()): Map = buildMap { + model.agent?.let { put("agent", it) } + model.model?.let { key -> + put("model", key) + parseModel(key)?.let { sel -> + put("provider", sel.first) + put("modelId", sel.second) + } + } + model.variant?.takeIf { it in model.variants }?.let { put("variant", it) } + if (files.isNotEmpty()) { + put("attachmentCount", files.size.toString()) + put("mediaAttachmentCount", files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }.toString()) + } + } + + private fun bucket(text: String): String = when (text.length) { + 0 -> "empty" + in 1..80 -> "short" + in 81..500 -> "medium" + else -> "long" + } + + private fun connectionProps(): Map = buildMap { + put("appStatus", model.app.status.name) + put("workspaceStatus", model.workspace.status.name) + model.app.error?.let { put("appError", bucketError(it)) } + model.workspace.error?.let { put("workspaceError", bucketError(it)) } + put("warningCount", model.app.warnings.size.toString()) + } + + private fun bucketError(text: String): String = when { + text.isBlank() -> "empty" + text.contains("timed out", ignoreCase = true) -> "timeout" + text.contains("not connected", ignoreCase = true) -> "not_connected" + text.contains("connection", ignoreCase = true) -> "connection" + text.contains("http", ignoreCase = true) -> "http" + else -> "other" + } + fun dismissLoginRequired() { assertEdt() + val active = model.state is SessionState.LoginRequired loginRetry = null - if (model.state is SessionState.LoginRequired) { + if (active) { + capture("Account Overlay Dismissed", sessionProps() + mapOf( + "surface" to "session", + "reason" to "paid_model_auth", + )) updateModel { model.setState(SessionState.Idle) } } } @@ -1264,13 +1815,19 @@ class SessionController( } override fun dispose() { - disposed = true - connectionDelay.dispose() - eventJob?.cancel() - childJobs.values.forEach { it.cancel() } - childJobs.clear() - childIds.clear() - cs.cancel() + runEdt { + if (disposed) return@runEdt + disposed = true + connectionDelay.dispose() + cancelSubscriptions() + drainJob?.cancel() + drainJob = null + val callbacks = enhancements.values.toList() + enhancements.clear() + cs.cancel() + val result = Result.failure(CancellationException("Session controller disposed")) + callbacks.forEach { it(result) } + } } override fun toString(): String { @@ -1345,6 +1902,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.PartRemoved -> event.sessionID == id is ChatEventDto.TurnOpen -> event.sessionID == id is ChatEventDto.TurnClose -> event.sessionID == id + is ChatEventDto.SessionCreated -> true is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id is ChatEventDto.MessageRemoved -> event.sessionID == id is ChatEventDto.PermissionAsked -> event.sessionID == id @@ -1413,6 +1971,12 @@ private fun parseModel(value: String): Pair? { return value.substring(0, slash) to value.substring(slash + 1) } +private fun pathKey(value: String): String = runCatching { + Path.of(value).normalize().toString().trimEnd('/', '\\') +}.getOrElse { + value.replace('\\', '/').trimEnd('/') +} + private sealed interface RecentsState { data object Idle : RecentsState data class Loading(val id: Any = Any()) : RecentsState @@ -1506,12 +2070,22 @@ private fun toQuestion(dto: QuestionRequestDto): Question { QuestionItem( question = it.question, header = it.header, - options = it.options.map { opt -> QuestionOption(opt.label, opt.description) }, + options = it.options.map { opt -> + QuestionOption( + label = opt.label, + description = opt.description, + labelKey = opt.labelKey, + descriptionKey = opt.descriptionKey, + mode = opt.mode, + ) + }, multiple = it.multiple, custom = it.custom, + questionKey = it.questionKey, + headerKey = it.headerKey, ) } - return Question(id = dto.id, items = items, tool = ref) + return Question(id = dto.id, items = items, tool = ref, blocking = dto.blocking) } private fun String.toDumpText(): String { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt index 7c35f9dde2e..ad27ed5e218 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt @@ -9,20 +9,24 @@ import com.intellij.openapi.util.Disposer import java.awt.Component import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch internal const val EVENT_FLUSH_MS = 150L internal class SessionUpdateQueue( parent: Disposable, + cs: CoroutineScope, private val comp: Component?, private val flushMs: Long = EVENT_FLUSH_MS, private val fire: (List) -> Unit, private val condense: Boolean = true, hold: Boolean, + private val hidden: (ChatEventDto) -> Boolean = { false }, private val sid: () -> String, ) : Disposable { companion object { @@ -33,8 +37,16 @@ internal class SessionUpdateQueue( private val condenser = SessionQueueCondenser() private val pending = mutableListOf() private val lock = Any() - private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor() - private val visible = AtomicBoolean(comp?.isShowing ?: true) + private val disposed = AtomicBoolean(false) + private val visible = AtomicBoolean(comp == null) + private val tick: Job? = if (flushMs == Long.MAX_VALUE) null else cs.launch { + while (isActive) { + delay(flushMs) + if (disposed.get()) continue + if (!visible.get()) continue + requestFlush(false, "tick") + } + } private val watch = comp?.let { HierarchyListener { event -> if (event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@HierarchyListener @@ -46,19 +58,18 @@ internal class SessionUpdateQueue( init { Disposer.register(parent, this) - if (comp != null && watch != null) comp.addHierarchyListener(watch) - exec?.scheduleAtFixedRate( - { - if (!visible.get()) return@scheduleAtFixedRate - requestFlush(false, "tick") - }, - flushMs, - flushMs, - TimeUnit.MILLISECONDS, - ) + if (comp != null && watch != null) edt { + visible.set(comp.isShowing) + comp.addHierarchyListener(watch) + } } fun enqueue(event: ChatEventDto) { + if (disposed.get()) return + if (!visible.get() && hidden(event)) { + LOG.debug { "${ChatLogSummary.sid(sid())} enqueue hidden=true visible=false" } + return + } val size = synchronized(lock) { pending.add(event) pending.size @@ -69,6 +80,7 @@ internal class SessionUpdateQueue( } fun holdFlush(hold: Boolean) { + if (disposed.get()) return edt { LOG.debug { "${ChatLogSummary.sid(sid())} hold=$hold" } this.hold = hold @@ -76,23 +88,25 @@ internal class SessionUpdateQueue( } fun requestFlush(forced: Boolean, source: String = "api") { + if (disposed.get()) return if (!forced && !visible.get()) return edt { flushNow(forced, source) } } override fun dispose() { + if (!disposed.compareAndSet(false, true)) return val size = synchronized(lock) { pending.size } LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=$size" } - exec?.shutdownNow() - if (comp != null && watch != null) comp.removeHierarchyListener(watch) - if (app.isDispatchThread) { + tick?.cancel() + val cleanup = { + if (comp != null && watch != null) comp.removeHierarchyListener(watch) synchronized(lock) { pending.clear() } - return } - app.invokeLater { synchronized(lock) { pending.clear() } } + if (app.isDispatchThread) cleanup() else app.invokeLater(cleanup) } private fun flushNow(forced: Boolean, source: String) { + if (disposed.get()) return if (hold) return if (!forced && !visible.get()) return val now = System.currentTimeMillis() @@ -111,6 +125,7 @@ internal class SessionUpdateQueue( } private fun onVisible(show: Boolean) { + if (disposed.get()) return val prev = visible.getAndSet(show) if (prev == show) return LOG.debug { "${ChatLogSummary.sid(sid())} visible=$show" } @@ -119,10 +134,14 @@ internal class SessionUpdateQueue( } private fun edt(block: () -> Unit) { + if (disposed.get()) return if (app.isDispatchThread) { block() return } - app.invokeLater(block) + app.invokeLater { + if (disposed.get()) return@invokeLater + block() + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt new file mode 100644 index 00000000000..e916578638b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt @@ -0,0 +1,13 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.session.SessionActivityKind + +internal data class HistoryActivitySnapshot( + val activity: Map = emptyMap(), + val titles: Map = emptyMap(), +) { + fun changed(next: HistoryActivitySnapshot): Set = + (activity.keys + next.activity.keys + titles.keys + next.titles.keys).filterTo(mutableSetOf()) { + activity[it] != next.activity[it] || titles[it] != next.titles[it] + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt index 81a386fda99..dabdf4067d4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.application.ApplicationManager @@ -21,6 +22,7 @@ class HistoryController( open: (SessionRef) -> Unit = {}, private val deleted: (String) -> Unit = {}, private val gitUrlProvider: () -> String? = { resolveGitRemoteUrl(workspace.directory) }, + private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, ) { companion object { const val CLOUD_LIMIT = 50 @@ -60,6 +62,8 @@ class HistoryController( reloadCloud() } + internal fun activity() = sessions.activity() + fun reloadLocal() { edt { local.start() } cs.launch { @@ -68,6 +72,7 @@ class HistoryController( val items = result.sessions.map(::localItem) edt { local.replace(items) } } catch (e: Exception) { + capture("History Load Failed", mapOf("source" to "local", "errorClass" to e::class.java.name)) edt { local.fail(e.message ?: KiloBundle.message("history.error.local")) } } } @@ -79,14 +84,35 @@ class HistoryController( fun loadMoreCloud() { if (cloud.cursor == null || cloud.loading) return + capture("History Cloud Load More Clicked") loadCloud(reset = false) } fun applyRepoOnly(value: Boolean) { updateRepoOnly(value) + capture("History Cloud Repo Filter Toggled", mapOf( + "enabled" to value.toString(), + "hasGitUrl" to (gitUrl != null).toString(), + )) edt { reloadCloud() } } + fun requestDelete(count: Int) { + capture("History Session Delete Clicked", mapOf("count" to count.toString())) + } + + fun cancelDelete(count: Int) { + capture("History Session Delete Cancelled", mapOf("count" to count.toString())) + } + + fun requestRename() { + capture("History Session Rename Clicked") + } + + fun cancelRename(reason: String) { + capture("History Session Rename Cancelled", mapOf("reason" to reason)) + } + fun delete(item: LocalHistoryItem) { edt { if (item.id in deleting) return@edt @@ -101,6 +127,7 @@ class HistoryController( edt { deleting.remove(item.id) local.remove(item.id) + capture("History Session Deleted", mapOf("sessionId" to item.id)) deleted(item.id) } } catch (e: Exception) { @@ -118,7 +145,10 @@ class HistoryController( cs.launch { try { val updated = sessions.renameSession(item.id, dir, title) - edt { local.update(LocalHistoryItem(updated)) } + edt { + local.update(LocalHistoryItem(updated)) + capture("History Session Renamed", mapOf("sessionId" to item.id)) + } } catch (e: Exception) { edt { local.fail(e.message ?: KiloBundle.message("history.error.local.rename")) } } @@ -128,10 +158,12 @@ class HistoryController( fun deleting(item: LocalHistoryItem): Boolean = item.id in deleting fun open(item: LocalHistoryItem) { + capture("History Session Opened", mapOf("source" to "local")) edt { opener(SessionRef.Local(item.session)) } } fun open(item: CloudHistoryItem) { + capture("History Session Opened", mapOf("source" to "cloud")) edt { opener(SessionRef.Cloud(item.session)) } } @@ -144,16 +176,27 @@ class HistoryController( try { val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, filter) val items = result.sessions.map(::cloudItem) + capture("History Cloud Page Loaded", mapOf( + "reset" to reset.toString(), + "count" to items.size.toString(), + "hasNextCursor" to (result.nextCursor != null).toString(), + "repoOnly" to (filter != null).toString(), + )) edt { if (reset) cloud.replace(items, result.nextCursor) else cloud.append(items, result.nextCursor) } } catch (e: Exception) { + capture("History Load Failed", mapOf("source" to "cloud", "errorClass" to e::class.java.name)) edt { cloud.fail(e.message ?: KiloBundle.message("history.error.cloud")) } } } } + private fun capture(event: String, props: Map = emptyMap()) { + telemetry(event, props) + } + /** * Resolves [gitUrl] on first cloud load. Subsequent calls return the cached value. * Also enables [repoOnly] by default when a URL is found the first time. 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 4aae232943a..794c80fcba5 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 @@ -1,6 +1,8 @@ package ai.kilocode.client.session.history import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.GroupHeaderSeparator @@ -11,8 +13,10 @@ 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 import javax.swing.Icon import javax.swing.JList import javax.swing.JPanel @@ -24,6 +28,8 @@ private const val DELETE_AREA_WIDTH = 32 internal open class HistoryRenderer( private val model: HistoryModel, private val deletable: Boolean, + private val activity: () -> Map, + private val titles: () -> Map = { emptyMap() }, ) : JPanel(BorderLayout()), ListCellRenderer { companion object { private val icon: Icon = AllIcons.Actions.GC @@ -55,14 +61,19 @@ internal open class HistoryRenderer( add(sep, BorderLayout.NORTH) } private val title = SimpleColoredComponent() + private val badge = BadgeLabel() private val time = JBLabel() private val del = JBLabel().apply { horizontalAlignment = SwingConstants.CENTER 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 main = JPanel(BorderLayout()).apply { - add(title, BorderLayout.CENTER) + add(head, BorderLayout.CENTER) add(time, BorderLayout.EAST) } private val row = JPanel(BorderLayout()).apply { @@ -70,12 +81,13 @@ internal open class HistoryRenderer( if (deletable) add(del, BorderLayout.EAST) } private val wrap = PickerRow() + private var text = "" init { isOpaque = true top.isOpaque = true row.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) - UiStyle.Components.transparent(row, main, title, time, del) + UiStyle.Components.transparent(row, main, head, title, badge, time, del) wrap.setContent(row) add(top, BorderLayout.NORTH) add(wrap, BorderLayout.CENTER) @@ -100,19 +112,50 @@ internal open class HistoryRenderer( top.isVisible = sep.caption != null title.clear() + text = value?.let { titles()[it.id] ?: title(it) }.orEmpty() title.append( - value?.let(::title).orEmpty(), + text, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg), ) time.text = value?.let(HistoryTime::relative).orEmpty() time.foreground = weak + badge.setKind(value?.id?.let(activity()::get)) if (deletable) del.icon = if (selected) icon else empty top.invalidate() return this } + + internal fun runningVisible() = badge.isVisible + + internal fun badgeText() = badge.kind?.label() + + internal fun titleText() = text + + private class BadgeLabel : JBLabel() { + var kind: SessionActivityKind? = null + private set + + init { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + alignmentY = Component.CENTER_ALIGNMENT + } + + fun setKind(value: SessionActivityKind?) { + kind = value + isVisible = value != null + icon = value?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } + } + } } -internal class LocalHistoryRenderer(model: HistoryModel) : HistoryRenderer(model, deletable = true) +internal class LocalHistoryRenderer( + model: HistoryModel, + activity: () -> Map = { emptyMap() }, + titles: () -> Map = { emptyMap() }, +) : HistoryRenderer(model, deletable = true, activity, titles) -internal class CloudHistoryRenderer(model: HistoryModel) : HistoryRenderer(model, deletable = false) +internal class CloudHistoryRenderer( + model: HistoryModel, + activity: () -> Map = { emptyMap() }, +) : HistoryRenderer(model, deletable = false, activity) 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 7945be7ee2b..3d969a291e7 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 @@ -7,6 +7,8 @@ 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.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import com.intellij.icons.AllIcons import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager @@ -16,6 +18,7 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.ui.DocumentAdapter import com.intellij.ui.PopupHandler import com.intellij.ui.SearchTextField @@ -57,9 +60,11 @@ class HistoryPanel( private val controller: HistoryController, private val nav: () -> Unit = {}, private val manager: SessionManager? = null, + private val timers: UiTimerSource = UiTimers, ) : BorderLayoutPanel(), Disposable, DataProvider { private val localSearch = search(controller.local) private val cloudSearch = search(controller.cloud) + private var snapshot = HistoryActivitySnapshot() private val localList = localList() private val cloudList = cloudList() private val more = LoadMoreButton() @@ -80,6 +85,7 @@ class HistoryPanel( .setText(KiloBundle.message("history.tab.cloud")) .setForeSideComponent(back()) private var stale = false + private val timer = timers.timer(ACTIVITY_MS) { syncActivity() } private val tabs: JBTabs = JBTabsFactory.createTabs(null, this).apply { presentation.setSingleRow(true) presentation.setTabsPosition(JBTabsPosition.top) @@ -105,11 +111,14 @@ class HistoryPanel( } addHierarchyListener { e -> if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener - if (isShowing && stale) { - refresh() + if (isShowing) { + syncActivity() + timer.start() + if (stale) refresh() return@addHierarchyListener } - if (!isShowing) stale = true + timer.stop() + stale = true } body.add(load, CARD_LOAD) body.add(tabs.component, CARD_TABS) @@ -215,7 +224,7 @@ class HistoryPanel( private fun localList() = JBList(controller.local).apply { selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION isFocusable = true - cellRenderer = LocalHistoryRenderer(controller.local) + cellRenderer = LocalHistoryRenderer(controller.local, { snapshot.activity }, { snapshot.titles }) cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { @@ -243,7 +252,7 @@ class HistoryPanel( private fun cloudList() = JBList(controller.cloud).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION isFocusable = true - cellRenderer = CloudHistoryRenderer(controller.cloud) + cellRenderer = CloudHistoryRenderer(controller.cloud) { snapshot.activity } cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { @@ -284,6 +293,26 @@ class HistoryPanel( repaint() } + @RequiresEdt + internal fun syncActivity() { + val next = HistoryActivitySnapshot( + activity = manager?.activity() ?: controller.activity(), + titles = manager?.titles().orEmpty(), + ) + val changed = snapshot.changed(next) + snapshot = next + repaintRows(localList, controller.local, changed) + repaintRows(cloudList, controller.cloud, changed) + } + + private fun repaintRows(list: JBList, model: HistoryModel, ids: Set) { + if (ids.isEmpty()) return + model.visibleItems.forEachIndexed { index, item -> + if (item.id !in ids) return@forEachIndexed + list.getCellBounds(index, index)?.let(list::repaint) + } + } + private fun loading(): Boolean { if (controller.local.loaded || controller.cloud.loaded) return false return controller.local.loading || controller.cloud.loading @@ -418,6 +447,34 @@ class HistoryPanel( return items.indices.mapNotNull { HistoryRenderer.section(items, it) } } + internal fun runningBadgeVisible(index: Int): Boolean { + return badgeText(index) != null + } + + internal fun badgeText(index: Int): String? { + val list = activeList() + val item = list.model.getElementAt(index) ?: return null + @Suppress("UNCHECKED_CAST") + val renderer = list.cellRenderer as javax.swing.ListCellRenderer + @Suppress("UNCHECKED_CAST") + val typed = list as JList + val view = renderer.getListCellRendererComponent(typed, item, index, false, false) + if (view !is HistoryRenderer<*>) return null + return view.badgeText() + } + + internal fun titleText(index: Int): String? { + val list = activeList() + val item = list.model.getElementAt(index) ?: return null + @Suppress("UNCHECKED_CAST") + val renderer = list.cellRenderer as javax.swing.ListCellRenderer + @Suppress("UNCHECKED_CAST") + val typed = list as JList + val view = renderer.getListCellRendererComponent(typed, item, index, false, false) + if (view !is HistoryRenderer<*>) return null + return view.titleText() + } + internal fun repoOnlyVisible() = repoOnly.isVisible internal fun repoOnlySelected() = repoOnly.isSelected @@ -445,6 +502,7 @@ class HistoryPanel( } override fun dispose() { + timer.stop() controller.onRepoOnlyChanged = null } @@ -495,5 +553,6 @@ class HistoryPanel( private companion object { const val CARD_LOAD = "load" const val CARD_TABS = "tabs" + const val ACTIVITY_MS = 3_000 } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index 7c9a610ed23..e2ab924b547 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -1,8 +1,10 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.PartSourceDto import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto data class SessionHeaderSnapshot( @@ -65,6 +67,14 @@ class Reasoning(id: String) : Content(id) { var done: Boolean = true } +/** User-provided file or image attachment. */ +class FileAttachment(id: String) : Content(id) { + var mime: String = "application/octet-stream" + var url: String = "" + var filename: String? = null + var source: PartSourceDto? = null +} + /** Tool invocation with lifecycle state. */ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { var state: ToolExecState = ToolExecState.PENDING @@ -75,6 +85,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { var output: String? = null var error: String? = null var time: PartTimeDto? = null + var todos: List = emptyList() + var todoView: TodoViewDto? = null } /** Context compaction marker. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt new file mode 100644 index 00000000000..ffb642a7e96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt @@ -0,0 +1,101 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.PromptPartDto +import java.awt.Image +import java.awt.image.BufferedImage +import java.awt.image.MultiResolutionImage +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.UUID +import javax.imageio.ImageIO +import java.nio.file.Path +import kotlin.io.path.name +import kotlin.io.path.readBytes + +data class PromptAttachment( + val id: String, + val name: String, + val mime: String, + val url: String, + val path: Path? = null, +) { + fun part() = PromptPartDto( + type = "file", + mime = mime, + url = path?.let { data(it, mime) } ?: url, + filename = name, + ) +} + +object PromptAttachmentExtractor { + private const val MAX_BYTES = 10 * 1024 * 1024 + + fun files(files: List): List = files + .filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES } + .map { file -> + val path = file.toPath() + val mime = mime(file) + if (!media(mime)) return@map null + PromptAttachment( + id = path.toAbsolutePath().normalize().toString(), + name = path.fileName?.toString() ?: path.name, + mime = mime, + url = path.toUri().toString(), + path = path, + ) + } + .filterNotNull() + + fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain" + + fun image(raw: Any): PromptAttachment? { + val image = when (raw) { + is MultiResolutionImage -> raw.resolutionVariants.firstOrNull()?.buffered() + is BufferedImage -> raw + is Image -> raw.buffered() + else -> null + } ?: return null + val out = ByteArrayOutputStream() + ImageIO.write(image, "png", out) + val id = UUID.randomUUID().toString() + val data = Base64.getEncoder().encodeToString(out.toByteArray()) + return PromptAttachment( + id = "clipboard-image:$id", + name = "pasted-image-$id.png", + mime = "image/png", + url = "data:image/png;base64,$data", + ) + } + + private fun mime(file: java.io.File): String { + if (file.isDirectory) return "application/x-directory" + return when (file.extension.lowercase()) { + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "webp" -> "image/webp" + "bmp" -> "image/bmp" + "svg" -> "image/svg+xml" + "pdf" -> "application/pdf" + "txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp" -> "text/plain" + else -> "application/octet-stream" + } + } + + private fun Image.buffered(): BufferedImage? { + if (this is BufferedImage) return this + val width = getWidth(null) + val height = getHeight(null) + if (width <= 0 || height <= 0) return null + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + g.drawImage(this, 0, 0, null) + } finally { + g.dispose() + } + return image + } +} + +private fun data(path: Path, mime: String) = "data:$mime;base64,${Base64.getEncoder().encodeToString(path.readBytes())}" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt index 70ea656fdbc..aba1b54b6b4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt @@ -7,6 +7,7 @@ data class Question( val items: List, val tool: ToolCallRef? = null, val state: QuestionRequestState = QuestionRequestState.PENDING, + val blocking: Boolean = false, ) data class QuestionItem( @@ -15,9 +16,14 @@ data class QuestionItem( val options: List, val multiple: Boolean, val custom: Boolean, + val questionKey: String? = null, + val headerKey: String? = null, ) data class QuestionOption( val label: String, val description: String, + val labelKey: String? = null, + val descriptionKey: String? = null, + val mode: String? = null, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 47bf784e457..87ae423c204 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import kotlin.math.roundToInt /** @@ -35,11 +36,12 @@ class SessionModel { companion object { /** Part types that are internal server markers and must never be stored or rendered. */ - val SILENT_PART_TYPES = setOf("step-start") + val SILENT_PART_TYPES = setOf("step-start", "patch") } private val entries = LinkedHashMap() private val turnEntries = LinkedHashMap() + private val hiddenText = mutableSetOf>() var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) var version: String? = null @@ -75,29 +77,38 @@ class SessionModel { private val listeners = mutableListOf() + @RequiresEdt fun addListener(parent: Disposable, listener: SessionModelEvent.Listener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } + @RequiresEdt fun messages(): Collection = entries.values + @RequiresEdt fun message(id: String): Message? = entries[id] + @RequiresEdt fun content(messageId: String, contentId: String): Content? = entries[messageId]?.parts?.get(contentId) + @RequiresEdt fun turns(): Collection = turnEntries.values + @RequiresEdt fun turn(id: String): Turn? = turnEntries[id] + @RequiresEdt fun isEmpty(): Boolean = entries.isEmpty() + @RequiresEdt fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY /** * Add a message if it doesn't exist, or update its [MessageDto] info if it does. * Returns true when the message was newly added (caller can decide to show messages). */ + @RequiresEdt fun upsertMessage(dto: MessageDto): Boolean { val existing = entries[dto.id] if (existing != null) { @@ -116,6 +127,7 @@ class SessionModel { } /** @deprecated Use [upsertMessage] instead. Kept for incremental migration. */ + @RequiresEdt fun addMessage(dto: MessageDto): Message? { if (entries.containsKey(dto.id)) return null val msg = Message(dto) @@ -126,24 +138,43 @@ class SessionModel { return msg } + @RequiresEdt fun removeMessage(id: String) { if (entries.remove(id) == null) return + hiddenText.removeAll { it.first == id } fire(SessionModelEvent.MessageRemoved(id)) regroup() updateHeader() } + @RequiresEdt fun removeContent(messageId: String, contentId: String) { + hiddenText.remove(messageId to contentId) val msg = entries[messageId] ?: return if (msg.parts.remove(contentId) == null) return fire(SessionModelEvent.ContentRemoved(messageId, contentId)) updateHeader() } + @RequiresEdt fun updateContent(messageId: String, dto: PartDto) { if (dto.type in SILENT_PART_TYPES) return val msg = entries[messageId] ?: return + val key = messageId to dto.id + if (hiddenSynthetic(msg, dto)) { + hiddenText.add(key) + if (msg.parts.remove(dto.id) != null) { + fire(SessionModelEvent.ContentRemoved(messageId, dto.id)) + updateHeader() + } + return + } + hiddenText.remove(key) val existing = msg.parts[dto.id] + if (empty(dto)) { + if (existing is Text) removeContent(messageId, dto.id) + return + } if (existing != null) { updateExisting(messageId, existing, dto) return @@ -154,9 +185,12 @@ class SessionModel { updateHeader() } + @RequiresEdt fun appendDelta(messageId: String, contentId: String, delta: String) { val msg = entries[messageId] ?: return + if (hiddenText.contains(messageId to contentId)) return val existing = msg.parts[contentId] + val created = existing == null if (existing != null) { val buf = when (existing) { is Text -> existing.content @@ -170,10 +204,11 @@ class SessionModel { msg.parts[contentId] = content fire(SessionModelEvent.ContentAdded(messageId, content)) } - fire(SessionModelEvent.ContentDelta(messageId, contentId, delta)) + fire(SessionModelEvent.ContentDelta(messageId, contentId, delta, created)) updateHeader() } + @RequiresEdt fun setState(state: SessionState) { if (this.state == state) return this.state = state @@ -181,6 +216,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun setSession(session: SessionDto) { if (this.session == session) return this.session = session @@ -188,29 +224,35 @@ class SessionModel { updateHeader() } + @RequiresEdt fun setDiff(diff: List) { this.diff = diff fire(SessionModelEvent.DiffUpdated(diff)) } + @RequiresEdt fun setTodos(todos: List) { this.todos = todos fire(SessionModelEvent.TodosUpdated(todos)) updateHeader() } + @RequiresEdt fun markCompacted() { compactionCount++ fire(SessionModelEvent.Compacted(compactionCount)) updateHeader() } + @RequiresEdt fun refreshHeader() { updateHeader() } + @RequiresEdt fun loadHistory(history: List) { entries.clear() + hiddenText.clear() session = null state = SessionState.Idle diff = emptyList() @@ -220,6 +262,11 @@ class SessionModel { val item = Message(msg.info) for (part in msg.parts) { if (part.type in SILENT_PART_TYPES) continue + if (hiddenSynthetic(item, part)) { + hiddenText.add(msg.info.id to part.id) + continue + } + if (empty(part)) continue val content = fromDto(part, part.text) item.parts[content.id] = content } @@ -230,9 +277,11 @@ class SessionModel { updateHeader() } + @RequiresEdt fun clear() { entries.clear() turnEntries.clear() + hiddenText.clear() session = null state = SessionState.Idle diff = emptyList() @@ -349,6 +398,12 @@ class SessionModel { existing.content.append(text) existing.done = dto.time?.end != null || dto.time == null } + is FileAttachment -> { + existing.mime = dto.mime ?: "application/octet-stream" + existing.url = dto.url ?: "" + existing.filename = dto.filename + existing.source = dto.source + } is Tool -> { existing.kind = toolKind(dto.tool) existing.state = parseToolState(dto.state) @@ -359,6 +414,8 @@ class SessionModel { existing.output = dto.output existing.error = dto.error existing.time = dto.time + existing.todos = dto.todos + existing.todoView = dto.todoView } is Compaction -> return is StepFinish -> { @@ -372,6 +429,11 @@ class SessionModel { updateHeader() } + private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true + + private fun hiddenSynthetic(msg: Message, dto: PartDto) = + msg.info.role == "user" && dto.type == "text" && dto.synthetic == true + private fun fromDto(dto: PartDto, text: CharSequence? = null): Content { val content = text ?: dto.text return when (dto.type) { @@ -382,6 +444,12 @@ class SessionModel { if (content != null && content.isNotEmpty()) this.content.append(content) done = dto.time?.end != null || dto.time == null } + "file" -> FileAttachment(dto.id).apply { + mime = dto.mime ?: "application/octet-stream" + url = dto.url ?: "" + filename = dto.filename + source = dto.source + } "tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply { state = parseToolState(dto.state) callId = dto.callID @@ -391,6 +459,8 @@ class SessionModel { output = dto.output error = dto.error time = dto.time + todos = dto.todos + todoView = dto.todoView } "compaction" -> Compaction(dto.id) "step-finish" -> StepFinish(dto.id).apply { @@ -538,8 +608,11 @@ data class ModelItem( val providerName: String, val recommendedIndex: Double?, val free: Boolean, + val byok: Boolean = false, val variants: List, val limit: ModelLimitItem?, + val attachment: Boolean = false, + val mayTrainOnYourPrompts: Boolean = false, ) { val key: String get() = "$provider/$id" } @@ -574,6 +647,7 @@ private fun parseModelKey(value: String): Pair? { private fun Content.timelineTitle(): String = when (this) { is Text -> "Text" is Reasoning -> "Reasoning" + is FileAttachment -> filename?.takeIf { it.isNotBlank() } ?: "File" is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name is Compaction -> "Compaction" is StepFinish -> "Step finish" @@ -607,6 +681,7 @@ private fun tail(path: String): String { private fun Content.weight(): Int = when (this) { is Text -> content.length / 200 + 1 is Reasoning -> content.length / 200 + 1 + is FileAttachment -> 1 is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1 is Compaction -> 2 is StepFinish -> tokens?.stepWeight() ?: 1 @@ -633,6 +708,7 @@ private fun renderMessage(msg: Message): List { out.add("reasoning#${part.id} done=${part.done}:") out.addAll(renderText(part.content)) } + is FileAttachment -> out.add("file#${part.id} ${part.mime} ${part.filename ?: tail(part.url)}") is Tool -> out.add(renderTool(part)) is Compaction -> out.add("compaction#${part.id}") is StepFinish -> out.add("step-finish#${part.id}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 83b79b45b5d..1ca9fe5a342 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -36,7 +36,12 @@ sealed class SessionModelEvent { data class ContentRemoved(val messageId: String, val contentId: String) : SessionModelEvent() { override fun toString() = "ContentRemoved $messageId/$contentId" } - data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() { + data class ContentDelta( + val messageId: String, + val contentId: String, + val delta: String, + val created: Boolean = false, + ) : SessionModelEvent() { override fun toString() = "ContentDelta $messageId/$contentId" } data class StateChanged(val state: SessionState) : SessionModelEvent() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt index 323e32f6db3..8d64f6fa8ff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/ScrollButtonIcon.kt @@ -1,17 +1,14 @@ package ai.kilocode.client.session.scroll -import ai.kilocode.client.ui.colorizeIfPossible import com.intellij.openapi.util.IconLoader -import com.intellij.util.ui.JBUI import javax.swing.Icon internal object ScrollButtonIcon { - private val icon = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java) + private val bottom: Icon = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java) + private val prompt: Icon = IconLoader.getIcon("/icons/scroll-question.svg", ScrollButtonIcon::class.java) - fun create(): Icon = icon.colorizeIfPossible( - fillColor = JBUI.CurrentTheme.Button.defaultButtonColorStart(), - borderColor = JBUI.CurrentTheme.Button.defaultButtonForeground(), - fillId = "ScrollButton.Background", - strokeId = "ScrollButton.Foreground", - ) + fun create(question: Boolean = false): Icon { + if (question) return prompt + return bottom + } } 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 59b13882c34..424cfeb62a0 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,19 +5,23 @@ 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 import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Cursor import java.awt.Point import java.awt.Rectangle import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.awt.event.MouseWheelListener import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JScrollBar +import javax.swing.SwingUtilities internal class SessionScroll( private val root: SessionRootPanel, @@ -40,6 +44,7 @@ internal class SessionScroll( internal val bar: JScrollBar get() = component.verticalScrollBar internal val jump: JBLabel val view: JComponent? get() = component.viewport.view as? JComponent + var onScroll: (() -> Unit)? = null private var style = SessionEditorStyle.current() private var tail = true @@ -47,6 +52,10 @@ internal class SessionScroll( private var opening = false private var stable = -1 private var seq = 0 + private var pause = false + private var user = false + private var value = 0 + private var question = false init { jump = JBLabel(ScrollButtonIcon.create()).apply { @@ -59,6 +68,12 @@ internal class SessionScroll( } }) } + component.addMouseWheelListener(MouseWheelListener { user = true }) + component.verticalScrollBar.addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + user = true + } + }) component.verticalScrollBar.addAdjustmentListener { onScroll() } root.addOverlay(jump) { _, child -> val size = child.preferredSize @@ -72,6 +87,7 @@ internal class SessionScroll( } } + @RequiresEdt fun show(panel: JPanel) { if (component.viewport.view === panel) return (panel as? SessionEditorStyleTarget)?.applyStyle(style) @@ -80,35 +96,87 @@ internal class SessionScroll( updateJump() } + @RequiresEdt fun atBottom(): Boolean { - val bar = component.verticalScrollBar return when { component.viewport.view !== messages -> tail - bar.maximum <= bar.visibleAmount -> true - else -> bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + !tail -> false + else -> near() } } + @RequiresEdt fun followBottom(follow: Boolean) { if (!follow) { seq++ updateJump() return } + user = false + pause = false tail = true stable = -1 auto = true show(messages) auto = false val id = ++seq + if (SwingUtilities.isEventDispatchThread()) { + followPass(id, FOLLOW_PASSES) + return + } ApplicationManager.getApplication().invokeLater { followPass(id, FOLLOW_PASSES) } } + @RequiresEdt + fun followTail() { + followBottom(component.viewport.view === messages && tail) + } + + @RequiresEdt + fun following(): Boolean { + return component.viewport.view === messages && tail + } + + @RequiresEdt + fun preserve(anchor: JComponent, action: () -> Unit) { + if (component.viewport.view !== messages) { + action() + return + } + val pos = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val delta = pos.y - component.viewport.viewPosition.y + seq++ + stable = -1 + user = false + pause = false + auto = true + try { + action() + layoutScroll() + val next = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val y = (next.y - delta).coerceIn(0, bottom()) + component.viewport.viewPosition = Point(0, y) + bar.value = y + } finally { + auto = false + } + tail = atBottom() + syncValue() + updateJump() + if (tail) { + stable = -1 + seq++ + } + } + + @RequiresEdt fun openBottom(done: () -> Unit) { opening = true stable = -1 + user = false + pause = false tail = true auto = true show(messages) @@ -119,22 +187,42 @@ internal class SessionScroll( } } + @RequiresEdt fun refresh() { updateJump() } + @RequiresEdt + fun setQuestionPending(value: Boolean) { + if (question == value) return + question = value + syncIcon() + } + + @RequiresEdt fun applyStyle(style: SessionEditorStyle) { this.style = style - jump.icon = ScrollButtonIcon.create() + component.background = SessionUiStyle.Transcript.bgColor() + component.viewport.background = SessionUiStyle.Transcript.bgColor() + syncIcon() messages.applyStyle(style) val view = component.viewport.view if (view !== messages) (view as? SessionEditorStyleTarget)?.applyStyle(style) refresh() } + @RequiresEdt + private fun syncIcon() { + jump.icon = ScrollButtonIcon.create(question) + jump.toolTipText = KiloBundle.message(if (question) "session.scroll.question" else "session.scroll.bottom") + } + + @RequiresEdt private fun jumpBottom() { opening = false stable = -1 + user = false + pause = false tail = true auto = true show(messages) @@ -145,6 +233,7 @@ internal class SessionScroll( } } + @RequiresEdt private fun followPass(id: Int, remaining: Int) { if (id != seq || !tail) return auto = true @@ -156,6 +245,7 @@ internal class SessionScroll( } finally { auto = false } + syncValue() if (remaining <= 0) { stable = -1 return @@ -168,6 +258,7 @@ internal class SessionScroll( } } + @RequiresEdt private fun openPass(id: Int, remaining: Int, done: () -> Unit) { if (id != seq) { opening = false @@ -184,6 +275,7 @@ internal class SessionScroll( } finally { auto = false } + syncValue() if (remaining <= 0) { opening = false stable = -1 @@ -198,10 +290,12 @@ internal class SessionScroll( } } + @RequiresEdt private fun layoutScroll() { root.validate() } + @RequiresEdt private fun scrollToBottom() { val view = component.viewport.view ?: return val y = (view.height - component.viewport.extentSize.height).coerceAtLeast(0) @@ -211,23 +305,70 @@ internal class SessionScroll( bar.value = bottom() } + @RequiresEdt private fun bottom(): Int { val bar = component.verticalScrollBar return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) } + @RequiresEdt + private fun near(): Boolean { + val bar = component.verticalScrollBar + return bar.maximum <= bar.visibleAmount || bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + } + + @RequiresEdt private fun onScroll() { + val prev = value + val moved = bar.value != value + val down = bar.value > value + syncValue() + if (moved) onScroll?.invoke() if (auto || opening) { updateJump() return } if (component.viewport.view === messages) { - tail = atBottom() - if (!tail) seq++ + val bottom = near() + if (bottom) { + if (user && moved && !down) { + tail = false + pause = true + } else if (!tail && !user) { + if (moved) { + auto = true + try { + bar.value = prev.coerceIn(bar.minimum, bottom()) + } finally { + auto = false + } + syncValue() + } + tail = false + } else if (pause && !user) { + tail = false + } else { + tail = true + pause = false + } + user = false + updateJump() + return + } + if (tail && !user && !moved) { + user = false + followBottom(true) + return + } + tail = false + pause = false + user = false + seq++ } updateJump() } + @RequiresEdt private fun updateJump() { val visible = component.viewport.view === messages && !atBottom() if (jump.isVisible == visible) return @@ -235,4 +376,9 @@ internal class SessionScroll( root.overlay.revalidate() root.overlay.repaint() } + + @RequiresEdt + private fun syncValue() { + value = bar.value + } } 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 9b6beb55e8c..63d42d0cb5e 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 @@ -94,7 +94,7 @@ class ConnectionPanel( // Keep the banner solid so expanded details cover transcript content beneath it. isOpaque = true background = UiStyle.Colors.bg() - border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1, 0, 0, 0) + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width(), 0, 0, 0) left.add(toggle, BorderLayout.WEST) left.add(label, BorderLayout.CENTER) header.add(left, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt deleted file mode 100644 index 339c6638a85..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ /dev/null @@ -1,318 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.SessionRef -import ai.kilocode.client.session.history.HistoryTime -import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.history.itemAt -import ai.kilocode.client.session.history.title -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.controller.SessionController -import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.layout.Align -import ai.kilocode.client.ui.layout.HAlign -import ai.kilocode.client.ui.layout.VAlign -import ai.kilocode.client.ui.layout.align -import ai.kilocode.rpc.dto.SessionDto -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList -import com.intellij.util.ui.Centerizer -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import com.intellij.util.ui.components.BorderLayoutPanel -import com.intellij.xml.util.XmlStringUtil -import java.awt.BorderLayout -import java.awt.Component -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.RenderingHints -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.awt.event.MouseMotionAdapter -import javax.swing.DefaultListModel -import javax.swing.JButton -import javax.swing.JList -import javax.swing.ListCellRenderer -import javax.swing.ListSelectionModel - -/** - * Empty-session panel. - * - * The content is a BorderLayout panel, wrapped in a - * [Align] (exposed as [view]) so callers need not know about centering. - */ -class EmptySessionPanel( - parent: Disposable, - private val controller: SessionController, - recents: List, - private val history: () -> Unit = {}, -) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { - val view: Align = align(HAlign.CENTER, VAlign.CENTER) - - private val model = DefaultListModel() - private var hover = -1 - private var style = SessionEditorStyle.current() - - private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val list = JBList(model).apply { - isOpaque = false - selectionMode = ListSelectionModel.SINGLE_SELECTION - visibleRowCount = SessionUiStyle.RecentSessions.LIMIT - cellRenderer = SessionRenderer() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - emptyText.clear() - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val item = itemAt(this@apply, e) ?: return - controller.openSession(SessionRef.Local(item.session)) - } - - override fun mouseExited(e: MouseEvent) { - hover = -1 - repaint() - } - }) - addMouseMotionListener(object : MouseMotionAdapter() { - override fun mouseMoved(e: MouseEvent) { - val index = index(e) - if (hover == index) return - hover = index - repaint() - } - }) - } - - private val historyButton = ShowHistoryButton().apply { - addActionListener { history() } - } - - private val welcomeLabel = JBLabel(welcomeHtml()).apply { - foreground = UIUtil.getContextHelpForeground() - horizontalAlignment = JBLabel.CENTER - setAllowAutoWrapping(true) - } - - private val description = object : BorderLayoutPanel() { - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - return Dimension(JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), size.height) - } - - override fun getMaximumSize(): Dimension { - val size = super.getMaximumSize() - return Dimension(JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), size.height) - } - }.apply { - isOpaque = false - border = JBUI.Borders.empty(UiStyle.Gap.lg(), 0, UiStyle.Gap.lg(), 0) - add(welcomeLabel, BorderLayout.CENTER) - } - - init { - Disposer.register(parent, this) - isOpaque = false - applyStyle(SessionEditorStyle.current()) - setSessions(recents) - - val gap = UiStyle.Gap.pad() - layout = BorderLayout(0, gap) - - val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), - ).apply { - horizontalAlignment = JBLabel.CENTER - } - val header = BorderLayoutPanel(0, gap).apply { - isOpaque = false - add(logo, BorderLayout.NORTH) - add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) - } - - val recent = BorderLayoutPanel().apply { - isOpaque = false - add(recentTitle, BorderLayout.NORTH) - add(list, BorderLayout.CENTER) - } - - val south = BorderLayoutPanel().apply { - isOpaque = false - add(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL), BorderLayout.CENTER) - } - - add(header, BorderLayout.NORTH) - add(recent, BorderLayout.CENTER) - add(south, BorderLayout.SOUTH) - } - - private fun setSessions(sessions: List) { - model.clear() - sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) - revalidate() - repaint() - } - - internal fun recentCount() = model.size() - - internal fun selectRecent(index: Int) { - list.selectedIndex = index - } - - internal fun selectedRecent() = list.selectedIndex - - internal fun clickRecent(index: Int) { - list.selectedIndex = index - controller.openSession(SessionRef.Local(model.getElementAt(index).session)) - } - - internal fun clickShowHistory() { - historyButton.doClick() - } - - internal fun showHistoryText() = historyButton.text - - internal fun showHistoryBorderPainted() = historyButton.isBorderPainted - - internal fun showHistoryCursor() = historyButton.cursor.type - - internal fun recentCursor() = list.cursor.type - - internal fun recentVisible() = true - - internal fun explanationText() = KiloBundle.message("session.empty.welcome") - - internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment - - internal fun descriptionPreferredSize() = description.preferredSize - - internal fun descriptionMaximumSize() = description.maximumSize - - internal fun historyButtonPreferredWidth() = historyButton.preferredSize.width - - internal fun initialized() = true - - internal fun loadingVisible() = false - - internal fun activeView() = getComponent(0) - - internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = - HistoryTime.relative(LocalHistoryItem(session), now) - - internal fun rendererComponent( - session: SessionDto, - selected: Boolean = false, - hover: Boolean = false, - ): Component { - val old = this.hover - this.hover = if (hover) 0 else -1 - return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also { - this.hover = old - } - } - - private fun index(e: MouseEvent): Int { - val idx = list.locationToIndex(e.point) - if (idx < 0) return -1 - val box = list.getCellBounds(idx, idx) ?: return -1 - if (!box.contains(e.point)) return -1 - return idx - } - - private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer { - private val title = JBLabel() - private val time = JBLabel() - - init { - layout = BorderLayout(UiStyle.Gap.pad(), 0) - border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) - add(title, BorderLayout.CENTER) - add(time, BorderLayout.EAST) - } - - override fun getListCellRendererComponent( - list: JList, - value: LocalHistoryItem?, - index: Int, - selected: Boolean, - focus: Boolean, - ): Component { - val active = selected || hover == index - isOpaque = active - background = if (active) list.selectionBackground else list.background - title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() - title.text = value?.let(::title) ?: "" - time.text = value?.let(HistoryTime::relative) ?: "" - return this - } - } - - private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) { - private var over = false - - init { - isFocusable = false - setRequestFocusEnabled(false) - isContentAreaFilled = false - isBorderPainted = false - isOpaque = false - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - addMouseListener(object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - sync(true) - } - - override fun mouseExited(e: MouseEvent) { - sync(false) - } - }) - } - - override fun paintComponent(g: Graphics) { - if (isEnabled && over) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() - val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) - g2.fillRoundRect(0, 0, width, height, arc, arc) - } finally { - g2.dispose() - } - } - super.paintComponent(g) - } - - private fun sync(value: Boolean) { - if (over == value) return - over = value - repaint() - } - } - - override fun dispose() { - // no-op - } - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - welcomeLabel.font = style.regularFont - recentTitle.font = style.smallFont - revalidate() - repaint() - } - - private fun welcomeHtml() = XmlStringUtil.wrapInHtml( - "
    ${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}
    " - ) -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt index 10be5d2285e..e7c20d74956 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt @@ -1,8 +1,10 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel import com.intellij.util.ui.Centerizer import java.awt.BorderLayout @@ -17,6 +19,30 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget { applyStyle(SessionEditorStyle.current()) } + fun setState(state: SessionState) { + when (state) { + is SessionState.Retry -> { + label.text = state.message.ifBlank { KiloBundle.message("session.status.retry") } + label.foreground = UiStyle.Colors.warningLabelForeground() + } + + is SessionState.Offline -> { + label.text = state.message.ifBlank { KiloBundle.message("session.status.offline") } + label.foreground = UiStyle.Colors.errorLabelForeground() + } + + else -> { + label.text = KiloBundle.message("session.empty.loading") + label.foreground = UiStyle.Colors.weak() + } + } + revalidate() + repaint() + } + + /** Exposed for test assertions. */ + fun labelText(): String = label.text + override fun applyStyle(style: SessionEditorStyle) { label.font = style.regularFont revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt index 3fd2887b184..e0a003cb891 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PickerRow.kt @@ -1,5 +1,8 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align import com.intellij.openapi.ui.popup.util.PopupUtil import com.intellij.ui.NewUI import com.intellij.ui.popup.list.SelectablePanel @@ -8,6 +11,7 @@ import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import javax.swing.JComponent import javax.swing.JList +import javax.swing.border.Border internal class PickerRow : SelectablePanel() { init { @@ -15,9 +19,12 @@ internal class PickerRow : SelectablePanel() { isOpaque = true } - fun setContent(component: JComponent) { + fun setContent(component: JComponent, trailing: JComponent? = null, border: Border? = null) { + removeAll() + component.border = border ?: component.border accessibleContextProvider = component add(component, BorderLayout.CENTER) + if (trailing != null) add(trailing.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST) } fun update(list: JList<*>, selected: Boolean, focused: Boolean) { 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 5454737bc6c..5a9d6d757cb 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 @@ -5,11 +5,14 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState 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 ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel -import java.awt.FlowLayout +import com.intellij.util.ui.JBUI /** * Progress footer rendered at the bottom of the session transcript while the @@ -25,7 +28,7 @@ import java.awt.FlowLayout class ProgressPanel( model: SessionModel, parent: Disposable, -) : SessionLayoutPanel(), SessionEditorStyleTarget { +) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget { private val label = JBLabel().apply { foreground = UiStyle.Colors.weak() @@ -34,11 +37,16 @@ class ProgressPanel( init { isOpaque = false isVisible = false - layout = FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0) + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + 0, + 0, + ) applyStyle(SessionEditorStyle.current()) - add(JBLabel(AnimatedIcon.Default())) - add(label) + next(JBLabel(AnimatedIcon.Default())) + next(label) model.addListener(parent) { event -> if (event is SessionModelEvent.StateChanged) onState(event.state) @@ -52,6 +60,7 @@ class ProgressPanel( when (state) { is SessionState.Busy -> { label.text = state.text + label.foreground = UiStyle.Colors.weak() isVisible = true } is SessionState.Loading -> isVisible = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt index 60b925da60a..b3db2c7d410 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.ui.PickerButton import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.ui.popup.ListPopup -import com.intellij.openapi.ui.popup.PopupShowOptions import com.intellij.openapi.ui.popup.PopupStep import com.intellij.openapi.ui.popup.util.BaseListPopupStep import com.intellij.util.ui.EmptyIcon @@ -17,8 +16,8 @@ import javax.swing.Icon /** * Clickable label-style dropdown picker with a native filled background. * - * Shows the selected item's display text with an up-arrow. On click, - * opens a list popup above the picker. Disabled (greyed out, not + * Shows the selected item's display text with a down-arrow. On click, + * opens a list popup below the picker. Disabled (greyed out, not * clickable) when no items are loaded. */ class ReasoningPicker : PickerButton() { @@ -76,11 +75,16 @@ class ReasoningPicker : PickerButton() { } isVisible = true val display = selected?.display ?: items.firstOrNull()?.display ?: "" - text = "$display ▴" + text = "$display ▾" isEnabled = true cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } + fun open() { + if (!isEnabled || items.isEmpty()) return + showPopup() + } + private fun showPopup() { val step = object : BaseListPopupStep("", items) { override fun getTextFor(value: Item) = value.display @@ -96,7 +100,7 @@ class ReasoningPicker : PickerButton() { } val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step) - popup.show(PopupShowOptions.aboveComponent(this)) + popup.showUnderneathOf(this) } private fun icon(item: Item): Icon = if (item.id == selected?.id) checked else empty diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt new file mode 100644 index 00000000000..c0a99af67ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.IconUtil +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Graphics +import java.awt.Graphics2D + +class SessionDropOverlay : BorderLayoutPanel() { + private val title = KiloBundle.message("session.drop.files.title") + private val subtitle = KiloBundle.message("session.drop.files.subtitle") + private val text = "$title $subtitle" + private val card = Card() + private var active = false + + init { + isOpaque = false + accessibleContext?.accessibleName = text + + val primary = JBLabel(title).apply { + font = JBFont.h0() + foreground = UIUtil.getLabelForeground() + } + val secondary = JBLabel(subtitle).apply { + font = JBFont.h2() + foreground = UIUtil.getLabelForeground() + } + val icon = JBLabel(IconUtil.scale(AllIcons.Actions.Download, null, 3f)) + val labels = Stack.vertical(JBUI.scale(SessionUiStyle.View.DropOverlay.LABEL_GAP)) + .next(primary.align(HAlign.CENTER, VAlign.CENTER)) + .next(secondary.align(HAlign.CENTER, VAlign.CENTER)) + .gap(JBUI.scale(SessionUiStyle.View.DropOverlay.ICON_GAP)) + .next(icon.align(HAlign.CENTER, VAlign.CENTER)) + card.apply { + isVisible = false + add(labels, BorderLayout.CENTER) + } + add(card.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) + } + + @RequiresEdt + fun setActive(value: Boolean) { + if (active == value) return + active = value + card.isVisible = value + revalidate() + repaint() + } + + override fun contains(x: Int, y: Int): Boolean = false + + override fun paintComponent(g: Graphics) { + if (!active) { + super.paintComponent(g) + return + } + val g2 = g.create() as Graphics2D + try { + g2.color = SessionUiStyle.View.DropOverlay.scrim() + g2.fillRect(0, 0, width, height) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + private class Card : RoundedContentPanel( + JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_HORIZONTAL_PADDING), + ) { + override fun contentColor(): Color = SessionUiStyle.View.DropOverlay.card() + + override fun outlineColor(): Color? = null + + override fun cornerArc(): Int = JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_ARC) + } +} 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 4c5e49f4ae1..c3b7f65d547 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 @@ -3,8 +3,10 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView @@ -12,8 +14,11 @@ import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView +import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Scrollable transcript panel that maps the model's turn grouping to @@ -46,27 +51,36 @@ class SessionMessageListPanel( private val question: QuestionView? = null, private val permission: PermissionView? = null, private val login: LoginRequiredView? = null, + private val openFile: (String) -> Unit, + private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> ai.kilocode.client.session.views.AttachmentView.openDefault(item, openFile, openUrl) }, + private val repo: String? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, + SessionUiStyle.SessionLayout.InnerInsets.top, + SessionUiStyle.SessionLayout.InnerInsets.left, + SessionUiStyle.SessionLayout.InnerInsets.bottom, + SessionUiStyle.SessionLayout.InnerInsets.right + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING, ), -), SessionEditorStyleTarget { +), Disposable, SessionEditorStyleTarget { private val turnViews = LinkedHashMap() private val msgToTurn = HashMap() private val msgToView = HashMap() private var style = SessionEditorStyle.current() private var hiddenTool: ToolCallRef? = null + private var hovered: PartView? = null /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) init { - isOpaque = false + isOpaque = true + background = SessionUiStyle.Transcript.bgColor() + Disposer.register(parent, this) model.addListener(parent) { event -> when (event) { @@ -76,28 +90,34 @@ class SessionMessageListPanel( is SessionModelEvent.ContentAdded -> { msgToView[event.messageId]?.upsertPart(event.content) + msgToTurn[event.messageId]?.syncCopyToolbars() refresh() } is SessionModelEvent.ContentUpdated -> { msgToView[event.messageId]?.upsertPart(event.content) + msgToTurn[event.messageId]?.syncCopyToolbars() refresh() } is SessionModelEvent.ContentRemoved -> { msgToView[event.messageId]?.removePart(event.contentId) + msgToTurn[event.messageId]?.syncCopyToolbars() refresh() } is SessionModelEvent.ContentDelta -> { - // Use the full current content from the model rather than - // an incremental append. This avoids the double-write that - // occurs when ContentAdded and ContentDelta both fire for - // the same first delta (the model auto-creates the content - // on first appendDelta and fires both events in sequence). + if (event.created) return@addListener + val handled = msgToView[event.messageId]?.appendDelta(event.contentId, event.delta) == true + if (handled) { + msgToTurn[event.messageId]?.syncCopyToolbars() + return@addListener + } val content = model.content(event.messageId, event.contentId) - if (content != null) msgToView[event.messageId]?.upsertPart(content) - refresh() + if (content != null) { + msgToView[event.messageId]?.upsertPart(content) + msgToTurn[event.messageId]?.syncCopyToolbars() + } } is SessionModelEvent.HistoryLoaded -> rebuild() @@ -173,13 +193,14 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, style) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue val mv = tv.addMessage(msg) register(msgId, tv, mv) } + tv.syncCopyToolbars() add(tv) anchorFooter() refresh() @@ -205,6 +226,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(id, tv, mv) } + tv.syncCopyToolbars() refresh() } @@ -213,24 +235,31 @@ class SessionMessageListPanel( val tv = turnViews.remove(id) ?: return for (msgId in tv.messageIds()) unregister(msgId) remove(tv) + Disposer.dispose(tv) anchorFooter() refresh() } private fun rebuild() { + clearHover() + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } turnViews.clear() msgToTurn.clear() msgToView.clear() removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, style) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue val mv = tv.addMessage(msg) register(msgId, tv, mv) } + tv.syncCopyToolbars() add(tv) } @@ -240,6 +269,11 @@ class SessionMessageListPanel( } private fun clear() { + clearHover() + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } turnViews.clear() msgToTurn.clear() msgToView.clear() @@ -325,8 +359,26 @@ class SessionMessageListPanel( repaint() } + private fun hover(view: PartView, value: Boolean) { + if (value) { + val prev = hovered + if (prev === view) return + hovered = view + prev?.setHovered(false) + return + } + if (hovered === view) hovered = null + } + + private fun clearHover() { + val view = hovered ?: return + hovered = null + view.setHovered(false) + } + override fun applyStyle(style: SessionEditorStyle) { this.style = style + background = SessionUiStyle.Transcript.bgColor() for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) @@ -334,4 +386,16 @@ class SessionMessageListPanel( progress.applyStyle(style) refresh() } + + override fun dispose() { + clearHover() + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } + turnViews.clear() + msgToTurn.clear() + msgToView.clear() + removeAll() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index 5b1f9f63974..ba03b2f1bb4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -1,80 +1,16 @@ package ai.kilocode.client.session.ui -import com.intellij.util.ui.JBDimension -import com.intellij.util.ui.components.BorderLayoutPanel -import java.awt.Dimension -import java.awt.Rectangle -import javax.swing.JComponent -import javax.swing.JLayeredPane -import javax.swing.JPanel +import ai.kilocode.client.ui.LayeredOverlayPanel -class SessionRootPanel : JLayeredPane() { +class SessionRootPanel( + private val sessionOverlay: Overlay = Overlay(), + private val sessionBlocker: Blocker = Blocker(), +) : LayeredOverlayPanel(overlay = sessionOverlay, blocker = sessionBlocker) { + override val overlay: Overlay get() = sessionOverlay - val content: JPanel = BorderLayoutPanel() + override val blocker: Blocker get() = sessionBlocker - val overlay = Overlay() + class Overlay : LayeredOverlayPanel.Overlay() - init { - layout = null - add(content) - setLayer(content, DEFAULT_LAYER) - add(overlay) - setLayer(overlay, PALETTE_LAYER) - } - - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - overlay.addOverlay(child, bounds) - } - - override fun doLayout() { - components - .sortedBy { getLayer(it) } - .forEach { child -> - child.setBounds(0, 0, width, height) - child.doLayout() - } - } - - override fun getPreferredSize(): Dimension { - val w = components.maxOfOrNull { it.preferredSize.width } ?: 0 - val h = components.maxOfOrNull { it.preferredSize.height } ?: 0 - return JBDimension(w, h) - } - - class Overlay : BorderLayoutPanel() { - - private val items = linkedMapOf Rectangle>() - - init { - layout = null - // The overlay must let mouse events fall through outside visible children. - isOpaque = false - } - - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - items[child] = bounds - add(child) - } - - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y)) return true - } - return false - } - - override fun doLayout() { - items.forEach { (child, bounds) -> - child.bounds = bounds(this, child) - child.doLayout() - } - } - - override fun getPreferredSize(): Dimension { - val pref = super.getPreferredSize() - val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) - val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) - return JBDimension(w, h) - } - } + class Blocker : LayeredOverlayPanel.Blocker() } 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 d30d3ca8803..5c4f63dd14b 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 @@ -2,11 +2,13 @@ package ai.kilocode.client.session.ui.account import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.PickerButton import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.CollectionListModel @@ -24,10 +26,7 @@ import java.awt.Cursor import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.Box -import javax.swing.BoxLayout import javax.swing.JComponent -import javax.swing.JPanel import javax.swing.KeyStroke import javax.swing.ListSelectionModel import javax.swing.ScrollPaneConstants @@ -68,15 +67,10 @@ internal class SessionAccountOverlay( addActionListener { profile() } } - private val row = JPanel().apply { - layout = BoxLayout(this, BoxLayout.X_AXIS) - isOpaque = false - add(picker) - add(Box.createHorizontalStrut(UiStyle.Gap.md())) - add(balance) - add(Box.createHorizontalStrut(UiStyle.Gap.md())) - add(profileBtn) - } + private val row = Stack.horizontal(gap = UiStyle.Gap.md()) + .next(picker) + .next(balance) + .next(profileBtn) private val panel = RoundedContentPanel(UiStyle.Gap.lg(), UiStyle.Gap.lg()).apply { addToCenter(row) @@ -200,7 +194,7 @@ internal class SessionAccountOverlay( @RequiresEdt private fun showPopup() { - val bg = UiStyle.Colors.cardBg() + val bg = SessionUiStyle.AccountPopup.bgColor() val model = CollectionListModel(choices) val list = JBList(model).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION @@ -291,7 +285,7 @@ internal class SessionAccountOverlay( internal fun choiceCount() = choices.size internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 internal fun panelBackground() = panel.background - internal fun panelBorderColor() = UiStyle.Colors.cardBorder() + internal fun panelBorderColor() = SessionUiStyle.AccountPopup.outlineColor() internal fun balanceVisible() = balance.isVisible internal fun balanceIcon() = balance.icon internal fun balanceText() = balanceText diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt new file mode 100644 index 00000000000..2ae5cdc7596 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt @@ -0,0 +1,299 @@ +package ai.kilocode.client.session.ui.attachment + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle +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 com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.IconLoader +import com.intellij.openapi.util.text.StringUtil +import com.intellij.xml.util.XmlStringUtil +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Image +import java.awt.LayoutManager2 +import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.io.ByteArrayInputStream +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import javax.imageio.ImageIO +import javax.swing.Icon +import javax.swing.ImageIcon +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingConstants +import javax.swing.SwingUtilities + +data class AttachmentCardItem( + val name: String, + val mime: String, + val url: String, + val path: Path? = null, +) + +open class AttachmentCard( + private val item: AttachmentCardItem, + remove: (() -> Unit)? = null, + open: (() -> Unit)? = null, +) : JPanel(CardLayout()) { + private var gen = 0 + private var loaded = false + private val icon = attachmentIcon(item.mime, item.name) + private val tip = tooltip(item) + private val open = open?.let { callback -> + object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + callback() + } + } + } + private val hover = object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + showAction(true) + } + + override fun mouseMoved(e: MouseEvent) { + showAction(true) + } + + override fun mouseExited(e: MouseEvent) { + val point = SwingUtilities.convertPoint(e.component, e.point, this@AttachmentCard) + showAction(contains(point)) + } + } + private val preview = PreviewPanel(::watch).apply { setIcon(icon) } + private val content = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.xs()) + add(preview, BorderLayout.CENTER) + } + private val action = remove?.let { callback -> + CloseButton().apply { + isVisible = false + toolTipText = KiloBundle.message("prompt.attachment.remove", item.name) + accessibleContext?.accessibleName = toolTipText + addActionListener { callback() } + } + } + + init { + isOpaque = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = tip + accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name) + add(content) + if (action != null) { + add(action) + setComponentZOrder(action, 0) + } + watch(this) + } + + override fun getPreferredSize(): Dimension = JBUI.size( + SessionUiStyle.View.Attachment.CARD_WIDTH, + SessionUiStyle.View.Attachment.CARD_HEIGHT, + ) + + override fun getMinimumSize(): Dimension = preferredSize + + override fun getMaximumSize(): Dimension = preferredSize + + override fun addNotify() { + super.addNotify() + if (loaded) return + loaded = true + load() + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC) + g2.color = SessionUiStyle.View.Surface.bgColor() + g2.fillRoundRect(0, 0, width, height, arc, arc) + g2.color = SessionUiStyle.View.Outline.color() + g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + @RequiresEdt + private fun load() { + if (!item.mime.startsWith("image/")) return + val stamp = ++gen + val size = JBUI.size( + SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2, + SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2, + ) + ApplicationManager.getApplication().executeOnPooledThread { + val image = runCatching { + val data = decodeDataImage(item.url) + val path = local(item) + if (data != null) ImageIO.read(ByteArrayInputStream(data)) else path?.let { ImageIO.read(it.toFile()) } + }.getOrNull() + val scaled = image?.let { scale(it, size.width, size.height) } + if (scaled == null) return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + if (gen != stamp || !isDisplayable) return@invokeLater + preview.setIcon(ImageIcon(scaled)) + } + } + } + + private fun watch(node: Component) { + if (node is JComponent && node !is JButton) node.toolTipText = tip + node.removeMouseListener(hover) + node.removeMouseMotionListener(hover) + node.addMouseListener(hover) + node.addMouseMotionListener(hover) + open?.let { + node.removeMouseListener(it) + if (node !is JButton) { + node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + node.addMouseListener(it) + } + } + if (node is Container) node.components.forEach(::watch) + } + + private fun showAction(value: Boolean) { + val button = action ?: return + if (button.isVisible == value) return + button.isVisible = value + revalidate() + repaint() + } + + private class PreviewPanel(private val watch: (Component) -> Unit) : JPanel(BorderLayout()) { + init { + isOpaque = false + } + + override fun getPreferredSize(): Dimension = JBUI.size( + SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2, + SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2, + ) + + fun setIcon(next: Icon) { + val label = JBLabel(next, SwingConstants.CENTER).align(HAlign.CENTER, VAlign.CENTER) + removeAll() + add(label, BorderLayout.CENTER) + watch(label) + revalidate() + repaint() + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC) + g2.color = SessionUiStyle.View.Surface.headerHoverBgColor() + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + } + + private class CardLayout : LayoutManager2 { + override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit + override fun addLayoutComponent(name: String?, comp: Component) = Unit + override fun removeLayoutComponent(comp: Component) = Unit + override fun minimumLayoutSize(parent: Container) = preferredLayoutSize(parent) + override fun preferredLayoutSize(parent: Container) = JBUI.size( + SessionUiStyle.View.Attachment.CARD_WIDTH, + SessionUiStyle.View.Attachment.CARD_HEIGHT, + ) + + override fun maximumLayoutSize(target: Container) = preferredLayoutSize(target) + override fun getLayoutAlignmentX(target: Container) = 0f + override fun getLayoutAlignmentY(target: Container) = 0f + override fun invalidateLayout(target: Container) = Unit + + override fun layoutContainer(parent: Container) { + val size = JBUI.scale(SessionUiStyle.View.Attachment.CLOSE_SIZE) + for (i in 0 until parent.componentCount) { + val child = parent.getComponent(i) + if (child is JButton) { + child.setBounds(parent.width - size - UiStyle.Gap.xs(), UiStyle.Gap.xs(), size, size) + continue + } + child.setBounds(0, 0, parent.width, parent.height) + } + } + } + + private class CloseButton : JButton() { + init { + iconButton(this) + icon = REMOVE_ICON + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + icon = REMOVE_HOVER_ICON + } + + override fun mouseExited(e: MouseEvent) { + icon = REMOVE_ICON + } + }) + } + } + + companion object { + private val REMOVE_ICON: Icon = IconLoader.getIcon("/icons/remove.svg", AttachmentCard::class.java) + private val REMOVE_HOVER_ICON: Icon = IconLoader.getIcon("/icons/remove-hover.svg", AttachmentCard::class.java) + } +} + +private fun scale(image: Image, width: Int, height: Int): Image { + val iw = image.getWidth(null) + val ih = image.getHeight(null) + if (iw <= 0 || ih <= 0) return image + val ratio = minOf(width.toDouble() / iw, height.toDouble() / ih) + val w = maxOf(1, (iw * ratio).toInt()) + val h = maxOf(1, (ih * ratio).toInt()) + return image.getScaledInstance(w, h, Image.SCALE_SMOOTH) +} + +private fun local(item: AttachmentCardItem): Path? { + if (item.path != null) return item.path + val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return null + if (uri.scheme != "file") return null + return runCatching { Path.of(uri) }.getOrNull() +} + +private fun tooltip(item: AttachmentCardItem): String = XmlStringUtil.wrapInHtml( + StringUtil.escapeXmlEntities( + KiloBundle.message("prompt.attachment.tooltip", item.name, item.mime, location(item)), + ).replace("\n", "
    "), +) + +private fun location(item: AttachmentCardItem): String { + if (item.path != null) return item.path.toString() + val uri = runCatching { URI.create(item.url) }.getOrNull() + if (uri?.scheme == "data") return KiloBundle.message("prompt.attachment.embedded") + if (uri?.scheme == "file") return runCatching { Path.of(uri).toString() } + .getOrElse { URLDecoder.decode(uri.rawSchemeSpecificPart.removePrefix("//"), StandardCharsets.UTF_8) } + return item.url +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt new file mode 100644 index 00000000000..a10409fc32a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt @@ -0,0 +1,316 @@ +package ai.kilocode.client.session.ui.attachment + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.vfs.KiloEditorKind +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.Centerizer +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.security.MessageDigest +import javax.imageio.ImageIO +import javax.swing.Icon +import javax.swing.ImageIcon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingConstants +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal object AttachmentEditorKind : KiloEditorKind { + const val ID = "attachment" + + override val id: String = ID + + override fun title(params: Map): String = ref(params)?.filename ?: KiloBundle.message("session.attachment.title") + override fun icon(params: Map): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params)) + override fun presentablePath(params: Map): String { + val ref = ref(params) + return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params)) + } + + override fun isValid(params: Map): Boolean = ref(params) != null + + @RequiresEdt + override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent { + val panel = JPanel(BorderLayout()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + } + panel.add(component(AttachmentData.Connecting), BorderLayout.CENTER) + val ref = ref(file.path.params) + LOG.info("kind=attachment-editor phase=create-content valid=${ref != null} project=${project.name} hash=${project.locationHash} ref=${ref?.let(::brief) ?: "invalid"}") + if (ref == null) { + panel.removeAll() + panel.add(component(AttachmentData.Missing), BorderLayout.CENTER) + return panel + } + project.service().load(ref, parent) { data -> + LOG.info("kind=attachment-editor phase=render data=${describe(data)} ref=${brief(ref)}") + panel.removeAll() + panel.add(component(data), BorderLayout.CENTER) + panel.revalidate() + panel.repaint() + } + return panel + } + + private fun ref(params: Map): AttachmentRef? { + val session = params["sessionId"].takeIfPresent() ?: return null + val message = params["messageId"].takeIfPresent() ?: return null + val part = params["partId"].takeIfPresent() ?: return null + val dir = params["directory"].takeIfPresent() ?: return null + return AttachmentRef( + directory = dir, + sessionId = session, + messageId = message, + partId = part, + attachmentKey = params["attachmentKey"].takeIfPresent(), + filename = params["filename"].takeIfPresent() ?: part, + mime = params["mime"].orEmpty(), + ) + } + + private val LOG = KiloLog.create(AttachmentEditorKind::class.java) +} + +private fun component(data: AttachmentData): JComponent = when (data) { + is AttachmentData.Text -> text(data.text) + is AttachmentData.Image -> JBScrollPane(JBLabel(ImageIcon(data.image), SwingConstants.CENTER)) + is AttachmentData.Binary -> metadata(data.name, data.mime, data.size) + is AttachmentData.Missing -> center(KiloBundle.message("session.attachment.missing")) + is AttachmentData.Error -> center(KiloBundle.message("session.attachment.error", data.message)) + AttachmentData.Connecting -> connecting() + AttachmentData.ConnectionFailed -> failed() +} + +private fun connecting(): JComponent { + return Stack.horizontal(gap = UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + next(JBLabel(AnimatedIcon.Default())) + next(JBLabel(KiloBundle.message("session.connection.connecting"))) + }.let { Centerizer(it, Centerizer.TYPE.BOTH) } +} + +private fun failed(): JComponent { + return Stack.horizontal(gap = UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + next(JBLabel(KiloBundle.message("session.connection.error.app"))) + next(ActionLink(KiloBundle.message("session.connection.retry")) { + service().retryAsync() + }) + }.let { Centerizer(it, Centerizer.TYPE.BOTH) } +} + +private fun text(value: String): JComponent { + val area = JBTextArea(value).apply { + isEditable = false + lineWrap = false + border = JBUI.Borders.empty(UiStyle.Gap.sm()) + } + return JBScrollPane(area) +} + +private fun metadata(name: String, mime: String, size: Int): JComponent { + return Stack.vertical(gap = UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + next(JBLabel(KiloBundle.message("session.attachment.unsupported", name))) + next(JBLabel(KiloBundle.message("session.attachment.mime", mime.ifBlank { "unknown" }))) + next(JBLabel(KiloBundle.message("session.attachment.size", size))) + } +} + +private fun center(value: String): JComponent = Centerizer(JBLabel(value), Centerizer.TYPE.BOTH) + +@Service(Service.Level.PROJECT) +internal class KiloAttachmentEditorService( + private val project: Project, + private val cs: CoroutineScope, +) { + companion object { + private val LOG = KiloLog.create(KiloAttachmentEditorService::class.java) + } + + fun load(ref: AttachmentRef, parent: Disposable, done: (AttachmentData) -> Unit) { + LOG.info("kind=attachment-load phase=start project=${project.name} hash=${project.locationHash} ref=${brief(ref)}") + val disposed = AtomicBoolean(false) + val job = cs.launch { + val app = service() + app.connect() + while (!disposed.get()) { + withContext(Dispatchers.Main) { + if (alive(disposed)) { + LOG.info("kind=attachment-load phase=connecting ref=${brief(ref)}") + done(AttachmentData.Connecting) + } + } + val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR } + LOG.info("kind=attachment-load phase=app-state status=${state.status} ref=${brief(ref)}") + if (state.status == KiloAppStatusDto.ERROR) { + withContext(Dispatchers.Main) { + if (alive(disposed)) { + LOG.info("kind=attachment-load phase=connection-failed ref=${brief(ref)}") + done(AttachmentData.ConnectionFailed) + } + } + app.state.first { it.status != KiloAppStatusDto.ERROR } + continue + } + val data = runCatching { fetch(ref) } + .getOrElse { + LOG.warn("kind=attachment-load phase=fetch-error ref=${brief(ref)} message=${it.message}", it) + AttachmentData.Error(it.message ?: it::class.java.simpleName) + } + withContext(Dispatchers.Main) { + if (alive(disposed)) { + LOG.info("kind=attachment-load phase=done data=${describe(data)} ref=${brief(ref)}") + done(data) + } + } + return@launch + } + } + Disposer.register(parent) { + disposed.set(true) + LOG.info("kind=attachment-load phase=dispose ref=${brief(ref)}") + job.cancel() + } + } + + private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get() + + private suspend fun fetch(ref: AttachmentRef): AttachmentData { + val item = project.service().attachmentPart( + ref.sessionId, + ref.directory, + ref.messageId, + ref.partId, + ref.attachmentKey, + ) ?: run { + LOG.info("kind=attachment-fetch result=missing reason=part-not-found session=${ref.sessionId} message=${ref.messageId} part=${ref.partId} key=${ref.attachmentKey ?: "none"}") + return AttachmentData.Missing + } + val mode = if (ref.attachmentKey.isPresent()) "attachmentKey" else "partId" + LOG.info("kind=attachment-fetch phase=matched mode=$mode session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=${item.filename.orEmpty()} mime=${item.mime.orEmpty()} url=${urlInfo(item.url.orEmpty())}") + val data = parseDataUrl(item.url.orEmpty()) ?: run { + LOG.info("kind=attachment-fetch result=missing reason=parse-data-url session=${ref.sessionId} message=${ref.messageId} part=${item.id} url=${urlInfo(item.url.orEmpty())}") + return AttachmentData.Missing + } + val mime = item.mime?.takeIf { it.isNotBlank() } ?: data.mime + val name = item.filename?.takeIf { it.isNotBlank() } ?: ref.filename + LOG.info("kind=attachment-fetch phase=parsed session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=$name dtoMime=${item.mime.orEmpty()} dataMime=${data.mime} mime=$mime bytes=${data.bytes.size}") + if (textual(mime)) return AttachmentData.Text(data.bytes.toString(Charsets.UTF_8)) + if (mime.startsWith("image/")) { + return withContext(Dispatchers.IO) { + val image = ImageIO.read(ByteArrayInputStream(data.bytes)) ?: return@withContext AttachmentData.Binary(name, mime, data.bytes.size) + LOG.info("kind=attachment-fetch phase=image session=${ref.sessionId} message=${ref.messageId} part=${item.id} width=${image.width} height=${image.height} bytes=${data.bytes.size}") + AttachmentData.Image(image) + } + } + return AttachmentData.Binary(name, mime, data.bytes.size) + } +} + +internal data class AttachmentRef( + val directory: String, + val sessionId: String, + val messageId: String, + val partId: String, + val attachmentKey: String?, + val filename: String, + val mime: String, +) + +fun ensureAttachmentEditorKind() { + service().register(AttachmentEditorKind) +} + +internal fun unregisterAttachmentEditorKind() { + service().unregister(AttachmentEditorKind.ID) +} + +internal fun attachmentParams( + sessionId: String, + messageId: String, + item: FileAttachment, + filename: String, + directory: String, +): Map = linkedMapOf( + "directory" to directory, + "sessionId" to sessionId, + "messageId" to messageId, + "partId" to item.id, + "attachmentKey" to attachmentKey(item.id, item.filename.orEmpty(), item.url), + "filename" to filename, + "mime" to item.mime, +) + +internal sealed interface AttachmentData { + data class Text(val text: String) : AttachmentData + data class Image(val image: BufferedImage) : AttachmentData + data class Binary(val name: String, val mime: String, val size: Int) : AttachmentData + data object Missing : AttachmentData + data class Error(val message: String) : AttachmentData + data object Connecting : AttachmentData + data object ConnectionFailed : AttachmentData +} + +private fun brief(ref: AttachmentRef): String { + return listOf( + "sessionId=${ref.sessionId}", + "messageId=${ref.messageId}", + "partId=${ref.partId}", + "attachmentKey=${ref.attachmentKey ?: ""}", + "filename=${ref.filename}", + "mime=${ref.mime}", + "directory=${ref.directory}", + ).joinToString(prefix = "{", postfix = "}") +} + +private fun String?.isPresent(): Boolean = !this.isNullOrBlank() +private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() } + +private fun describe(data: AttachmentData): String = when (data) { + is AttachmentData.Text -> "text chars=${data.text.length}" + is AttachmentData.Image -> "image width=${data.image.width} height=${data.image.height}" + is AttachmentData.Binary -> "binary name=${data.name} mime=${data.mime} bytes=${data.size}" + is AttachmentData.Error -> "error message=${data.message}" + AttachmentData.Missing -> "missing" + AttachmentData.Connecting -> "connecting" + AttachmentData.ConnectionFailed -> "connection-failed" +} + +private fun urlInfo(url: String): String { + val scheme = url.substringBefore(':', missingDelimiterValue = "none") + return "urlScheme=$scheme urlChars=${url.length} embedded=${isEmbeddedAttachment(url)}" +} + +private fun attachmentKey(part: String, name: String, url: String): String { + val value = listOf(part, name, url).joinToString("\u0000") + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return bytes.take(16).joinToString("") { "%02x".format(it) } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt new file mode 100644 index 00000000000..371addaa17b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.session.ui.attachment + +import com.intellij.icons.AllIcons +import com.intellij.openapi.fileTypes.FileTypeManager +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Base64 +import javax.swing.Icon + +fun decodeDataImage(url: String): ByteArray? { + val data = parseDataUrl(url) ?: return null + if (!data.mime.startsWith("image/")) return null + return data.bytes +} + +internal data class DataUrl(val mime: String, val bytes: ByteArray) + +internal fun parseDataUrl(url: String): DataUrl? { + if (!url.startsWith("data:")) return null + val comma = url.indexOf(',') + if (comma < 0) return null + val meta = url.substring(5, comma) + val body = url.substring(comma + 1) + val parts = meta.split(';').filter { it.isNotBlank() } + val mime = parts.firstOrNull()?.takeIf { it.contains('/') } ?: "text/plain" + val bytes = if (parts.any { it.equals("base64", ignoreCase = true) }) { + runCatching { Base64.getDecoder().decode(body) }.getOrNull() ?: return null + } else { + URLDecoder.decode(body, StandardCharsets.UTF_8).toByteArray(StandardCharsets.UTF_8) + } + return DataUrl(mime, bytes) +} + +internal fun textual(mime: String) = mime.startsWith("text/") || mime in setOf( + "application/json", + "application/javascript", + "application/xml", + "application/x-yaml", +) + +internal fun attachmentIcon(mime: String, name: String = "attachment"): Icon = when { + mime.startsWith("image/") -> AllIcons.FileTypes.Image + mime == "application/x-directory" -> AllIcons.Nodes.Folder + else -> FileTypeManager.getInstance().getFileTypeByFileName(name).icon ?: AllIcons.FileTypes.Text +} + +fun isEmbeddedAttachment(url: String) = url.startsWith("data:") + +fun isLocalAttachment(url: String) = runCatching { java.net.URI.create(url).scheme == "file" }.getOrDefault(false) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index 366bc5b4801..f42a02439ab 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -1,11 +1,27 @@ package ai.kilocode.client.session.ui.editor +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.SendPromptContext +import ai.kilocode.client.session.ui.selection.SessionSelection +import com.intellij.ide.actions.UndoRedoAction +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.command.undo.UndoManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.PlainTextLanguage +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.ui.EditorTextField +import com.intellij.ui.LanguageTextField +import com.intellij.util.textCompletion.TextCompletionProvider +import com.intellij.util.textCompletion.TextCompletionUtil /** * A session-scoped [EditorTextField] for plain-text input. @@ -22,11 +38,74 @@ import com.intellij.ui.EditorTextField * wrapping here. */ internal open class SessionEditorTextField( - project: Project, + private val project: Project, private val ctx: SendPromptContext? = null, -) : EditorTextField(project, PlainTextFileType.INSTANCE) { + completion: TextCompletionProvider? = null, + private val selection: SessionSelection? = null, +) : EditorTextField( + completion?.let { + LanguageTextField.createDocument( + "", + PlainTextLanguage.INSTANCE, + project, + TextCompletionUtil.DocumentWithCompletionCreator(it, true), + ) + }, + project, + PlainTextFileType.INSTANCE, +) { + private val undo = action(KiloBundle.message("session.editor.undo"), true) + private val redo = action(KiloBundle.message("session.editor.redo"), false) + + init { + addSettingsProvider(::install) + } + override fun uiDataSnapshot(sink: DataSink) { super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } ctx?.let { sink.set(PromptDataKeys.SEND, it) } + file()?.let { sink.set(PlatformCoreDataKeys.FILE_EDITOR, it) } + } + + private fun install(editor: Editor) { + editor.contentComponent.putClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER, true) + // Workaround: global $Undo/$Redo can miss the synthetic FileEditor for this embedded + // EditorTextField. Bind the shortcuts locally until the platform data context targets it reliably. + val manager = ActionManager.getInstance() + manager.getAction(IdeActions.ACTION_UNDO)?.shortcutSet?.let { + undo.registerCustomShortcutSet(it, editor.contentComponent) + } + manager.getAction(IdeActions.ACTION_REDO)?.shortcutSet?.let { + redo.registerCustomShortcutSet(it, editor.contentComponent) + } + } + + private fun action(text: String, undo: Boolean) = object : DumbAwareAction(text) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = available(undo) + } + + override fun actionPerformed(e: AnActionEvent) { + if (project.isDisposed) return + val file = file() ?: return + val manager = UndoManager.getInstance(project) + if (undo) { + if (manager.isUndoAvailable(file)) manager.undo(file) + return + } + if (manager.isRedoAvailable(file)) manager.redo(file) + } + } + + private fun available(undo: Boolean): Boolean { + if (project.isDisposed) return false + val file = file() ?: return false + val manager = UndoManager.getInstance(project) + return if (undo) manager.isUndoAvailable(file) else manager.isRedoAvailable(file) + } + + private fun file(): TextEditor? { + return getEditor(false)?.let(TextEditorProvider.getInstance()::getTextEditor) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt new file mode 100644 index 00000000000..ad6f79d05bb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -0,0 +1,133 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.xml.util.XmlStringUtil +import java.awt.Cursor +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent + +internal class EmptySessionFeedback( + private val browse: (String) -> Unit, +) : Disposable { + private var balloon: Balloon? = null + + val button: JButton = FeedbackButton().apply { + addActionListener { popup() } + } + + @RequiresEdt + private fun popup() { + balloon?.let { + it.hide() + return + } + + val content = content { url -> + browse(url) + balloon?.hide() + } + val point = RelativePoint(button, Point(button.width / 2, button.height + JBUI.scale(1))) + val popup = JBPopupFactory.getInstance() + .createBalloonBuilder(content) + .setHideOnClickOutside(true) + .setHideOnKeyOutside(true) + .setHideOnAction(true) + .setHideOnFrameResize(true) + .setBorderColor(UiStyle.Balloon.border()) + .setFillColor(UiStyle.Balloon.bg()) + .setBorderInsets(UiStyle.Balloon.insets()) + .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerRadius(UiStyle.Balloon.arc()) + .createBalloon() + + balloon = popup + popup.setAnimationEnabled(false) + Disposer.register(popup) { balloon = null } + popup.show(point, Balloon.Position.below) + } + + private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) + + override fun dispose() { + balloon?.hide() + } + + private class ActionButton(text: String, icon: javax.swing.Icon, action: () -> Unit) : JButton(text, icon) { + init { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addActionListener { action() } + } + } + + companion object { + @RequiresEdt + fun content(open: (String) -> Unit): JComponent { + val logo = JBLabel(IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java)).apply { + horizontalAlignment = JBLabel.CENTER + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + open(KILO_URL) + } + }) + } + val msg = JBLabel(messageHtml()).apply { + foreground = UIUtil.getLabelForeground() + horizontalAlignment = JBLabel.CENTER + } + val actions = Stack.vertical(gap = UiStyle.Gap.sm()) + .next(ActionButton(KiloBundle.message("feedback.dialog.github"), AllIcons.Vcs.Vendors.Github) { + open(GITHUB_ISSUES_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + .next(ActionButton(KiloBundle.message("feedback.dialog.discord"), DISCORD_ICON) { + open(DISCORD_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + .next(ActionButton(KiloBundle.message("feedback.dialog.support"), AllIcons.Actions.Help) { + open(SUPPORT_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + + return Stack.vertical(gap = UiStyle.Gap.lg()) + .fill(UiStyle.Gap.sm()) + .next(logo.align(HAlign.CENTER, VAlign.CENTER)) + .next(msg.align(HAlign.CENTER, VAlign.CENTER)) + .fill(UiStyle.Gap.xs()) + .next(actions.align(HAlign.CENTER, VAlign.CENTER)) + .fill(UiStyle.Gap.xs()) + } + + fun urls() = listOf(GITHUB_ISSUES_URL, DISCORD_URL, SUPPORT_URL) + + private fun messageHtml() = XmlStringUtil.wrapInHtml( + "
    ${XmlStringUtil.escapeString(KiloBundle.message("feedback.dialog.message"))}
    " + ) + + private fun buttonHtml() = XmlStringUtil.wrapInHtml( + XmlStringUtil.escapeString(KiloBundle.message("feedback.button")) + ) + + private const val KILO_URL = "https://kilocode.ai" + private const val GITHUB_ISSUES_URL = "https://github.com/Kilo-Org/kilocode/issues/new/choose" + private const val DISCORD_URL = "https://kilo.ai/discord" + private const val SUPPORT_URL = "https://kilo.ai/support" + private val DISCORD_ICON = IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt new file mode 100644 index 00000000000..f277884c261 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -0,0 +1,265 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.controller.SessionController +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 ai.kilocode.client.ui.layout.Align +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +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 ai.kilocode.rpc.dto.SessionDto +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.Centerizer +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.HierarchyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent + +/** + * Empty-session panel. + * + * The content is a BorderLayout panel, wrapped in a + * [Align] (exposed as [view]) so callers need not know about centering. + */ +class EmptySessionPanel( + parent: Disposable, + private val controller: SessionController, + recents: List, + private val history: () -> Unit = {}, + private val activity: () -> Map = { emptyMap() }, + private val titles: () -> Map = { emptyMap() }, + private val browse: (String) -> Unit = BrowserUtil::browse, + private val timers: UiTimerSource = UiTimers, +) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { + val view: Align = align(HAlign.CENTER, VAlign.CENTER) + + private val timer = timers.timer(ACTIVITY_MS) { syncActivity() } + internal val recent = RecentsList(recents, controller) + + private val historyButton = ShowHistoryButton().apply { + addActionListener { history() } + } + + private val feedback = EmptySessionFeedback(browse) + + private val welcomeLabel = JBLabel(welcomeHtml()).apply { + foreground = UIUtil.getContextHelpForeground() + horizontalAlignment = JBLabel.CENTER + setAllowAutoWrapping(true) + } + + private val description = object : BorderLayoutPanel() { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), size.height) + } + + override fun getMaximumSize(): Dimension { + val size = super.getMaximumSize() + return Dimension(JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), size.height) + } + }.apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.lg(), 0, UiStyle.Gap.lg(), 0) + add(welcomeLabel, BorderLayout.CENTER) + } + + init { + Disposer.register(parent, this) + Disposer.register(this, feedback) + isOpaque = false + applyStyle(SessionEditorStyle.current()) + addHierarchyListener { e -> + if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener + if (isShowing) { + syncActivity() + timer.start() + return@addHierarchyListener + } + timer.stop() + } + + val gap = UiStyle.Gap.pad() + layout = BorderLayout(0, gap) + + val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + horizontalAlignment = JBLabel.CENTER + } + val header = BorderLayoutPanel(0, gap).apply { + isOpaque = false + add(logo, BorderLayout.NORTH) + add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) + } + + val south = BorderLayoutPanel().apply { + isOpaque = false + add(Stack.vertical(gap = UiStyle.Gap.lg()) + .next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL)) + .next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL)), BorderLayout.CENTER) + } + + add(header, BorderLayout.NORTH) + if (recent.hasSessions()) add(recent, BorderLayout.CENTER) + add(south, BorderLayout.SOUTH) + } + + internal fun recentCount() = recent.count() + + internal fun selectRecent(index: Int) { + recent.select(index) + } + + internal fun selectedRecent() = recent.selected() + + internal fun clickRecent(index: Int) { + recent.click(index) + } + + internal fun clickShowHistory() { + historyButton.doClick() + } + + internal fun showHistoryText() = historyButton.text + + internal fun feedbackText() = KiloBundle.message("feedback.button") + + internal fun feedbackCursor() = feedback.button.cursor.type + + internal fun feedbackIcon() = feedback.button.icon + + internal fun feedbackBorderPainted() = feedback.button.isBorderPainted + + internal fun feedbackContent(open: (String) -> Unit = {}): JComponent = EmptySessionFeedback.content(open) + + internal fun feedbackUrls() = EmptySessionFeedback.urls() + + internal fun showHistoryBorderPainted() = historyButton.isBorderPainted + + internal fun showHistoryCursor() = historyButton.cursor.type + + internal fun recentVisible() = recent.hasSessions() + + internal fun explanationText() = KiloBundle.message("session.empty.welcome") + + internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment + + internal fun descriptionPreferredSize() = description.preferredSize + + internal fun descriptionMaximumSize() = description.maximumSize + + internal fun historyButtonPreferredWidth() = historyButton.preferredSize.width + + internal fun initialized() = true + + internal fun loadingVisible() = false + + internal fun activeView() = getComponent(0) + + internal fun text(session: SessionDto, now: Long = timers.now()) = + recent.text(session, now) + + internal fun rendererComponent( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + return recent.renderer(session, selected, hover) + } + + @RequiresEdt + internal fun syncActivity() { + recent.sync(activity(), titles()) + } + + internal open class ShowHistoryButton( + text: String = KiloBundle.message("session.showHistory"), + icon: javax.swing.Icon = AllIcons.Vcs.History, + ) : JButton(text, icon) { + private var over = false + + init { + isFocusable = false + setRequestFocusEnabled(false) + isContentAreaFilled = false + isBorderPainted = false + isOpaque = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + sync(true) + } + + override fun mouseExited(e: MouseEvent) { + sync(false) + } + }) + } + + override fun paintComponent(g: Graphics) { + if (isEnabled && over) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + } + super.paintComponent(g) + } + + private fun sync(value: Boolean) { + if (over == value) return + over = value + repaint() + } + } + + override fun dispose() { + timer.stop() + } + + override fun applyStyle(style: SessionEditorStyle) { + welcomeLabel.font = style.regularFont + recent.applyStyle(style) + revalidate() + repaint() + } + + private fun welcomeHtml() = XmlStringUtil.wrapInHtml( + "
    ${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}
    " + ) + + private companion object { + const val ACTIVITY_MS = 3_000 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt new file mode 100644 index 00000000000..75380c86918 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt @@ -0,0 +1,200 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.history.HistoryActivitySnapshot +import ai.kilocode.client.session.history.HistoryTime +import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.session.history.itemAt +import ai.kilocode.client.session.history.title +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.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Cursor +import java.awt.FlowLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel + +internal class RecentsList( + sessions: List, + private val controller: SessionController, +) : BorderLayoutPanel(), SessionEditorStyleTarget { + private val model = DefaultListModel() + private var hover = -1 + private var snapshot = HistoryActivitySnapshot() + + private val title = JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + } + + internal val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = SessionUiStyle.RecentSessions.LIMIT + cellRenderer = Renderer() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val item = itemAt(this@apply, e) ?: return + controller.openSession(SessionRef.Local(item.session)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = index(e) + if (hover == index) return + hover = index + repaint() + } + }) + } + + init { + isOpaque = false + add(title, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + setSessions(sessions) + } + + fun count() = model.size() + + fun hasSessions() = model.size() > 0 + + fun select(index: Int) { + list.selectedIndex = index + } + + fun selected() = list.selectedIndex + + fun click(index: Int) { + list.selectedIndex = index + controller.openSession(SessionRef.Local(model.getElementAt(index).session)) + } + + fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = + HistoryTime.relative(LocalHistoryItem(session), now) + + fun renderer( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also { + this.hover = old + } + } + + @RequiresEdt + fun sync(activity: Map, titles: Map) { + val next = HistoryActivitySnapshot(activity, titles) + val changed = snapshot.changed(next) + snapshot = next + repaintRows(changed) + } + + override fun applyStyle(style: SessionEditorStyle) { + title.font = style.smallFont + revalidate() + repaint() + } + + private fun setSessions(sessions: List) { + model.clear() + sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) + revalidate() + repaint() + } + + private fun repaintRows(ids: Set) { + if (ids.isEmpty()) return + repeat(model.size()) { index -> + if (model.getElementAt(index).id !in ids) return@repeat + list.getCellBounds(index, index)?.let(list::repaint) + } + } + + private fun index(e: MouseEvent): Int { + val idx = list.locationToIndex(e.point) + if (idx < 0) return -1 + val box = list.getCellBounds(idx, idx) ?: return -1 + if (!box.contains(e.point)) return -1 + return idx + } + + private inner class Renderer : BorderLayoutPanel(), ListCellRenderer { + private val title = JBLabel() + private val badge = JBLabel().apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + private val time = JBLabel() + private val head = BorderLayoutPanel().apply { + add(BorderLayoutPanel().apply { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + isOpaque = false + add(title) + add(badge) + }, BorderLayout.CENTER) + } + + init { + layout = BorderLayout(UiStyle.Gap.pad(), 0) + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) + head.isOpaque = false + add(head, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: LocalHistoryItem?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val over = selected || hover == index + isOpaque = over + background = if (over) list.selectionBackground else list.background + title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" + time.text = value?.let(HistoryTime::relative) ?: "" + setBadge(value?.id?.let(snapshot.activity::get)) + return this + } + + private fun setBadge(kind: SessionActivityKind?) { + badge.isVisible = kind != null + badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } + } + } +} + +private fun Map.changed(next: Map) = (keys + next.keys).filterTo(mutableSetOf()) { + this[it] != next[it] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt deleted file mode 100644 index e4f37d69334..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ai.kilocode.client.session.ui.header - -import java.awt.Component -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.geom.AffineTransform -import javax.swing.Icon - -internal class RotatedIcon(private val base: Icon) : Icon { - override fun getIconWidth(): Int = base.iconWidth - - override fun getIconHeight(): Int = base.iconHeight - - override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { - val g2 = g.create() as Graphics2D - try { - val tx = AffineTransform() - tx.translate((x + iconWidth / 2.0), (y + iconHeight / 2.0)) - tx.rotate(Math.PI) - tx.translate((-iconWidth / 2.0), (-iconHeight / 2.0)) - g2.transform(tx) - base.paintIcon(c, g2, 0, 0) - } finally { - g2.dispose() - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index cc7f31d1079..02562e013bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -6,12 +6,18 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.todo.TodoListPanel import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto +import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.Disposable import com.intellij.openapi.util.IconLoader +import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel @@ -38,8 +44,6 @@ class SessionHeaderPanel( companion object { private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java) - private val CHEVRON_ICON: Icon = IconLoader.getIcon("/icons/chevron-down.svg", SessionHeaderPanel::class.java) - private val CHEVRON_UP_ICON: Icon = RotatedIcon(CHEVRON_ICON) private val UP_ICON: Icon = IconLoader.getIcon("/icons/arrow-up.svg", SessionHeaderPanel::class.java) private val DOWN_ICON: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", SessionHeaderPanel::class.java) private const val TOUCH_BEGIN = 2 @@ -52,17 +56,24 @@ class SessionHeaderPanel( private val cost = JBLabel() private val context = JBLabel() private val todos = JBLabel() + private val todoArrow = JBLabel(AllIcons.General.ArrowRight) + private val todoList = TodoListPanel() private val compact = HoverIcon().apply { icon = COMPRESS_ICON + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.header.compact.description") accessibleContext.accessibleName = KiloBundle.message("session.header.compact") addActionListener { controller.compact() } } - private val expand = HoverIcon().apply { - icon = CHEVRON_ICON + private val expand = JBLabel().apply { + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) toolTipText = KiloBundle.message("session.header.expand") accessibleContext.accessibleName = KiloBundle.message("session.header.expand") - addActionListener { toggle() } + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + toggle() + } + }) } private val timeline = TimelinePanel() private val viewport = JViewport().apply { @@ -91,16 +102,18 @@ class SessionHeaderPanel( iconTextGap = UiStyle.Gap.xs() } private val top = BorderLayoutPanel() - private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.md(), 0)).apply { - isOpaque = false - add(cost) - add(context) - add(compact) - add(expand) + private val center = BorderLayoutPanel().apply { + border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0) } + private val right = Stack.horizontal() + .next(cost) + .gap(UiStyle.Gap.xl()) + .next(context) + .gap(UiStyle.Gap.sm()) + .next(compact) private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false - border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad(), 0, UiStyle.Gap.pad()) add(tokenTitle) add(Box.createHorizontalStrut(UiStyle.Gap.md())) add(input) @@ -111,28 +124,45 @@ class SessionHeaderPanel( add(Box.createHorizontalStrut(UiStyle.Gap.sm())) add(cacheWrite) } - private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)).apply { + private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { isOpaque = false border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("session.header.todos.toggle") + accessibleContext.accessibleName = KiloBundle.message("session.header.todos.toggle") + add(todoArrow) add(todos) } + private val todoBox = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(todoRow) + } private val body = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) - border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + UiStyle.Gap.xl(), + UiStyle.Gap.md(), + UiStyle.Gap.xl(), + ) add(viewport) add(tokens) add(bar) - add(todoRow) + add(todoBox) } private var style = SessionEditorStyle.current() + private var costValue = "" init { isOpaque = true updateUI() - top.add(title, BorderLayout.CENTER) - top.add(right, BorderLayout.EAST) + center.add(title, BorderLayout.CENTER) + center.add(right, BorderLayout.EAST) + top.add(expand, BorderLayout.WEST) + top.add(center, BorderLayout.CENTER) add(top, BorderLayout.NORTH) timeline.addMouseListener(object : MouseAdapter() { override fun mousePressed(event: MouseEvent) { @@ -151,6 +181,15 @@ class SessionHeaderPanel( }) timeline.addMouseWheelListener { scroll(it) } viewport.addMouseWheelListener { scroll(it) } + val todoClick = object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + toggleTodos() + } + } + listOf(todoRow, todoArrow, todos).forEach { + it.cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + it.addMouseListener(todoClick) + } controller.model.addListener(parent) { event -> when (event) { @@ -184,11 +223,13 @@ class SessionHeaderPanel( override fun updateUI() { super.updateUI() border = JBUI.Borders.compound( - JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0), - JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad()), + JBUI.Borders.customLine(separator(), 0, 0, 1, 0), + JBUI.Borders.empty(), ) } + private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor()) + fun update(header: SessionHeaderSnapshot) { val before = isVisible title.text = header.title @@ -203,12 +244,11 @@ class SessionHeaderPanel( syncExpanded(expanded()) - set(cost, money(header.cost)) + setCost(money(header.cost)) set(context, contextText(header.context)) context.toolTipText = contextTip(header.context) setTokens(header.tokens) - set(todos, todo(header.todos.completed, header.todos.total)) - todoRow.isVisible = todos.isVisible + syncTodos(header.todos.items) compact.isEnabled = header.canCompact val appended = timeline.setItems(header.timeline) @@ -224,19 +264,27 @@ class SessionHeaderPanel( background = style.editorBackground foreground = style.editorForeground top.background = style.editorBackground + top.isOpaque = true + top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm()) + center.background = style.editorBackground + center.isOpaque = true right.background = style.editorBackground tokens.background = style.editorBackground todoRow.background = style.editorBackground + todoBox.background = style.editorBackground body.background = style.editorBackground viewport.background = style.editorBackground title.font = style.boldFont title.foreground = style.editorForeground cost.font = style.regularFont cost.foreground = style.editorForeground + cost.icon = null context.font = style.regularFont context.foreground = style.editorForeground todos.font = style.smallFont todos.foreground = style.editorForeground + todoArrow.foreground = style.editorForeground + todoList.applyStyle(style) tokenTitle.font = style.smallFont tokenTitle.foreground = style.editorForeground input.font = style.smallFont @@ -253,7 +301,9 @@ class SessionHeaderPanel( internal fun titleText(): String = title.text - internal fun costText(): String = cost.text + internal fun costText(): String = costValue + + internal fun costTip() = cost.toolTipText internal fun contextText(): String = context.text @@ -278,6 +328,14 @@ class SessionHeaderPanel( internal fun todoVisible() = todoRow.isVisible && todos.isVisible + internal fun todoListVisible() = todoList.parent === todoBox + + internal fun todoRowPanel() = todoRow + + internal fun todoLabel() = todos + + internal fun todoListPanel() = todoList + internal fun compactButton() = compact internal fun expandButton() = expand @@ -358,6 +416,51 @@ class SessionHeaderPanel( tokens.isVisible = total > 0 } + private fun setCost(value: String?) { + costValue = value.orEmpty() + cost.text = costValue + cost.icon = null + val tip = costValue.takeIf { it.isNotBlank() }?.let { KiloBundle.message("session.header.cost.tooltip", it) } + cost.toolTipText = tip + cost.accessibleContext.accessibleName = tip + cost.isVisible = costValue.isNotBlank() + } + + private fun syncTodos(items: List) { + val total = items.size + val done = items.count { it.status == "completed" } + set(todos, todo(done, total)) + todos.foreground = if (total > 0 && done == total) SessionUiStyle.Timeline.SUCCESS else style.editorForeground + todoArrow.isVisible = total > 0 + todoBox.isVisible = total > 0 + todoRow.isVisible = total > 0 + todoList.update(items) + if (total == 0) collapseTodos() + } + + private fun toggleTodos() { + if (!todoBox.isVisible) return + if (todoListVisible()) collapseTodos() else expandTodos() + refresh() + } + + private fun expandTodos(): Boolean { + if (todoListVisible()) return false + todoBox.add(todoList) + todoArrow.icon = AllIcons.General.ArrowDown + return true + } + + private fun collapseTodos(): Boolean { + if (!todoListVisible()) { + todoArrow.icon = AllIcons.General.ArrowRight + return false + } + todoBox.remove(todoList) + todoArrow.icon = AllIcons.General.ArrowRight + return true + } + private fun toggle() { val next = !isExpanded() syncExpanded(next) @@ -378,7 +481,10 @@ class SessionHeaderPanel( private fun collapse(): Boolean { val attached = body.parent === this - if (!attached) return false + if (!attached) { + setExpand(false) + return false + } remove(body) setExpand(false) return attached @@ -386,7 +492,8 @@ class SessionHeaderPanel( private fun setExpand(expanded: Boolean) { val key = if (expanded) "session.header.collapse" else "session.header.expand" - expand.icon = if (expanded) CHEVRON_UP_ICON else CHEVRON_ICON + expand.text = "" + expand.icon = if (expanded) AllIcons.General.ChevronDown else AllIcons.General.ChevronRight expand.toolTipText = KiloBundle.message(key) expand.accessibleContext.accessibleName = KiloBundle.message(key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt index 6c9a03b6565..c3381ec4d41 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt @@ -66,6 +66,11 @@ class ModePicker : PickerButton() { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } + fun open() { + if (!isEnabled || items.isEmpty()) return + showPopup() + } + private fun showPopup() { val item = selected ?: items.first() val popup = JBPopupFactory.getInstance() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 6913142e533..6ae1c2bec11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -19,6 +19,7 @@ import com.intellij.ui.components.JBList import com.intellij.ui.popup.AbstractPopup import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout import java.awt.Color import java.awt.Cursor @@ -34,6 +35,7 @@ import javax.swing.JScrollPane import javax.swing.KeyStroke import javax.swing.ListSelectionModel import javax.swing.ScrollPaneConstants +import javax.swing.SwingConstants import javax.swing.SwingUtilities import javax.swing.event.DocumentEvent @@ -54,16 +56,29 @@ class ModelPicker : PickerButton() { val providerName: String, val recommendedIndex: Double? = null, val free: Boolean = false, + val byok: Boolean = false, val variants: List = emptyList(), + val attachment: Boolean = false, + val mayTrainOnYourPrompts: Boolean = false, ) { val key: String get() = "$provider/$id" override fun toString(): String = listOf(display, id, providerName).joinToString(" ") } + enum class Placement { + ABOVE, + BELOW, + } + var onSelect: (Item) -> Unit = {} + var onClear: () -> Unit = {} var favorites: () -> List = { emptyList() } var onFavoriteToggle: (Item) -> Unit = {} + var allowEmpty: Boolean = false + var emptyText: String = KiloBundle.message("settings.models.notSet") + var includeSmall: Boolean = false + var placement: Placement = Placement.BELOW private var items: List = emptyList() private var selected: Item? = null @@ -75,7 +90,7 @@ class ModelPicker : PickerButton() { addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { - if (!isEnabled || items.isEmpty()) return + if (!isEnabled || (items.isEmpty() && !allowEmpty)) return showPopup() } }) @@ -85,7 +100,7 @@ class ModelPicker : PickerButton() { items = values val key = default ?: selected?.key selected = key?.let { target -> values.firstOrNull { it.key == target || it.id == target } } - ?: values.firstOrNull() + ?: if (allowEmpty) null else values.firstOrNull() refresh() } @@ -96,21 +111,39 @@ class ModelPicker : PickerButton() { internal fun selectedForTest(): Item? = selected + fun clearSelection() { + selected = null + refresh() + } + + fun selectionKeyForTest(): String? = selected?.key + private fun refresh() { if (items.isEmpty()) { - isEnabled = false - text = " " - cursor = Cursor.getDefaultCursor() + isEnabled = allowEmpty + text = if (allowEmpty) emptyText else " " + icon = null + toolTipText = KiloBundle.message("model.picker.tooltip") + cursor = if (allowEmpty) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() return } - val display = selected?.display ?: items.firstOrNull()?.display ?: "" - text = "${ModelText.sanitize(display)} ▴" + val item = selected ?: if (allowEmpty) null else items.firstOrNull() + text = if (item == null && allowEmpty) "$emptyText ▾" else "${ModelText.buttonLabel(item ?: items.first())} ▾" + icon = if (item?.let(ModelText::collectsData) == true) ModelPickerRenderer.DATA_COLLECTED else null + horizontalTextPosition = SwingConstants.LEFT + iconTextGap = JBUI.CurrentTheme.ActionsList.elementIconGap() + toolTipText = if (item?.let(ModelText::collectsData) == true) ModelText.dataCollectedTooltip() else KiloBundle.message("model.picker.tooltip") isEnabled = true cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } + fun open() { + if (!isEnabled || (items.isEmpty() && !allowEmpty)) return + showPopup() + } + private fun showPopup() { - val rows = modelPickerRows(items, favorites(), "") + val rows = modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall) val model = CollectionListModel(rows) val list = JBList(model).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION @@ -139,7 +172,7 @@ class ModelPicker : PickerButton() { } fun sync(prefer: String? = activeKey(), at: Int? = null) { - val rows = modelPickerRows(items, favorites(), search.text) + val rows = modelPickerRows(items, favorites(), search.text, allowEmpty, emptyText, includeSmall) model.replaceAll(rows) val idx = at?.let { modelPickerIndex(rows, it) }?.takeIf { it >= 0 } ?: modelPickerIndex(rows, prefer).takeIf { it >= 0 } @@ -157,6 +190,22 @@ class ModelPicker : PickerButton() { popup.closeOk(null) } + fun clear() { + selected = null + refresh() + onClear() + popup.closeOk(null) + } + + fun activate(row: ModelPickerRow) { + val item = row.item + if (item == null) { + clear() + return + } + activate(item) + } + fun move(step: Int) { val size = model.size if (size <= 0) return @@ -166,8 +215,9 @@ class ModelPicker : PickerButton() { } fun toggle(row: ModelPickerRow) { + val item = row.item ?: return val idx = list.selectedIndex - onFavoriteToggle(row.item) + onFavoriteToggle(item) sync(at = idx) list.selectedIndex.takeIf { it >= 0 }?.let { repaintRow(list, it) } } @@ -188,7 +238,7 @@ class ModelPicker : PickerButton() { JComponent.WHEN_FOCUSED, ) search.textEditor.registerKeyboardAction( - { list.selectedValue?.item?.let(::activate) }, + { list.selectedValue?.let(::activate) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) @@ -203,7 +253,7 @@ class ModelPicker : PickerButton() { JComponent.WHEN_FOCUSED, ) list.registerKeyboardAction( - { list.selectedValue?.item?.let(::activate) }, + { list.selectedValue?.let(::activate) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) @@ -229,7 +279,7 @@ class ModelPicker : PickerButton() { e.consume() return } - activate(value.item) + activate(value) } }) ListUtil.installAutoSelectOnMouseMove(list) @@ -269,7 +319,10 @@ class ModelPicker : PickerButton() { .setMovable(false) .createPopup() - popup.show(PopupShowOptions.aboveComponent(this)) + when (placement) { + Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(this)) + Placement.BELOW -> popup.showUnderneathOf(this) + } SwingUtilities.invokeLater { search.textEditor.requestFocusInWindow() search.selectText() @@ -281,10 +334,14 @@ class ModelPicker : PickerButton() { } internal data class ModelPickerRow( - val item: ModelPicker.Item, + val item: ModelPicker.Item?, val section: String?, val favorite: Boolean, -) + val emptyText: String = "", +) { + val key: String? get() = item?.key + val isEmpty: Boolean get() = item == null +} private fun computeInitialPopupSize( list: JList, @@ -402,9 +459,28 @@ internal object ModelText { return Parts(null, text) } + fun buttonLabel(item: ModelPicker.Item): String { + val part = parts(item).model + if (item.provider == "kilo") return part + val provider = item.providerName.trim() + if (provider.isEmpty()) return part + return "$provider / $part" + } + fun small(item: ModelPicker.Item): Boolean = item.provider == "kilo" && item.id in small fun providerSort(id: String): Int = if (id == "kilo") 0 else 1 + fun dataCollected(): String = KiloBundle.message("model.picker.dataCollected") + + fun dataCollectedTooltip(): String = XmlStringUtil.wrapInHtmlLines( + KiloBundle.message("model.picker.tooltip"), + KiloBundle.message("model.picker.dataCollected.current"), + ) + + fun freeLabel(): String = KiloBundle.message("model.picker.free") + + fun collectsData(item: ModelPicker.Item): Boolean = item.mayTrainOnYourPrompts + fun freeBg(): JBColor = JBColor.namedColor("Kilo.ModelPicker.freeBadgeBackground", JBColor(0x95D6AC, 0x7FCA99)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index a6c004b46fc..e3e9797dd22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -1,10 +1,10 @@ package ai.kilocode.client.session.ui.model -import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.PickerRow import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons +import com.intellij.openapi.util.IconLoader import com.intellij.ui.CollectionListModel import com.intellij.ui.GroupHeaderSeparator import com.intellij.ui.NewUI @@ -33,6 +33,7 @@ internal class ModelPickerRenderer( private val favorites: () -> Set, ) : JPanel(BorderLayout()), ListCellRenderer { companion object { + val DATA_COLLECTED: Icon = IconLoader.getIcon("/icons/book-open-check.svg", ModelPickerRenderer::class.java) val checked: Icon = AllIcons.Actions.Checked val empty: Icon = EmptyIcon.create(checked) @@ -66,16 +67,31 @@ internal class ModelPickerRenderer( } private val title = SimpleColoredComponent() private val badge = FilledBadgeIcon( - KiloBundle.message("model.picker.free"), + ModelText.freeLabel(), ModelText.freeBg(), JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE), ) + private val badgeLabel = BadgeLabel(badge).apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + private val byok = FilledBadgeIcon( + "BYOK", + UiStyle.Colors.badgeBg(), + UiStyle.Colors.badgeFg(), + ) + private val byokLabel = BadgeLabel(byok).apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + private val warn = JBLabel(DATA_COLLECTED).apply { + toolTipText = ModelText.dataCollected() + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } private val provider = JBLabel() private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(title) - add(BadgeLabel(badge).apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - }) + add(warn) + add(badgeLabel) + add(byokLabel) add(provider) } private val star = JBLabel().apply { @@ -85,21 +101,20 @@ internal class ModelPickerRenderer( private val row = JPanel(BorderLayout()).apply { add(check, BorderLayout.WEST) add(head, BorderLayout.CENTER) - add(star, BorderLayout.EAST) } private val wrap = PickerRow() init { isOpaque = true top.isOpaque = true - UiStyle.Components.transparent(row, check, title, head, provider, star) + UiStyle.Components.transparent(row, check, title, head, warn, provider, star) row.border = JBUI.Borders.empty( UiStyle.Gap.md(), UiStyle.Gap.lg(), UiStyle.Gap.md(), UiStyle.Gap.pad(), ) - wrap.setContent(row) + wrap.setContent(row, star) add(top, BorderLayout.NORTH) add(wrap, BorderLayout.CENTER) } @@ -124,22 +139,35 @@ internal class ModelPickerRenderer( sep.setHideLine(index == 0) top.isVisible = section != null - check.icon = if (value.item.key == active()) checked else empty + check.icon = if (value.key == active()) checked else empty title.clear() - val name = ModelText.parts(value.item) + val item = value.item + if (item == null) { + title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) + badgeLabel.isVisible = false + byokLabel.isVisible = false + warn.isVisible = false + provider.isVisible = false + star.icon = EmptyIcon.ICON_16 + top.invalidate() + return this + } + val name = ModelText.parts(item) if (name.provider != null) { title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) } title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) - head.getComponent(1).isVisible = value.item.free + warn.isVisible = ModelText.collectsData(item) + badgeLabel.isVisible = item.free && !item.byok + byokLabel.isVisible = item.byok provider.isVisible = value.favorite - provider.text = value.item.providerName + provider.text = item.providerName provider.foreground = weak provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - val fav = value.item.key in favorites() + val fav = item.key in favorites() star.icon = when { fav -> AllIcons.Nodes.Favorite selected -> AllIcons.Nodes.NotFavoriteOnHover @@ -153,7 +181,15 @@ internal class ModelPickerRenderer( internal fun starIcon(): Icon? = star.icon - internal fun badgeVisible(): Boolean = head.getComponent(1).isVisible + internal fun badgeVisible(): Boolean = badgeLabel.isVisible + + internal fun badgeText(): String = badge.text + + internal fun byokVisible(): Boolean = byokLabel.isVisible + + internal fun warningVisible(): Boolean = warn.isVisible + + internal fun warningTooltip(): String? = warn.toolTipText private class BadgeLabel(icon: Icon) : JBLabel(icon) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt index 22a069cf787..4f3ac688771 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt @@ -7,9 +7,12 @@ internal fun modelPickerRows( items: List, favorites: List, query: String, + allowEmpty: Boolean = false, + emptyText: String = KiloBundle.message("settings.models.notSet"), + includeSmall: Boolean = false, ): List { val q = query.trim() - val all = items.filterNot(ModelText::small) + val all = if (includeSmall) items else items.filterNot(ModelText::small) val filtered = all.filter { ModelSearch.matches(q, it.display) || ModelSearch.matches(q, it.id) || ModelSearch.matches(q, it.providerName) } @@ -22,6 +25,9 @@ internal fun modelPickerRows( .toList() .sortedWith(compareBy>> { ModelText.providerSort(it.first) }) val out = mutableListOf() + if (allowEmpty && ModelSearch.matches(q, emptyText)) { + out += ModelPickerRow(null, null, favorite = false, emptyText = emptyText) + } if (q.isBlank()) { val byKey = all.associateBy { it.key } val fav = favorites.map { "${it.providerID}/${it.modelID}" }.mapNotNull(byKey::get) @@ -43,8 +49,8 @@ internal fun modelPickerRows( } internal fun modelPickerIndex(rows: List, key: String?): Int { - if (key == null) return -1 - return rows.indexOfFirst { it.item.key == key } + if (key == null) return rows.indexOfFirst { it.item == null } + return rows.indexOfFirst { it.item?.key == key } } internal fun modelPickerIndex(rows: List, index: Int): Int { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt new file mode 100644 index 00000000000..5e40551cd03 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -0,0 +1,306 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.FileSearchResultDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.completion.PlainPrefixMatcher +import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.lookup.AutoCompletionPolicy +import com.intellij.codeInsight.lookup.CharFilter +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.util.textCompletion.TextCompletionProvider +import java.util.Collections +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class KiloPromptCompletionProvider( + private val workspace: Workspace, + private val service: KiloWorkspaceService, + private val actions: List, + private val mentions: List, + private val scope: CoroutineScope, +) : TextCompletionProvider, DumbAware { + private val paths = Collections.synchronizedSet(mutableSetOf()) + private val exists = Collections.synchronizedMap(mutableMapOf()) + private val pending = Collections.synchronizedSet(mutableSetOf()) + private val cache: MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > 64 + }, + ) + + data class Highlight(val start: Int, val end: Int, val kind: HighlightKind) + + enum class HighlightKind { MENTION, COMMAND, INVALID } + + /** A file mention token at a caret/mouse offset, with whether it resolves to a real file. */ + data class MentionHit(val start: Int, val end: Int, val value: String, val resolved: Boolean) + + /** + * The file mention spanning [offset], or null when the offset is outside a mention, + * on a special (`@git-changes`) token, or on a mention not yet validated. + */ + fun mentionAt(text: String, offset: Int): MentionHit? { + val span = mentionSpans(text).firstOrNull { offset in it.start..it.end } ?: return null + if (span.value in mentionNames()) return null + val resolved = span.value in paths || exists[span.value] == true + if (!resolved && exists[span.value] != false) return null + return MentionHit(span.start, span.end, span.value, resolved) + } + + /** Open a resolved file mention using the workspace's go-to-file plumbing. */ + fun navigate(value: String) { + scope.launch { service.openPath(workspace.directory, value) } + } + + fun clearMentions() { + paths.clear() + exists.clear() + pending.clear() + cache.clear() + } + + fun prewarm() { + if (cache.containsKey("")) return + scope.launch { + val result = service.searchFiles(workspace.directory, "", 50) + if (result.files.isNotEmpty() || result.git) cache.putIfAbsent("", result) + } + } + + fun inside(text: String, caret: Int): Boolean = mentionSpans(text).any { span -> caret in span.start..span.end } + + private fun clientTokens(): Set = actions.flatMapTo(mutableSetOf()) { action -> + listOf(action.name) + action.hints + } + + fun clientAction(text: String): SlashAction? { + val name = commandName(text) ?: return null + return actions.firstOrNull { action -> name == action.name || name in action.hints } + } + + fun mentionNames(): Set = mentions.mapTo(mutableSetOf()) { it.name } + + fun serverCommand(text: String): Pair? { + val name = commandName(text) ?: return null + if (actions.any { action -> name == action.name || name in action.hints }) return null + if (workspace.state.value.commands.none { it.name == name }) return null + val raw = text.trimStart() + return name to raw.drop(name.length + 1).trimStart() + } + + fun highlights(text: String, caret: Int = -1): List = buildList { + val command = text.takeIf { it.startsWith('/') } + ?.drop(1) + ?.takeWhile { !it.isWhitespace() } + ?.takeIf { it.isNotBlank() } + val commands = workspace.state.value.commands.mapTo(mutableSetOf()) { it.name } + if (command != null && (clientAction("/$command") != null || command in commands)) { + add(Highlight(0, command.length + 1, HighlightKind.COMMAND)) + } + + mentionSpans(text).forEach { span -> + val under = caret in span.start..span.end + when { + span.value in mentionNames() -> add(Highlight(span.start, span.end, HighlightKind.MENTION)) + span.value in paths || exists[span.value] == true -> add(Highlight(span.start, span.end, HighlightKind.MENTION)) + under -> Unit + exists[span.value] == false -> add(Highlight(span.start, span.end, HighlightKind.INVALID)) + } + } + } + + fun validate(text: String, caret: Int, onResolved: () -> Unit) { + mentionSpans(text).forEach { span -> + val value = span.value + if (value in mentionNames()) return@forEach + if (value in paths) return@forEach + if (caret in span.start..span.end) return@forEach + if (exists.containsKey(value)) return@forEach + if (!pending.add(value)) return@forEach + scope.launch { + val ok = runCatching { service.files(workspace.directory, value).isNotEmpty() }.getOrDefault(false) + exists[value] = ok + pending.remove(value) + onResolved() + } + } + } + + override fun getAdvertisement(): String? = null + + override fun getPrefix(text: String, offset: Int): String? = token(text, offset)?.prefix + + override fun applyPrefixMatcher(result: CompletionResultSet, prefix: String): CompletionResultSet = + result.withPrefixMatcher(PlainPrefixMatcher(prefix)).caseInsensitive() + + override fun acceptChar(c: Char): CharFilter.Result = when { + c.isWhitespace() -> CharFilter.Result.HIDE_LOOKUP + else -> CharFilter.Result.ADD_TO_PREFIX + } + + override fun fillCompletionVariants(parameters: CompletionParameters, prefix: String, result: CompletionResultSet) { + when (token(parameters.originalFile.text, parameters.offset)?.kind) { + Kind.SLASH -> slash(prefix, result) + Kind.MENTION -> mention(prefix, result) + null -> Unit + } + result.stopHere() + } + + private fun slash(prefix: String, result: CompletionResultSet) { + result.restartCompletionOnAnyPrefixChange() + val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) + val names = clientTokens() + val clients = actions.filter { action -> matches(prefix, action.name, action.hints) } + clients.forEach { action -> out.addElement(client(action)) } + val commands = workspace.state.value.commands + .filter { it.name !in names && matches(prefix, it.name, it.hints) } + commands.forEach { command -> out.addElement(server(command)) } + if (clients.isNotEmpty() || commands.isNotEmpty()) return + result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) + .addElement(info(prefix, KiloBundle.message("prompt.completion.noMatches"))) + } + + private fun mention(prefix: String, result: CompletionResultSet) { + result.restartCompletionOnAnyPrefixChange() + val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) + val search = search(prefix) + val known = mentions.filter { action -> matches(prefix, action.name, action.hints) && action.available(search) } + known.forEach { action -> out.addElement(prioritize(resource(action))) } + if (search.indexing) { + val msg = KiloBundle.message("prompt.mention.indexing") + result.addLookupAdvertisement(msg) + out.addElement(info(prefix, msg)) + return + } + search.files.forEach { file -> out.addElement(file(file)) } + if (known.isEmpty() && search.files.isEmpty()) { + val msg = KiloBundle.message("prompt.completion.noMatches") + out.addElement(info(prefix, msg)) + } + } + + private fun search(prefix: String): FileSearchResultDto = cache[prefix] ?: fetch(prefix) + + private fun fetch(prefix: String): FileSearchResultDto { + val result = runBlockingCancellable { service.searchFiles(workspace.directory, prefix, 50) } + cache[prefix] = result + return result + } + + private fun info(prefix: String, msg: String): LookupElement = LookupElementBuilder.create(msg) + .withPresentableText(msg) + .withIcon(AllIcons.General.Information) + .withInsertHandler { ctx, _ -> + val start = (ctx.startOffset - prefix.length).coerceAtLeast(0) + val tail = ctx.tailOffset.coerceAtMost(ctx.document.textLength) + val end = (tail until ctx.document.textLength).firstOrNull { ctx.document.text[it].isWhitespace() } + ?: ctx.document.textLength + ctx.document.replaceString(start, end, prefix) + ctx.editor.caretModel.moveToOffset(start + prefix.length) + } + .withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE) + + private fun matches(prefix: String, name: String, hints: List): Boolean = + (listOf(name) + hints).any { it.startsWith(prefix, ignoreCase = true) } + + private fun commandName(text: String): String? { + val raw = text.trimStart() + if (!raw.startsWith('/')) return null + return raw.drop(1).takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() } + } + + private fun client(action: SlashAction): LookupElement = LookupElementBuilder.create(action.name) + .withPresentableText("/${action.name}") + .withTailText(" ${action.description}", true) + .withLookupStrings(action.hints) + .withIcon(AllIcons.Actions.Execute) + .withInsertHandler { ctx, _ -> + ctx.document.setText("") + ApplicationManager.getApplication().invokeLater { action.action() } + } + + private fun server(command: CommandDto): LookupElement = LookupElementBuilder.create(command.name) + .withPresentableText("/${command.name}") + .withTailText(command.description?.let { " $it" } ?: "", true) + .withTypeText(command.source) + .withLookupStrings(command.hints) + .withIcon(AllIcons.Nodes.Function) + .withInsertHandler { ctx, _ -> replace(ctx, "/${command.name} ", false) } + + private fun resource(action: MentionAction): LookupElement = LookupElementBuilder.create(action.name) + .withPresentableText("@${action.name}") + .withTailText(" ${action.description}", true) + .withLookupStrings(action.hints) + .withIcon(AllIcons.Nodes.Tag) + .withInsertHandler { ctx, _ -> replace(ctx, "@${action.name} ", false) } + + private fun prioritize(element: LookupElement): LookupElement = + PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100) + + private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path) + .withPresentableText("@${file.path}") + .withTailText(parent(file.path), true) + .withIcon(icon(file)) + .withLookupString(file.name) + .withInsertHandler { ctx, _ -> replace(ctx, "@${file.path} ", true, file.path) } + + private fun icon(file: WorkspaceFileDto) = when { + file.directory -> AllIcons.Nodes.Folder + else -> FileTypeManager.getInstance().getFileTypeByFileName(file.name).icon ?: AllIcons.FileTypes.Text + } + + private fun replace(ctx: InsertionContext, value: String, trim: Boolean, path: String? = null) { + val text = ctx.document.text + val offset = ctx.startOffset.coerceAtMost(text.length) + val start = (offset - 1 downTo 0).firstOrNull { text[it].isWhitespace() }?.plus(1) ?: 0 + val end = tokenEnd(text, start) + val next = text.getOrNull(end) + val insert = if (trim && next?.isWhitespace() == true) value.trimEnd() else value + path?.let { + paths.add(it) + exists[it] = true + } + ctx.document.replaceString(start, end, insert) + ctx.editor.caretModel.moveToOffset(start + insert.length) + } + + private fun tokenEnd(text: String, start: Int): Int = + (start until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length + + private fun parent(path: String): String { + val idx = path.lastIndexOf('/') + if (idx <= 0) return "" + return " ${path.substring(0, idx)}" + } + + private fun token(text: String, offset: Int): Token? { + val pos = offset.coerceIn(0, text.length) + val start = (pos - 1 downTo 0).firstOrNull { text[it].isWhitespace() }?.plus(1) ?: 0 + val end = (pos until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length + val head = text.substring(start, pos) + val raw = text.substring(start, end) + if (raw.startsWith("/") && text.take(start).isBlank() && raw.indexOf(' ') < 0) return Token(Kind.SLASH, head.drop(1)) + if (raw.startsWith("@") && raw.indexOf(' ') < 0) return Token(Kind.MENTION, head.drop(1)) + return null + } + + private data class Token(val kind: Kind, val prefix: String) + + private fun mentionSpans(text: String): List = promptMentions(text) + + private enum class Kind { SLASH, MENTION } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/MentionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/MentionAction.kt new file mode 100644 index 00000000000..308b0f846c6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/MentionAction.kt @@ -0,0 +1,33 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.rpc.dto.FileSearchResultDto + +data class MentionAction( + val name: String, + val description: String, + val hints: List = emptyList(), + val available: (FileSearchResultDto) -> Boolean, +) { + data class Spec( + val name: String, + val descriptionKey: String, + val hints: List = emptyList(), + val filename: String, + val uri: String, + val available: (FileSearchResultDto) -> Boolean, + ) { + val token: String get() = "@$name" + } + + companion object { + val GIT_CHANGES = Spec( + "git-changes", + "prompt.mention.gitChanges", + filename = "git-changes.txt", + uri = "git-changes", + available = { it.git }, + ) + + val ALL = listOf(GIT_CHANGES) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/MentionNavigator.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/MentionNavigator.kt new file mode 100644 index 00000000000..5efe9a872f8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/MentionNavigator.kt @@ -0,0 +1,103 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.event.EditorMouseEvent +import com.intellij.openapi.editor.event.EditorMouseListener +import com.intellij.openapi.editor.event.EditorMouseMotionListener +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Cursor +import java.awt.event.MouseEvent + +/** + * Makes resolved `@file` mentions behave like the platform's "Go to File": + * + * - Holding the navigation modifier (Cmd on macOS, Ctrl elsewhere) and hovering a resolved + * mention paints it as a hyperlink and shows the hand cursor; clicking opens the file. + * - Go to Declaration (Cmd-B / Ctrl-B) opens the mention under the caret. + * - Hovering an unresolved (red) mention shows a tooltip explaining why it cannot be resolved. + * + * Implemented locally on the frontend editor — like completion and undo here — because the + * platform navigation machinery targets the backend-shared editor in split mode. + */ +internal class MentionNavigator( + private val editor: EditorEx, + private val provider: KiloPromptCompletionProvider, +) : EditorMouseListener, EditorMouseMotionListener { + + private var link: RangeHighlighter? = null + + @RequiresEdt + fun install() { + editor.addEditorMouseListener(this) + editor.addEditorMouseMotionListener(this) + val base = ActionManager.getInstance().getAction(IdeActions.ACTION_GOTO_DECLARATION) ?: return + object : DumbAwareAction(KiloBundle.message("prompt.mention.goto")) { + override fun actionPerformed(e: AnActionEvent) { + val hit = provider.mentionAt(editor.document.text, editor.caretModel.offset) ?: return + if (hit.resolved) provider.navigate(hit.value) + } + }.registerCustomShortcutSet(base.shortcutSet, editor.contentComponent) + } + + override fun mouseMoved(e: EditorMouseEvent) { + val hit = if (e.isOverText) provider.mentionAt(editor.document.text, e.offset) else null + syncTooltip(hit) + syncLink(hit, e.mouseEvent) + } + + override fun mouseClicked(e: EditorMouseEvent) { + if (!modifier(e.mouseEvent) || !e.isOverText) return + val hit = provider.mentionAt(editor.document.text, e.offset) ?: return + if (!hit.resolved) return + e.consume() + provider.navigate(hit.value) + } + + override fun mouseExited(e: EditorMouseEvent) { + syncTooltip(null) + clearLink() + } + + private fun syncTooltip(hit: KiloPromptCompletionProvider.MentionHit?) { + val text = hit?.takeIf { !it.resolved }?.let { KiloBundle.message("prompt.mention.unresolved", it.value) } + if (editor.contentComponent.toolTipText != text) editor.contentComponent.toolTipText = text + } + + private fun syncLink(hit: KiloPromptCompletionProvider.MentionHit?, mouse: MouseEvent) { + val active = hit?.takeIf { it.resolved && modifier(mouse) } + if (active == null) { + clearLink() + return + } + if (link?.startOffset == active.start && link?.endOffset == active.end) return + clearLink() + val attributes = EditorColorsManager.getInstance().globalScheme.getAttributes(EditorColors.REFERENCE_HYPERLINK_COLOR) + link = editor.markupModel.addRangeHighlighter( + active.start, + active.end, + HighlighterLayer.HYPERLINK, + attributes, + HighlighterTargetArea.EXACT_RANGE, + ) + editor.setCustomCursor(MentionNavigator::class.java, Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + } + + private fun clearLink() { + link?.let(editor.markupModel::removeHighlighter) + link = null + editor.setCustomCursor(MentionNavigator::class.java, null) + } + + private fun modifier(e: MouseEvent) = if (SystemInfo.isMac) e.isMetaDown else e.isControlDown +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt new file mode 100644 index 00000000000..e6b0ffbae5f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.session.ui.prompt + +import com.intellij.ide.PasteProvider +import com.intellij.ide.dnd.FileCopyPasteUtil +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.editor.actions.PasteAction +import com.intellij.openapi.util.Key +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable + +internal fun interface PromptAttachmentPasteHandler { + fun paste(transferable: Transferable) +} + +internal val PROMPT_ATTACHMENT_PASTE_HANDLER_KEY: Key = + Key.create("ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler") + +internal class PromptAttachmentPasteProvider : PasteProvider { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun isPastePossible(dataContext: DataContext): Boolean = transferable(dataContext) != null + + override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext) + + override fun performPaste(dataContext: DataContext) { + val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return + val handler = editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) ?: return + val item = transferable(dataContext) ?: return + handler.paste(item) + } + + private fun transferable(dataContext: DataContext): Transferable? { + val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return null + if (editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) == null) return null + val item = dataContext.getData(PasteAction.TRANSFERABLE_PROVIDER)?.produce() ?: return null + if (FileCopyPasteUtil.isFileListFlavorAvailable(item.transferDataFlavors)) return item + if (item.isDataFlavorSupported(DataFlavor.imageFlavor)) return item + return null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt new file mode 100644 index 00000000000..9efa44aabf1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.PromptAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.ui.UiStyle +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.FlowLayout +import javax.swing.JPanel + +class PromptAttachmentStrip( + private val project: Project, + private val removed: (PromptAttachment) -> Unit, +) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), UiStyle.Gap.sm())) { + private val chips = LinkedHashMap() + + init { + border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + isVisible = false + } + + val count: Int get() = chips.size + + @RequiresEdt + fun add(item: PromptAttachment) { + if (chips.containsKey(item.id)) return + val chip = PromptAttachmentChip(project, item, remove = { removed(item) }) + chips[item.id] = chip + add(chip) + sync() + } + + @RequiresEdt + fun remove(item: PromptAttachment) { + val chip = chips.remove(item.id) ?: return + remove(chip) + sync() + } + + @RequiresEdt + fun clear() { + if (chips.isEmpty()) return + chips.clear() + removeAll() + sync() + } + + @RequiresEdt + private fun sync() { + isVisible = chips.isNotEmpty() + revalidate() + repaint() + } +} + +private class PromptAttachmentChip( + project: Project, + item: PromptAttachment, + remove: () -> Unit, +) : AttachmentCard( + AttachmentCardItem(item.name, item.mime, item.url, item.path), + remove = remove, + open = { open(project, item) }, +) { + companion object { + private fun open(project: Project, item: PromptAttachment) { + val path = item.path ?: return + ApplicationManager.getApplication().executeOnPooledThread { + val file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path) + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + if (file == null) { + Notification("Kilo Code", KiloBundle.message("prompt.attachment.missing", item.name), NotificationType.WARNING).notify(project) + return@invokeLater + } + FileEditorManager.getInstance(project).openFile(file, true) + } + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt index c5aaa89b63f..b6db14ab92f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt @@ -1,9 +1,13 @@ package ai.kilocode.client.session.ui.prompt import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.project.Project +import com.intellij.util.textCompletion.TextCompletionProvider internal class PromptEditorTextField( project: Project, ctx: SendPromptContext, -) : SessionEditorTextField(project, ctx) + completion: TextCompletionProvider?, + selection: SessionSelection? = null, +) : SessionEditorTextField(project, ctx, completion, selection) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt new file mode 100644 index 00000000000..3fbddc5b497 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto +import ai.kilocode.rpc.dto.PromptPartDto +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Path + +data class Mention(val value: String, val start: Int, val end: Int) + +fun promptMentions(text: String): List = buildList { + var pos = 0 + while (pos < text.length) { + val start = (pos until text.length).firstOrNull { !text[it].isWhitespace() } ?: return@buildList + val end = tokenEnd(text, start) + if (text[start] == '@' && end > start + 1) add(Mention(text.substring(start + 1, end), start, end)) + pos = end + 1 + } +} + +fun fileMentions(text: String, reserved: Set): List { + val seen = mutableSetOf() + return promptMentions(text).filter { item -> item.value !in reserved && seen.add(item.value) } +} + +suspend fun mentionParts( + text: String, + directory: String, + reserved: Set, + resolve: suspend (String) -> Boolean, + gitChanges: suspend () -> String?, +): List = coroutineScope { + val files = fileMentions(text, reserved) + val paths = files.map { item -> async { item.value to resolve(item.value) } } + .mapNotNullTo(mutableSetOf()) { item -> item.await().takeIf { it.second }?.first } + buildList { + addAll(mentionFileParts(text, paths, directory)) + if (text.contains(MentionAction.GIT_CHANGES.token)) gitChangesPart(text, gitChanges())?.let(::add) + } +} + +fun mentionFileParts(text: String, paths: Set, directory: String): List = buildList { + fileMentions(text, emptySet()).filter { item -> item.value in paths }.forEach { mention -> + val target = runCatching { + val item = Path.of(mention.value) + if (item.isAbsolute) item else Path.of(directory).resolve(item).normalize() + }.getOrNull() ?: return@forEach + add(PromptPartDto( + type = "file", + mime = "text/plain", + url = target.toUri().toString(), + filename = target.fileName?.toString(), + source = source("file", "@${mention.value}", mention.start, path = mention.value), + )) + } +} + +fun gitChangesPart(text: String, diff: String?): PromptPartDto? { + val spec = MentionAction.GIT_CHANGES + val start = text.mentionStart(spec.token) ?: return null + val value = diff?.takeIf { it.isNotBlank() } ?: return null + return dataPart(spec.filename, value, source("file", spec.token, start, path = spec.uri)) +} + +private fun String.mentionStart(token: String): Int? = promptMentions(this) + .firstOrNull { item -> "@${item.value}" == token } + ?.start + +private fun tokenEnd(text: String, start: Int): Int = + (start until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length + +private fun dataPart(name: String, text: String, source: PartSourceDto? = null): PromptPartDto { + val data = URLEncoder.encode(text, StandardCharsets.UTF_8).replace("+", "%20") + return PromptPartDto(type = "file", mime = "text/plain", url = "data:text/plain;charset=utf-8,$data", filename = name, source = source) +} + +private fun source( + type: String, + token: String, + start: Int, + path: String? = null, +) = PartSourceDto( + type = type, + text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()), + path = path, +) 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 811847628f0..676c265fca7 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 @@ -1,84 +1,158 @@ package ai.kilocode.client.session.ui.prompt +import ai.kilocode.client.KiloNotifications import ai.kilocode.client.actions.SendPromptAction import ai.kilocode.client.actions.StopSessionAction import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.ReasoningPicker +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.model.PromptAttachment +import ai.kilocode.client.session.model.PromptAttachmentExtractor import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.ui.HoverIcon -import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.PromptPartDto import com.intellij.icons.AllIcons +import com.intellij.codeInsight.completion.CodeCompletionHandlerBase +import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.lookup.LookupEx +import com.intellij.codeInsight.lookup.LookupManagerListener +import com.intellij.codeInsight.lookup.LookupPositionStrategy +import com.intellij.codeInsight.lookup.LookupPresentation import com.intellij.ide.DataManager +import com.intellij.ide.dnd.DnDEvent +import com.intellij.ide.dnd.DnDSupport +import com.intellij.ide.dnd.FileCopyPasteUtil import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider 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.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.editor.event.CaretListener +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter import com.intellij.openapi.keymap.Keymap import com.intellij.openapi.keymap.KeymapManagerListener import com.intellij.openapi.keymap.KeymapUtil import com.intellij.openapi.project.Project +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.util.IconLoader +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.JBColor +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.xml.util.XmlStringUtil -import com.intellij.util.ui.JBValue import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import com.intellij.util.messages.MessageBusConnection +import kotlinx.coroutines.CancellationException import java.awt.BorderLayout import java.awt.Cursor import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable import java.awt.event.FocusAdapter import java.awt.event.FocusEvent +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.util.concurrent.Future import javax.swing.Box import javax.swing.BoxLayout import javax.swing.Icon import javax.swing.JButton import javax.swing.JComponent import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities /** - * Prompt input panel with borderless IntelliJ editor text field and - * mode/model controls grouped inside one rounded editor-background shell. + * Prompt input panel with a borderless IntelliJ editor text field and + * mode/model controls in the full bottom session area. */ class PromptPanel( private val project: Project, - private val onSend: (String) -> Unit, + private val onSend: (String, List) -> Unit, private val onAbort: () -> Unit, -) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext { + private val onEnhance: (String, (Result) -> Unit) -> Unit, + private val onMentions: (String) -> List = { emptyList() }, + private val completion: KiloPromptCompletionProvider? = null, + private val selection: SessionSelection? = null, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext, UiDataProvider { companion object { private val LOG = KiloLog.create(PromptPanel::class.java) private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java) private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java) + private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java) + private val SHIELD_FILLED_ICON: Icon = IconLoader.getIcon("/icons/shield-filled.svg", PromptPanel::class.java) + private val WAND_ICON: Icon = IconLoader.getIcon("/icons/wand-sparkles.svg", PromptPanel::class.java) + private val MENTION_KEY = DefaultLanguageHighlighterColors.METADATA + private val COMMAND_KEY = DefaultLanguageHighlighterColors.KEYWORD + private val INVALID_KEY = CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES } val mode = ModePicker() - val model = ModelPicker() + val model = ModelPicker().apply { + placement = ModelPicker.Placement.ABOVE + } val reasoning = ReasoningPicker() var onReset: () -> Unit = {} + var onChange: () -> Unit = {} + var onAutoApproveToggle: (Boolean) -> Unit = {} + var onFileDrag: (Boolean) -> Unit = {} private var style = SessionEditorStyle.current() - private val shell = PromptShell() + private val shell = BorderLayoutPanel().apply { + isOpaque = true + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + } + private val attachments = mutableListOf() + private val highlighters = mutableListOf() + private val strip = PromptAttachmentStrip(project) { removeAttachment(it) } private var bus: MessageBusConnection? = null + private var lookupBus: MessageBusConnection? = null + private var completionAction: AnAction? = null + private var completionTarget: JComponent? = null + private var mentionCaret = false + private var autoApprove = false + private var attachment = true + private var submitting = false + private var root: SessionRootPanel? = null + private val resize = object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + syncEditorHeight() + } + } - private val editor = PromptEditorTextField(project, this).apply { + private val editor = PromptEditorTextField(project, this, completion, selection).apply { border = JBUI.Borders.empty() setFontInheritedFromLAF(false) setPlaceholder(placeholder()) @@ -93,16 +167,34 @@ class PromptPanel( 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 + ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) }) + ed.scrollPane.verticalScrollBarPolicy = + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + installCompletionShortcut(ed) + completion?.let { MentionNavigator(ed, it).install() } + installFileDrop(ed.contentComponent, "editor") + installFileDrop(ed.scrollPane, "scroll") + syncHighlights() + ed.caretModel.addCaretListener(object : CaretListener { + override fun caretPositionChanged(e: CaretEvent) { + val provider = completion ?: return + val inside = provider.inside(ed.document.text, ed.caretModel.offset) + if (mentionCaret == inside) return + mentionCaret = inside + syncHighlights() + } + }) ed.contentComponent.addFocusListener(object : FocusAdapter() { override fun focusGained(e: FocusEvent) { - shell.repaint() + repaint() } override fun focusLost(e: FocusEvent) { - shell.repaint() + repaint() } }) } @@ -131,28 +223,45 @@ class PromptPanel( addActionListener { onReset() } } + private val auto = AutoApproveButton().apply { + icon = SHIELD_ICON + addActionListener { onAutoApproveToggle(!autoApprove) } + } + + private val enhancingIcon = AnimatedIcon.Default() + private val enhance = HoverIcon().apply { + icon = WAND_ICON + toolTipText = KiloBundle.message("prompt.action.enhance") + accessibleContext.accessibleName = KiloBundle.message("prompt.action.enhance") + addActionListener { enhance() } + } + @Volatile private var busy = false private var ready = false + private var enhancing = false + private var request = 0L override val isSendEnabled: Boolean - get() = ready && !busy && text().isNotEmpty() + get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) override val isStopEnabled: Boolean get() = busy init { - border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(JBUI.CurrentTheme.ToolWindow.borderColor()), - JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), - ), - ) - applyStyle(style) + selection?.register(editor) + editor.text = "" + editor.addDocumentListener(object : DocumentListener { + override fun documentChanged(e: DocumentEvent) { + invalidateEnhancement() + syncEditorHeight() + triggerCompletion(e) + syncHighlights() + onChange() + } + }) + shell.add(strip, BorderLayout.NORTH) shell.add(editor, BorderLayout.CENTER) val bar = BorderLayoutPanel().apply { @@ -168,34 +277,71 @@ class PromptPanel( bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(reset) bar.add(Box.createHorizontalGlue()) + bar.add(auto) + bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) + bar.add(enhance) + bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(button) shell.add(bar, BorderLayout.SOUTH) add(shell, BorderLayout.CENTER) + addComponentListener(resize) + installFileDrop(shell, "shell") syncTooltip() + syncAutoApprove() + syncEnhance() } + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.compound( + JBUI.Borders.customLineTop(separator()), + JBUI.Borders.empty(), + ) + } + + @RequiresEdt fun setReady(value: Boolean) { ready = value + if (value) completion?.prewarm() + if (!value) invalidateEnhancement() else syncEnhance() + } + + @RequiresEdt + fun setAttachmentEnabled(value: Boolean) { + attachment = value } + @RequiresEdt fun setBusy(value: Boolean) { busy = value + if (value) invalidateEnhancement() else syncEnhance() button.icon = if (value) STOP_ICON else SEND_ICON syncTooltip() } + @RequiresEdt + fun setAutoApprove(value: Boolean) { + if (autoApprove == value) return + autoApprove = value + syncAutoApprove() + } + + @RequiresEdt fun setResetVisible(value: Boolean) { reset.isVisible = value revalidate() repaint() } + @RequiresEdt fun text(): String = editor.text.trim() + @RequiresEdt override fun send() { submit("action") } + @RequiresEdt override fun stop() { if (!isStopEnabled) return onAbort() @@ -211,49 +357,353 @@ class PromptPanel( internal fun buttonForTest(): JButton = button + internal fun attachmentCountForTest(): Int = attachments.size + internal val defaultFocusedComponent: JComponent get() = editor + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { editor.text } + } + + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style - editor.font = style.transcriptFont + background = style.editorScheme.defaultBackground + shell.background = style.editorScheme.defaultBackground + editor.font = style.editorFont editor.getEditor(false)?.let(style::applyToEditor) editor.background = style.editorScheme.defaultBackground - val height = style.transcriptFont.size * SessionUiStyle.View.Prompt.EDITOR_LINES + JBUI.scale( - SessionUiStyle.View.Prompt.EDITOR_CHROME) - editor.preferredSize = JBDimension(0, height) - editor.minimumSize = JBDimension(0, height) - revalidate() - repaint() + syncEditorHeight() + syncAutoApprove() + syncHighlights() } + @RequiresEdt + fun refreshHighlights() { + syncHighlights() + } + + @RequiresEdt fun clear() { editor.text = "" + attachments.clear() + completion?.clearMentions() + completion?.prewarm() + strip.clear() + syncEditorHeight() + syncHighlights() + } + + @RequiresEdt + private fun syncHighlights() { + val provider = completion ?: return + val ed = editor.getEditor(false) ?: return + highlighters.forEach(ed.markupModel::removeHighlighter) + highlighters.clear() + val length = ed.document.textLength + val text = ed.document.text + val caret = ed.caretModel.offset + provider.validate(text, caret) { + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) refreshHighlights() + } + } + provider.highlights(text, caret).forEach { item -> + val start = item.start.coerceIn(0, length) + val end = item.end.coerceIn(start, length) + if (start == end) return@forEach + highlighters += ed.markupModel.addRangeHighlighter( + key(item.kind), + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } + mentionCaret = provider.inside(text, caret) + } + + private fun key(kind: KiloPromptCompletionProvider.HighlightKind): TextAttributesKey = when (kind) { + KiloPromptCompletionProvider.HighlightKind.MENTION -> MENTION_KEY + KiloPromptCompletionProvider.HighlightKind.COMMAND -> COMMAND_KEY + KiloPromptCompletionProvider.HighlightKind.INVALID -> INVALID_KEY } + @RequiresEdt + fun addAttachmentForTest(item: PromptAttachment) { + addAttachment(item) + } + + internal fun processPasteForTest(transferable: Transferable): Future<*> = processPaste(transferable) + + @RequiresEdt fun focus() { editor.requestFocusInWindow() } override fun addNotify() { super.addNotify() + bindRoot() bindKeymap() + bindLookup() } override fun removeNotify() { + root?.removeComponentListener(resize) + root = null bus?.disconnect() bus = null + lookupBus?.disconnect() + lookupBus = null + uninstallCompletionShortcut() super.removeNotify() } + @RequiresEdt + private fun enhance() { + if (!enhance.isEnabled) return + val source = editor.text + if (source.isBlank()) { + editor.text = KiloBundle.message("prompt.action.enhance.description") + syncEditorHeight() + focus() + return + } + val id = ++request + enhancing = true + syncEnhance() + onEnhance(source.trim()) { result -> completeEnhancement(id, source, result) } + } + + @RequiresEdt + private fun completeEnhancement(id: Long, source: String, result: Result) { + if (id != request || editor.text != source) return + enhancing = false + syncEnhance() + result.onSuccess { + editor.text = it + syncEditorHeight() + focus() + }.onFailure { + if (it is CancellationException) return@onFailure + KiloNotifications.error( + project, + KiloBundle.message("prompt.action.enhance.failed"), + KiloBundle.message("prompt.action.enhance.failed.description"), + ) + } + } + + @RequiresEdt + private fun invalidateEnhancement() { + request++ + enhancing = false + syncEnhance() + } + + @RequiresEdt + private fun syncEnhance() { + enhance.isEnabled = ready && !busy && !enhancing + enhance.icon = if (enhancing) enhancingIcon else WAND_ICON + enhance.toolTipText = if (enhancing) { + KiloBundle.message("prompt.action.enhance.loading") + } else { + KiloBundle.message("prompt.action.enhance") + } + } + + @RequiresEdt private fun submit(src: String) { if (!isSendEnabled) return val txt = text() - LOG.debug { "${ChatLogSummary.prompt(txt)} src=$src busy=$busy" } - if (txt.isNotEmpty()) { - onSend(txt) + val items = attachments.toList() + submitting = true + ApplicationManager.getApplication().executeOnPooledThread { + try { + val files = items.map { it.part() } + val mentioned = onMentions(txt) + ApplicationManager.getApplication().invokeLater { + submitting = false + if (project.isDisposed) return@invokeLater + val parts = files + mentioned + LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, parts))} src=$src busy=$busy" } + onSend(txt, parts) + } + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + submitting = false + if (project.isDisposed) return@invokeLater + LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e) + notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName)) + } + } + } + } + + private fun triggerCompletion(e: DocumentEvent) { + if (project.isDisposed) return + val value = e.newFragment.toString() + if (value.length != 1) return + val text = editor.text + val offset = e.offset + value.length + val popup = value == "@" || (value == "/" && text.take(offset).trim() == "/") + if (!popup) return + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + editor.getEditor(false)?.let(::showCompletion) } } + @RequiresEdt + @Suppress("UnstableApiUsage") + private fun showCompletion(ed: com.intellij.openapi.editor.Editor) { + // Run completion synchronously and locally on this frontend-only editor. The public entry + // points (ACTION_CODE_COMPLETION, AutoPopupController.scheduleAutoPopup) route through + // split-mode RD/autopopup machinery that targets a backend-shared editor: in split mode they + // either dispatch to the backend (which has no editor and asserts) or get cancelled before the + // popup shows. CodeCompletionHandlerBase is public (not @ApiStatus.Internal) and is the only + // API that drives the completion engine directly against this local editor. + CodeCompletionHandlerBase.createHandler(CompletionType.BASIC, true, false, true) + .invokeCompletion(project, ed, 1) + } + + @RequiresEdt + private fun installCompletionShortcut(ed: com.intellij.openapi.editor.Editor) { + if (completion == null) return + val base = ActionManager.getInstance().getAction(IdeActions.ACTION_CODE_COMPLETION) ?: return + val action = completionAction ?: object : DumbAwareAction(KiloBundle.message("prompt.completion.action")) { + override fun actionPerformed(e: AnActionEvent) { + editor.getEditor(false)?.let(::showCompletion) + } + }.also { completionAction = it } + uninstallCompletionShortcut() + val target = ed.contentComponent + action.registerCustomShortcutSet(base.shortcutSet, target) + completionTarget = target + } + + @RequiresEdt + private fun refreshCompletionShortcut() { + val ed = editor.getEditor(false) ?: return + installCompletionShortcut(ed) + } + + @RequiresEdt + private fun uninstallCompletionShortcut() { + val target = completionTarget ?: return + completionAction?.unregisterCustomShortcutSet(target) + completionTarget = null + } + + @RequiresEdt + private fun addAttachment(item: PromptAttachment) { + if (!attachment && PromptAttachmentExtractor.media(item.mime)) { + LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" } + notify(KiloBundle.message("prompt.attachment.unsupported.model")) + return + } + if (attachments.any { it.id == item.id }) { + LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=duplicate" } + return + } + attachments += item + strip.add(item) + LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} count=${attachments.size}" } + syncEditorHeight() + onChange() + } + + @RequiresEdt + private fun removeAttachment(item: PromptAttachment) { + if (!attachments.removeIf { it.id == item.id }) return + strip.remove(item) + syncEditorHeight() + onChange() + } + + private fun promptDto(text: String, files: List) = ai.kilocode.rpc.dto.PromptDto( + parts = buildList { + text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } + addAll(files) + } + ) + + internal fun installFileDrop(target: JComponent, area: String) { + LOG.debug { "kind=prompt-dnd install area=$area component=${target.javaClass.name}" } + DnDSupport.createBuilder(target) + .enableAsNativeTarget() + .setTargetChecker { event -> + if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) { + onFileDrag(false) + LOG.debug { "kind=prompt-dnd check area=$area accept=false flavor=false" } + return@setTargetChecker true + } + event.setDropPossible(true) + onFileDrag(true) + LOG.debug { "kind=prompt-dnd check area=$area accept=true flavor=true" } + false + } + .setCleanUpOnLeaveCallback { + onFileDrag(false) + } + .setDropHandlerWithResult { event -> + val start = System.nanoTime() + val files = dropFiles(event) + val ms = elapsedMs(start) + LOG.debug { "kind=prompt-dnd drop area=$area files=${files.size} extractMs=$ms queued=${files.isNotEmpty()}" } + onFileDrag(false) + if (files.isEmpty()) return@setDropHandlerWithResult false + processAttachments("prompt-dnd", area, files, null, ms) + true + } + .install() + } + + private fun processPaste(transferable: Transferable): Future<*> { + return processAttachments("prompt-paste", "editor", null, transferable, 0) + } + + private fun processAttachments( + kind: String, + area: String, + files: List?, + transferable: Transferable?, + sourceMs: Long, + ): Future<*> { + return ApplicationManager.getApplication().executeOnPooledThread { + val start = System.nanoTime() + try { + val list = files ?: transferable?.let { FileCopyPasteUtil.getFileList(it).orEmpty() }.orEmpty() + val image = transferable?.takeIf { list.isEmpty() && it.isDataFlavorSupported(DataFlavor.imageFlavor) } + ?.getTransferData(DataFlavor.imageFlavor) + ?.let(PromptAttachmentExtractor::image) + val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image) + val ms = elapsedMs(start) + LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" } + if (items.isEmpty()) return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" } + items.forEach(::addAttachment) + } + } catch (e: Exception) { + LOG.warn("kind=$kind extract area=$area failed message=${e.message}", e) + } + } + } + + private fun dropFiles(event: DnDEvent): List { + if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList() + return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty() + } + + private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000 + + private fun notify(text: String) { + com.intellij.notification.Notification("Kilo Code", text, com.intellij.notification.NotificationType.WARNING).notify(project) + } + + @RequiresEdt private fun bindKeymap() { if (bus != null) return val connection = ApplicationManager.getApplication().messageBus.connect() @@ -262,6 +712,7 @@ class PromptPanel( override fun activeKeymapChanged(keymap: Keymap?) { editor.setPlaceholder(placeholder()) syncTooltip() + refreshCompletionShortcut() } override fun shortcutsChanged(keymap: Keymap, actionIds: Collection, fromSettings: Boolean) { @@ -270,14 +721,48 @@ class PromptPanel( editor.setPlaceholder(placeholder()) syncTooltip() } + if (IdeActions.ACTION_CODE_COMPLETION in actionIds) refreshCompletionShortcut() } }) } + @RequiresEdt + private fun bindLookup() { + if (lookupBus != null) return + val connection = project.messageBus.connect() + lookupBus = connection + connection.subscribe(LookupManagerListener.TOPIC, LookupManagerListener { _, next -> + // LookupPresentation/LookupPositionStrategy are @ApiStatus.Experimental (no stable API + // forces the lookup above the caret). LookupEx is the public lookup interface. + val lookup = next as? LookupEx ?: return@LookupManagerListener + if (lookup.editor !== editor.getEditor(false)) return@LookupManagerListener + lookup.presentation = LookupPresentation.Builder(lookup.presentation) + .withPositionStrategy(LookupPositionStrategy.ONLY_ABOVE) + .build() + }) + } + + @RequiresEdt private fun syncTooltip() { button.toolTipText = tooltip() } + private fun syncAutoApprove() { + auto.isSelected = autoApprove + auto.icon = if (autoApprove) SHIELD_FILLED_ICON else SHIELD_ICON + auto.toolTipText = if (autoApprove) { + KiloBundle.message("prompt.action.autoApprove.enabled.tooltip") + } else { + KiloBundle.message("prompt.action.autoApprove.disabled.tooltip") + } + auto.accessibleContext.accessibleName = if (autoApprove) { + KiloBundle.message("prompt.action.autoApprove.disable") + } else { + KiloBundle.message("prompt.action.autoApprove.enable") + } + auto.repaint() + } + private fun tooltip(): String { val id = if (busy) StopSessionAction.ID else SendPromptAction.ID val text = if (busy) { @@ -306,7 +791,57 @@ class PromptPanel( return KiloBundle.message("prompt.placeholder") } - private inner class SendButton : JButton(), UiDataProvider { + private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor()) + + @RequiresEdt + private fun syncEditorHeight() { + val before = editor.preferredSize.height + val lower = editor.minimumSize.height + editor.setPreferredSize(null) + editor.setMinimumSize(null) + editor.getEditor(false)?.let { + it.contentComponent.invalidate() + it.component.invalidate() + it.scrollPane.invalidate() + } + editor.invalidate() + editor.ensureWillComputePreferredSize() + val view = editor.getEditor(false) + val line = view?.lineHeight ?: editor.getFontMetrics(editor.font).height + val min = line * SessionUiStyle.View.Prompt.EDITOR_LINES + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME) + val content = editor.preferredSize.height + val sessionCap = rootCap(min) + val height = minOf(content, sessionCap ?: content).coerceAtLeast(min) + if (before == height && lower == height) { + editor.preferredSize = JBDimension(0, height) + editor.minimumSize = JBDimension(0, height) + return + } + editor.preferredSize = JBDimension(0, height) + editor.minimumSize = JBDimension(0, height) + revalidate() + repaint() + } + + @RequiresEdt + private fun rootCap(min: Int): Int? { + val root = root ?: return null + if (root.height <= 0) return null + val chrome = (shell.preferredSize.height - editor.preferredSize.height).coerceAtLeast(0) + return (root.height / 3 - chrome).coerceAtLeast(min) + } + + @RequiresEdt + private fun bindRoot() { + val next = SwingUtilities.getAncestorOfClass(SessionRootPanel::class.java, this) as? SessionRootPanel + if (root === next) return + root?.removeComponentListener(resize) + root = next + root?.addComponentListener(resize) + syncEditorHeight() + } + + private inner abstract class PromptButton : JButton() { private var over = false init { @@ -328,29 +863,27 @@ class PromptPanel( SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE, ) - override fun uiDataSnapshot(sink: DataSink) { - sink.set(PromptDataKeys.SEND, this@PromptPanel) - } - override fun getMinimumSize() = preferredSize override fun getMaximumSize() = preferredSize override fun paintComponent(g: Graphics) { - if (over) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() - val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) - g2.fillRoundRect(0, 0, width, height, arc, arc) - } finally { - g2.dispose() - } - } + if (over) paintHover(g) super.paintComponent(g) } + private fun paintHover(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + } + private fun sync(value: Boolean) { if (over == value) return over = value @@ -358,22 +891,12 @@ class PromptPanel( } } - private inner class PromptShell : RoundedContentPanel( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) { - private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH) - - override fun contentColor() = style.editorScheme.defaultBackground - - override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { - JBUI.CurrentTheme.Focus.focusColor() - } else { - SessionUiStyle.View.line() + private inner class SendButton : PromptButton(), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + sink.set(PromptDataKeys.SEND, this@PromptPanel) } + } - override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1) + private inner class AutoApproveButton : PromptButton() - override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SlashAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SlashAction.kt new file mode 100644 index 00000000000..c64adaf30ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/SlashAction.kt @@ -0,0 +1,36 @@ +package ai.kilocode.client.session.ui.prompt + +data class SlashAction( + val name: String, + val description: String, + val hints: List = emptyList(), + val action: () -> Unit, +) { + data class Spec( + val name: String, + val descriptionKey: String, + val hints: List = emptyList(), + ) + + companion object { + val NEW = Spec("new", "prompt.slash.new", listOf("clear")) + val SESSIONS = Spec("sessions", "prompt.slash.sessions", listOf("history", "resume", "continue")) + val MODELS = Spec("models", "prompt.slash.models") + val AGENTS = Spec("agents", "prompt.slash.agents", listOf("modes")) + val VARIANT = Spec("variant", "prompt.slash.variant", listOf("reasoning", "variants", "thinking")) + val COMPACT = Spec("compact", "prompt.slash.compact", listOf("smol", "condense")) + val SETTINGS = Spec("settings", "prompt.slash.settings") + val HELP = Spec("help", "prompt.slash.help") + + val ALL = listOf( + NEW, + SESSIONS, + MODELS, + AGENTS, + VARIANT, + COMPACT, + SETTINGS, + HELP, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt new file mode 100644 index 00000000000..0a34cee1eb2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.ide.DataManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.AWTEvent +import java.awt.Component +import java.awt.Point +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.SwingUtilities + +internal object SessionContextMenu { + private val KEY = Any() + private const val ID = "Kilo.Session.ContextMenu" + + @RequiresEdt + fun install(root: JComponent, parent: Disposable) { + if (root.getClientProperty(KEY) == true) return + val listener = AWTEventListener { event -> + val mouse = event as? MouseEvent ?: return@AWTEventListener + if (!mouse.isPopupTrigger) return@AWTEventListener + if (mouse.id != MouseEvent.MOUSE_PRESSED && mouse.id != MouseEvent.MOUSE_RELEASED) return@AWTEventListener + show(root, mouse) + } + Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK) + root.putClientProperty(KEY, true) + Disposer.register(parent) { + Toolkit.getDefaultToolkit().removeAWTEventListener(listener) + root.putClientProperty(KEY, null) + } + } + + @RequiresEdt + internal fun target(root: JComponent, src: Component, point: Point): Component? = + SessionTargetResolver.target(root, src, point) + + @RequiresEdt + private fun show(root: JComponent, event: MouseEvent) { + if (!root.isShowing) return + val src = event.component ?: return + val target = target(root, src, event.point) ?: return + val group = ActionManager.getInstance().getAction(ID) as? ActionGroup ?: return + val ctx = DataManager.getInstance().getDataContext(target) + val popup = JBPopupFactory.getInstance().createActionGroupPopup( + null, + group, + ctx, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ) + val point = SwingUtilities.convertPoint(src, event.point, target) + popup.show(RelativePoint(target, point)) + event.consume() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyButton.kt new file mode 100644 index 00000000000..519d850cd9d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyButton.kt @@ -0,0 +1,56 @@ +package ai.kilocode.client.session.ui.selection + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.HoverIcon +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Cursor +import java.awt.Point +import java.awt.datatransfer.StringSelection +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent + +internal class SessionCopyButton( + fill: Boolean = false, + private val text: () -> String?, +) { + private var balloon: Balloon? = null + val button = HoverIcon(fill = fill).apply { + icon = AllIcons.Actions.Copy + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("session.copy.hover") + } + + init { + button.addActionListener { copy() } + button.addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) { + dismiss() + } + }) + } + + @RequiresEdt + fun dismiss() { + balloon?.hide() + balloon = null + } + + @RequiresEdt + fun copy() { + val value = text()?.takeIf { it.isNotEmpty() } ?: return + CopyPasteManager.getInstance().setContents(StringSelection(value)) + dismiss() + balloon = JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder(KiloBundle.message("session.copy.copied"), null, null, null) + .createBalloon() + .also { item -> + item.setAnimationEnabled(false) + item.show(RelativePoint(button, Point(button.width / 2, 0)), Balloon.Position.above) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt new file mode 100644 index 00000000000..d4528e833c0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt @@ -0,0 +1,11 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +internal interface SessionCopyTarget { + val copyAnchor: JComponent + + @RequiresEdt + fun copyText(): String? +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt new file mode 100644 index 00000000000..3b9d55aedd5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt @@ -0,0 +1,129 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.AWTEvent +import java.awt.Component +import java.awt.Point +import java.awt.Rectangle +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities + +internal class SessionHoverCopyOverlay( + private val root: JComponent, + parent: Disposable, +) : JPanel(null), Disposable { + private var target: SessionCopyTarget? = null + private val copy = SessionCopyButton(fill = true) { target?.copyText() } + private val button = copy.button + + init { + isVisible = false + isOpaque = false + add(button) + + val listener = AWTEventListener { event -> + val mouse = event as? MouseEvent ?: return@AWTEventListener + when (mouse.id) { + MouseEvent.MOUSE_MOVED, + MouseEvent.MOUSE_DRAGGED, + MouseEvent.MOUSE_EXITED -> sync(mouse) + } + } + Toolkit.getDefaultToolkit().addAWTEventListener( + listener, + AWTEvent.MOUSE_MOTION_EVENT_MASK or AWTEvent.MOUSE_EVENT_MASK, + ) + Disposer.register(parent, this) + Disposer.register(this) { + Toolkit.getDefaultToolkit().removeAWTEventListener(listener) + } + } + + @RequiresEdt + fun bounds(pane: JPanel, child: JComponent): Rectangle { + val item = target ?: return Rectangle() + val anchor = item.copyAnchor + if (!anchor.isShowing || anchor.parent == null) return Rectangle() + val visible = anchor.visibleRect + if (visible.isEmpty) return Rectangle() + val size = child.preferredSize + val gap = JBUI.scale(4) + val pt = SwingUtilities.convertPoint(anchor, Point(visible.x + visible.width, visible.y), pane) + val x = (pt.x - size.width - gap).coerceIn(0, (pane.width - size.width).coerceAtLeast(0)) + val y = (pt.y + gap).coerceIn(0, (pane.height - size.height).coerceAtLeast(0)) + return Rectangle(x, y, size.width, size.height) + } + + override fun doLayout() { + button.setBounds(0, 0, width, height) + } + + override fun getPreferredSize() = button.preferredSize + + override fun getMinimumSize() = button.minimumSize + + override fun getMaximumSize() = button.maximumSize + + @RequiresEdt + private fun sync(event: MouseEvent) { + val src = event.component ?: return conceal() + if (SessionTargetResolver.inside(this, src)) return retain() + if (contains(target, src, event.point)) return + val item = SessionTargetResolver.copy(root, src, event.point, this) + if (item == null) { + conceal() + return + } + show(item) + } + + @RequiresEdt + private fun show(item: SessionCopyTarget) { + if (target === item && isVisible) return + target = item + isVisible = true + parent?.doLayout() + revalidate() + repaint() + } + + @RequiresEdt + private fun retain() { + if (target == null || isVisible) return + isVisible = true + } + + @RequiresEdt + internal fun contains(item: SessionCopyTarget?, src: Component, point: Point): Boolean { + val anchor = item?.copyAnchor ?: return false + if (!SessionTargetResolver.inside(anchor, src)) return false + val pt = SwingUtilities.convertPoint(src, point, anchor) + return anchor.contains(pt) + } + + @RequiresEdt + fun clear() { + conceal() + } + + @RequiresEdt + private fun conceal() { + copy.dismiss() + if (target == null && !isVisible) return + target = null + isVisible = false + revalidate() + repaint() + } + + override fun dispose() { + clear() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt new file mode 100644 index 00000000000..32b1c740dce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -0,0 +1,280 @@ +package ai.kilocode.client.session.ui.selection + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.ide.TextCopyProvider +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.event.SelectionEvent +import com.intellij.openapi.editor.event.SelectionListener +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Color +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.lang.ref.WeakReference +import javax.swing.event.CaretEvent +import javax.swing.event.CaretListener +import javax.swing.text.JTextComponent + +class SessionSelection : Disposable { + private val items = linkedSetOf() + private var active: Item? = null + private var style: SessionEditorStyle? = null + private var clearing = false + private var disposed = false + private val copy = provider { selectedText() } + + @RequiresEdt + fun selectedText(): String? { + active?.selectedText()?.takeIf { it.isNotEmpty() }?.let { return it } + val item = items.toList().asReversed().firstOrNull { !it.selectedText().isNullOrEmpty() } + if (item == null) { + active = null + return null + } + active = item + clearExcept(item) + return item.selectedText()?.takeIf { it.isNotEmpty() } + } + + @RequiresEdt + fun provideCopy(sink: DataSink, content: (() -> String?)? = null) { + if (content == null) { + sink.set(PlatformDataKeys.COPY_PROVIDER, copy) + return + } + sink.set(PlatformDataKeys.COPY_PROVIDER, provider { selectedText() ?: content() }) + } + + private fun provider(text: () -> String?) = object : TextCopyProvider() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun getTextLinesToCopy(): Collection? { + val item = text()?.takeIf { it.isNotEmpty() } ?: return null + return listOf(item) + } + } + + @RequiresEdt + fun register(component: JTextComponent, parent: Disposable? = null): Disposable { + val item = TextItem(component) + add(item, parent) + return item + } + + @RequiresEdt + fun register(field: EditorTextField, parent: Disposable? = null): Disposable { + val item = FieldItem(field) + add(item, parent) + return item + } + + @RequiresEdt + fun register(editor: EditorEx, parent: Disposable? = null): Disposable { + val item = EditorItem(editor) + add(item, parent) + return item + } + + @RequiresEdt + private fun clearExcept(item: Item) { + if (clearing) return + clearing = true + try { + for (entry in items) { + if (entry !== item) entry.clearSelection() + } + } finally { + clearing = false + } + } + + @RequiresEdt + fun clear() { + if (clearing) return + clearing = true + try { + for (entry in items) entry.clearSelection() + active = null + } finally { + clearing = false + } + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle) { + this.style = style + for (item in items) item.applyStyle(style) + } + + @RequiresEdt + override fun dispose() { + disposed = true + clear() + val copy = items.toList() + for (item in copy) item.dispose() + items.clear() + active = null + } + + @RequiresEdt + private fun add(item: Item, parent: Disposable?) { + if (disposed) return + items.add(item) + style?.let(item::applyStyle) + parent?.let { Disposer.register(it, item) } + } + + @RequiresEdt + private fun changed(item: Item) { + if (clearing || item.disposed) return + if (!items.contains(item)) return + if (!item.selectedText().isNullOrEmpty()) { + active = item + clearExcept(item) + return + } + if (active === item) active = null + } + + @RequiresEdt + private fun started(item: Item) { + if (clearing || item.disposed) return + if (!items.contains(item)) return + active = item + clearExcept(item) + } + + private interface Item : Disposable { + val disposed: Boolean + fun selectedText(): String? + fun clearSelection() + fun applyStyle(style: SessionEditorStyle) + } + + private inner class TextItem(private val component: JTextComponent) : Item, CaretListener { + private val mouse = object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) = started(this@TextItem) + } + + override var disposed = false + private set + + init { + component.caret.isSelectionVisible = true + component.caret.isVisible = false + component.isFocusable = true + component.isRequestFocusEnabled = true + component.addCaretListener(this) + component.addMouseListener(mouse) + } + + override fun caretUpdate(e: CaretEvent) = changed(this) + + override fun selectedText(): String? = component.selectedText + + override fun clearSelection() { + val pos = component.selectionStart.coerceIn(0, component.document.length) + component.caretPosition = pos + } + + override fun applyStyle(style: SessionEditorStyle) { + selectionColors(style, component.selectionColor, component.selectedTextColor).let { + component.selectionColor = it.first + component.selectedTextColor = it.second + } + } + + override fun dispose() { + if (disposed) return + disposed = true + component.removeCaretListener(this) + component.removeMouseListener(mouse) + if (active === this) active = null + items.remove(this) + } + } + + private inner class FieldItem(private val field: EditorTextField) : Item, SelectionListener { + private var editor: EditorEx? = null + private var reg: Disposable? = null + override var disposed = false + private set + + init { + field.getEditor(false)?.let(::bind) + val ref = WeakReference(this) + field.addSettingsProvider { ed -> ref.get()?.bind(ed) } + } + + override fun selectedText(): String? = editor?.selectionModel?.selectedText + + override fun clearSelection() { + editor?.selectionModel?.removeSelection() + } + + override fun applyStyle(style: SessionEditorStyle) { + editor?.let(style::applyToEditor) + } + + override fun selectionChanged(e: SelectionEvent) = changed(this) + + override fun dispose() { + if (disposed) return + disposed = true + reg?.let(Disposer::dispose) + reg = null + editor = null + if (active === this) active = null + items.remove(this) + } + + private fun bind(editor: EditorEx) { + if (disposed || this.editor != null) return + this.editor = editor + val disposable = Disposer.newDisposable("Session selection editor") + reg = disposable + editor.selectionModel.addSelectionListener(this, disposable) + style?.let(::applyStyle) + } + } + + private inner class EditorItem(private val editor: EditorEx) : Item, SelectionListener { + override var disposed = false + private set + + init { + editor.selectionModel.addSelectionListener(this, this) + } + + override fun selectionChanged(e: SelectionEvent) = changed(this) + + override fun selectedText(): String? = editor.selectionModel.selectedText + + override fun clearSelection() { + editor.selectionModel.removeSelection() + } + + override fun applyStyle(style: SessionEditorStyle) { + style.applyToEditor(editor) + } + + override fun dispose() { + if (disposed) return + disposed = true + if (active === this) active = null + items.remove(this) + } + } + + private fun selectionColors(style: SessionEditorStyle, bg: Color?, fg: Color?): Pair { + val scheme = style.editorScheme + return (scheme.getColor(EditorColors.SELECTION_BACKGROUND_COLOR) ?: bg ?: style.editorBackground) to + (scheme.getColor(EditorColors.SELECTION_FOREGROUND_COLOR) ?: fg ?: style.editorForeground) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionTargetResolver.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionTargetResolver.kt new file mode 100644 index 00000000000..5c1615304d3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionTargetResolver.kt @@ -0,0 +1,74 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Component +import java.awt.Container +import java.awt.Point +import javax.swing.JComponent +import javax.swing.SwingUtilities + +internal object SessionTargetResolver { + @RequiresEdt + fun target(root: JComponent, src: Component, point: Point, skip: Component? = null): Component? { + if (!inside(root, src)) return null + val pt = SwingUtilities.convertPoint(src, point, root) + if (!root.contains(pt)) return null + val deep = deepest(root, pt, skip) ?: src + return provider(root, deep) ?: deep + } + + @RequiresEdt + fun copy(root: JComponent, src: Component, point: Point, skip: Component? = null): SessionCopyTarget? { + if (!inside(root, src)) return null + val pt = SwingUtilities.convertPoint(src, point, root) + if (!root.contains(pt)) return null + return copy(root, deepest(root, pt, skip) ?: src) + } + + @RequiresEdt + internal fun inside(root: JComponent, comp: Component): Boolean = comp === root || SwingUtilities.isDescendingFrom(comp, root) + + @RequiresEdt + private fun deepest(root: JComponent, pt: Point, skip: Component?): Component? { + if (skip == null || !skip.isVisible) { + return SwingUtilities.getDeepestComponentAt(root, pt.x, pt.y)?.takeIf { inside(root, it) } + } + return deepestSkipping(root, pt, skip) + } + + @RequiresEdt + private fun deepestSkipping(container: Container, pt: Point, skip: Component): Component? { + for (child in container.components.toList().asReversed()) { + if (child === skip || SwingUtilities.isDescendingFrom(child, skip)) continue + if (!child.isVisible || !child.contains(SwingUtilities.convertPoint(container, pt, child))) continue + if (child is Container) { + val next = deepestSkipping(child, SwingUtilities.convertPoint(container, pt, child), skip) + if (next != null) return next + } + return child + } + return if (container === skip) null else container + } + + @RequiresEdt + private fun provider(root: JComponent, comp: Component): Component? { + var current: Component? = comp + while (current != null && inside(root, current)) { + if (current is UiDataProvider) return current + current = current.parent + } + return null + } + + @RequiresEdt + private fun copy(root: JComponent, comp: Component): SessionCopyTarget? { + var current: Component? = comp + var target: SessionCopyTarget? = null + while (current != null && inside(root, current)) { + if (current is SessionCopyTarget) target = current + current = current.parent + } + return target + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 02daa43ca22..19c76b825d3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -15,11 +15,12 @@ import kotlin.math.roundToInt * Session UI uses this instead of reading editor globals in every component so font and color changes can be applied * consistently through [SessionEditorStyleTarget]. * - * Editor-specific fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [editorForeground], [editorBackground]) - * are derived from the active editor color scheme and are used for code/editor-rendered content. + * Editor-specific fields ([editorFont], [editorForeground], [editorBackground]) are derived from the active editor color + * scheme and are used for code/editor-rendered content. * - * UI font fields ([headerFont], [hintFont], [regularFont], [boldFont], [smallFont]) come from [UiStyle.Fonts] - * and follow standard platform typography — they do not derive from the editor font size. + * UI font fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [headerFont], [hintFont], [regularFont], + * [boldFont], [smallFont]) come from [UiStyle.Fonts] and follow standard platform typography. Transcript fonts use the + * editor size so the session body tracks editor zoom without adopting the editor family. */ data class SessionEditorStyle( val editorScheme: EditorColorsScheme, @@ -27,6 +28,7 @@ data class SessionEditorStyle( val editorSize: Int, val editorForeground: Color, val editorBackground: Color, + val editorFont: Font, val transcriptFont: Font, val smallEditorFont: Font, val boldEditorFont: Font, @@ -38,8 +40,13 @@ data class SessionEditorStyle( ) { /** Apply this snapshot to embedded IntelliJ editor components used by session UI. */ fun applyToEditor(editor: EditorEx) { - editor.setColorsScheme(editorScheme) - editor.setFontSize(editorSize) + try { + if (editor.isDisposed) return + editor.setColorsScheme(editorScheme) + editor.setFontSize(editorSize) + } catch (err: RuntimeException) { + if (err.javaClass.name != "com.intellij.openapi.util.TraceableDisposable\$DisposalException") throw err + } } companion object { @@ -61,9 +68,10 @@ data class SessionEditorStyle( editorSize = size, editorForeground = scheme.defaultForeground, editorBackground = scheme.defaultBackground, - transcriptFont = Font(family, Font.PLAIN, size), - smallEditorFont = Font(family, Font.PLAIN, small), - boldEditorFont = Font(family, Font.BOLD, size), + editorFont = Font(family, Font.PLAIN, size), + transcriptFont = uiFont(UiStyle.Fonts.regular(), Font.PLAIN, size), + smallEditorFont = uiFont(UiStyle.Fonts.small(), Font.PLAIN, small), + boldEditorFont = uiFont(UiStyle.Fonts.regular(), Font.BOLD, size), headerFont = UiStyle.Fonts.header(), hintFont = UiStyle.Fonts.hint(), regularFont = UiStyle.Fonts.regular(), @@ -77,6 +85,8 @@ data class SessionEditorStyle( val ratio = font.size.toFloat() / base return (size * ratio).roundToInt().coerceAtLeast(1) } + + private fun uiFont(font: Font, style: Int, size: Int): Font = font.deriveFont(style, size.toFloat()) } } 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 df7f84ec064..d6da98a2162 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 @@ -3,46 +3,64 @@ package ai.kilocode.client.session.ui.style import ai.kilocode.client.ui.UiStyle import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI -import com.intellij.util.ui.JBUI.Borders.customLine import com.intellij.util.ui.UIUtil import java.awt.Color +import java.awt.Insets import javax.swing.border.Border /** Static style tokens owned by the chat session UI. */ object SessionUiStyle { + object Transcript { + fun bgColor(): Color = UiStyle.Colors.bg() + } + /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { - const val GAP = 4 - const val TRANSCRIPT_PADDING = 12 + 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 const val USER_PROMPT_INDENT = 100 - const val SCROLL_INCREMENT = 16 + const val SCROLL_INCREMENT = 48 } - /** Shared tokens for individual transcript views and cards. */ + /** Shared tokens for individual transcript views and session views. */ object View { - const val CARD_LAYOUT_GAP = 6 - const val CARD_VERTICAL_PADDING = 8 - const val CARD_HORIZONTAL_PADDING = 12 - const val CARD_BODY_EXTRA_HEIGHT = 16 + object Layout { + const val GAP = 5 + const val VERTICAL_PADDING = 7 + const val HORIZONTAL_PADDING = 12 + const val BODY_EXTRA_HEIGHT = 16 + } - internal const val BORDER_DELTA = 64 - internal const val HOVER_ALPHA = 0.35f + internal const val BORDER_DELTA = 80 + internal const val HOVER_BORDER_ALPHA = 0.18f + internal const val HOVER_FILL_ALPHA = 0.10f - /** Creates a visible separator against editor-derived transcript surfaces. */ - fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) } + object Surface { + fun bgColor(): Color = UiStyle.Colors.editorBackground() - fun surface(): Color = UiStyle.Colors.editorBackground() + fun headerBgColor(): Color = UiStyle.Colors.editorBackground() - fun header(): Color = UiStyle.Colors.editorBackground() + /** Subtle hover fill, softer than the session-view outline. */ + fun headerHoverBgColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(headerBgColor(), Outline.hoverColor(), HOVER_FILL_ALPHA) + } + } - /** Local hover color for collapsible transcript card headers. */ - fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), line(), HOVER_ALPHA) } + object Outline { + fun color(): Color = UiStyle.Colors.contentBorder() - fun card(): Border = cardBorder() + fun brightColor(): Color = JBColor.lazy { + UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) + } - fun cardBorder(): Border = JBUI.Borders.customLine(line(), 1) + /** Subtle hover outline, stronger than the hover fill. */ + fun hoverColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(brightColor(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + } - fun cardTop(): Border = JBUI.Borders.customLineTop(line()) + fun width(): Int = JBUI.scale(1) + } /** Prompt input dimensions and chrome inside the session view. */ object Prompt { @@ -58,9 +76,37 @@ object SessionUiStyle { const val SHELL_HORIZONTAL_PADDING = 8 } + /** Attachment preview card geometry. */ + object Attachment { + const val CARD_WIDTH = 80 + const val CARD_HEIGHT = 59 + const val CLOSE_SIZE = 18 + const val CORNER_ARC = 8 + } + + /** Full-session file drop overlay geometry and colors. */ + object DropOverlay { + const val CARD_VERTICAL_PADDING = 16 + const val CARD_HORIZONTAL_PADDING = 20 + const val CARD_ARC = 12 + const val LABEL_GAP = 2 + const val ICON_GAP = 10 + private const val SCRIM_ALPHA = 210 + + fun scrim(): Color = JBColor.lazy { + val bg = UiStyle.Colors.bg() + Color(bg.red, bg.green, bg.blue, SCRIM_ALPHA) + } + + fun card(): Color = UiStyle.Colors.contentBackground() + } + /** Reasoning block preview sizing. */ object Reasoning { const val BODY_LINES = 5 + const val HEADER_VERTICAL_PADDING = 5 + const val BODY_VERTICAL_PADDING = 4 + const val BODY_HORIZONTAL_PADDING = 8 } /** Message container roles and user bubble geometry. */ @@ -72,12 +118,26 @@ object SessionUiStyle { const val USER_BORDER_HORIZONTAL_PADDING = 12 } - /** Permission card command preview limits. */ + /** Markdown code block geometry inside assistant messages. */ + object Code { + const val BLOCK_GAP = SessionLayout.GAP + 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_BOTTOM_PADDING = 6 + const val SCROLLBAR_HEIGHT = 12 + const val WIDTH_PADDING = 16 + + fun topPadding(): Int = VIEWPORT_TOP_PADDING + } + + /** Permission session-view command preview limits. */ object Permission { const val COMMAND_LINES = 3 } - /** Tool card preview limits and state colors. */ + /** Tool session-view preview limits and state colors. */ object Tool { const val BODY_LINES = 15 const val PREVIEW_LIMIT = 20_000 @@ -92,6 +152,12 @@ object SessionUiStyle { } } + object AccountPopup { + fun bgColor(): Color = UiStyle.Colors.contentBackground() + + fun outlineColor(): Color = UiStyle.Colors.contentBorder() + } + /** Limits for the empty-state recent sessions list. */ object RecentSessions { const val LIMIT = 5 @@ -100,20 +166,26 @@ object SessionUiStyle { /** Colors for timeline/activity indicators in the session header. */ object Timeline { - val READ: Color = JBColor(Color(0x37, 0x94, 0xff), Color(0x37, 0x94, 0xff)) - val WRITE: Color = JBColor(Color(0x00, 0x7f, 0xd4), Color(0x00, 0x7f, 0xd4)) - val TOOL: Color = JBColor(Color(0x00, 0x7a, 0xcc), Color(0x00, 0x7a, 0xcc)) + val READ: Color = JBColor.namedColor("Kilo.Session.Timeline.Read", Color(0x37, 0x94, 0xff)) + val WRITE: Color = JBColor.namedColor("Kilo.Session.Timeline.Write", Color(0x00, 0x7f, 0xd4)) + val TOOL: Color = JBColor.namedColor("Kilo.Session.Timeline.Tool", Color(0x00, 0x7a, 0xcc)) val SUCCESS: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground()) - val ERROR: Color = JBColor(Color(0xf4, 0x87, 0x71), Color(0xf4, 0x87, 0x71)) - val TEXT: Color = JBColor(Color(0x9d, 0x9d, 0x9d), Color(0x9d, 0x9d, 0x9d)) - val STEP: Color = JBColor(Color(0x4d, 0x4d, 0x4d), Color(0x4d, 0x4d, 0x4d)) + val ERROR: Color = JBColor.namedColor("Kilo.Session.Timeline.Error", UIUtil.getErrorForeground()) + val TEXT: Color = JBColor.namedColor("Kilo.Session.Timeline.Text", UIUtil.getContextHelpForeground()) + val STEP: Color = JBColor.namedColor("Kilo.Session.Timeline.Step", JBColor.border()) } } /** Border presets for connection dock panel. */ object Dock { fun banner(): Border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(SessionUiStyle.View.line()), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()), )!! } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt new file mode 100644 index 00000000000..50f07635b1a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt @@ -0,0 +1,79 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.ui.JBUI +import java.awt.FlowLayout +import java.net.URI +import java.nio.file.Path + +class AttachmentView( + private var item: FileAttachment, + private val openAttachment: (FileAttachment) -> Unit, +) : PartView() { + constructor( + item: FileAttachment, + openFile: (String) -> Unit, + openUrl: (String) -> Unit, + ) : this(item, { openDefault(it, openFile, openUrl) }) + + override val contentId: String = item.id + private var chip = chip(item) + + init { + layout = FlowLayout(FlowLayout.LEFT, 0, UiStyle.Gap.xs()) + border = JBUI.Borders.empty(0, UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad()) + add(chip) + } + + override fun update(content: Content) { + if (content !is FileAttachment) return + if (same(content)) { + item = content + return + } + item = content + remove(chip) + chip = chip(content) + add(chip) + revalidate() + repaint() + } + + override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}" + + private fun chip(item: FileAttachment) = AttachmentCard( + AttachmentCardItem(name(item), item.mime, item.url), + open = { openAttachment(item) }, + ) + + private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename + + companion object { + fun openDefault(item: FileAttachment, openFile: (String) -> Unit, openUrl: (String) -> Unit) { + val url = item.url.takeIf { it.isNotBlank() } ?: return + val uri = runCatching { URI.create(url) }.getOrNull() ?: return + if (uri.scheme == "file") { + val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return + openFile(path) + return + } + openUrl(url) + } + } + + private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } + ?: tail(item.url).takeIf { it.isNotBlank() } + ?: "attachment" + + private fun tail(value: String): String { + val clean = value.trimEnd('/', '\\') + val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + if (index < 0) return clean + return clean.substring(index + 1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index 25435404d9b..ece0854420a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -40,7 +40,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par applyStyle(SessionEditorStyle.current()) val line = { JPanel().apply { - background = SessionUiStyle.View.line() + background = SessionUiStyle.View.Outline.color() isOpaque = true preferredSize = JBDimension(0, JBUI.scale(1)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt index 83a658f528d..b45fbb58869 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -3,10 +3,13 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Container +import javax.swing.JButton /** * Retained inline view shown at the bottom of the transcript when a session @@ -19,11 +22,12 @@ import com.intellij.util.ui.components.BorderLayoutPanel class LoginRequiredView( private val openProfile: () -> Unit, private val dismiss: () -> Unit, + selection: SessionSelection? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { override val sessionViewKind = SessionView.Kind.Default - private val card = BaseQuestionView() + private val card = BaseQuestionView(selection) private val ID_DISMISS = "dismiss" private val ID_OPEN = "open" @@ -63,8 +67,19 @@ class LoginRequiredView( } // Test helpers — return generic JButton to keep SessionQuestionButton internal - internal fun openProfileButton() = card.actionButtonsForTest()[ID_OPEN]!! - internal fun dismissButton() = card.actionButtonsForTest()[ID_DISMISS]!! + internal fun openProfileButton() = button(KiloBundle.message("session.login.required.button")) + internal fun dismissButton() = button(KiloBundle.message("session.login.required.dismiss")) + + private fun button(text: String) = buttons(card).first { it.text == text } + + private fun buttons(root: Container): List { + val result = mutableListOf() + if (root is JButton) result.add(root) + for (child in root.components) { + if (child is Container) result.addAll(buttons(child)) + } + return result + } private fun refresh() { revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt new file mode 100644 index 00000000000..b32eb166b94 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.ui.selection.SessionCopyButton +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.BorderLayout +import java.awt.Graphics +import javax.swing.JPanel + +internal class MessageToolbar( + private val align: String = BorderLayout.LINE_START, + private val text: () -> String?, +) : JPanel(BorderLayout()) { + private val copy = SessionCopyButton(text = text) + private val button = copy.button + + init { + isOpaque = false + add(button, align) + } + + @RequiresEdt + fun sync(value: Boolean) { + if (isVisible == value && button.isEnabled == value) return + isVisible = value + button.isEnabled = value + revalidate() + repaint() + } + + @RequiresEdt + fun paint(value: Boolean) { + // Prompt toolbars stay visible to reserve layout space while their button is visually hidden. + if (!isVisible) isVisible = true + if (button.isEnabled == value) return + button.isEnabled = value + repaint() + } + + @RequiresEdt + fun paints() = button.isEnabled + + @RequiresEdt + fun alignment() = align + + @RequiresEdt + fun copyButton() = button + + override fun removeNotify() { + copy.dismiss() + super.removeNotify() + } + + override fun paintComponent(g: Graphics) { + if (!button.isEnabled) return + super.paintComponent(g) + } + + override fun paintChildren(g: Graphics) { + if (!button.isEnabled) return + super.paintChildren(g) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index e2db58c0c7e..1335d1298c7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -1,18 +1,34 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.ui.RoundedLineBorder +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Point +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.Container +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities /** * A single message container inside a [TurnView]. @@ -27,12 +43,19 @@ import com.intellij.util.ui.JBUI */ class MessageView( val msg: Message, + private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), + private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) }, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), -), SessionEditorStyleTarget, SessionView { +), Disposable, SessionEditorStyleTarget, SessionView { - constructor(msg: Message) : this(msg, SessionEditorStyle.current()) + constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current()) val role: String get() = msg.info.role @@ -40,24 +63,39 @@ class MessageView( get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default private val parts = LinkedHashMap() + // Adjacent reasoning parts render through the first ReasoningView. aliases maps each + // merged child id to that owner id, and sources stores the child's latest full text + // so snapshot updates can append only deltas. + private val aliases = LinkedHashMap() + private val sources = LinkedHashMap() + private var attachments: PromptAttachmentView? = null private var hidden: ToolCallRef? = null + private var prompt: PromptView? = null + private var promptBox: JPanel? = null + private var promptToolbar: MessageToolbar? = null + private var promptHover = false init { isOpaque = false - border = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - userBorder() - } else { - assistantBorder() + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground + border = assistantBorder() + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + setPromptHovered(true) + } + + override fun mouseExited(e: MouseEvent) { + setPromptHovered(false) + } + }) } // Populate content that already exists (e.g. after loadHistory) for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } } @@ -72,52 +110,158 @@ class MessageView( } /** Add or update the renderer for [content]. */ + @RequiresEdt fun upsertPart(content: Content) { if (content is StepFinish) return if (isHidden(content)) { + if (isPromptMention(content)) syncPromptMentions() // Remove any stale view for this content so it disappears when suppressed - val stale = parts.remove(content.id) + val id = aliases.remove(content.id) + sources.remove(content.id) + val stale = if (id == null) parts.remove(content.id) else null if (stale != null) { + if (stale is PromptAttachmentView) { + stale.remove(content.id) + if (!stale.isEmpty()) { + refresh() + return + } + attachments = null + } + detach(stale) remove(stale) + Disposer.dispose(stale) syncBorder() refresh() } return } + val id = aliases[content.id] + if (id != null && content is Reasoning) { + updateAlias(content, id) + refresh() + return + } + if (id != null) { + aliases.remove(content.id) + sources.remove(content.id) + } val existing = parts[content.id] if (existing != null) { + if (existing is PromptAttachmentView && content is FileAttachment) { + existing.upsert(content) + refresh() + return + } if (ViewFactory.shouldReplace(existing, content)) { replacePart(content, existing) return } existing.update(content) + syncPromptToolbar() refresh() return } - val view = ViewFactory.create(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) syncBorder() refresh() } + @RequiresEdt + private fun addPart(content: Content) { + if (content is FileAttachment && role == SessionUiStyle.View.Message.USER_ROLE) { + addAttachment(content) + return + } + if (content is Reasoning) { + val previous = parts.values.lastOrNull() + if (previous is ReasoningView) { + aliases[content.id] = previous.contentId + sources[content.id] = content.content.toString() + previous.update(merged(previous, content, content.content.toString())) + return + } + } + val view = view(content) + val item = wrapPrompt(view) + view.resize = resize + view.hover = hover + view.applyStyle(style) + parts[content.id] = view + add(item) + } + + @RequiresEdt + private fun addAttachment(content: FileAttachment) { + val view = attachments ?: PromptAttachmentView(msg.info.id) { openAttachment(msg.info.id, it) }.also { + it.resize = resize + it.hover = hover + it.applyStyle(style) + attachments = it + add(it) + } + view.upsert(content) + parts[content.id] = view + } + + @RequiresEdt + private fun updateAlias(content: Reasoning, id: String) { + val view = parts[id] as? ReasoningView ?: return + val prev = sources[content.id].orEmpty() + val next = content.content.toString() + val delta = if (next.startsWith(prev)) next.removePrefix(prev) else next + sources[content.id] = next + if (delta.isEmpty()) return + view.update(merged(view, content, delta)) + } + + private fun merged(view: ReasoningView, content: Reasoning, delta: String) = Reasoning(view.contentId).also { + it.done = content.done + it.content.append(view.markdown()) + it.content.append(delta) + } + + @RequiresEdt private fun replacePart(content: Content, existing: PartView) { val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) + aliases.values.removeAll { it == content.id } + sources.keys.removeAll { it !in aliases } + detach(existing) remove(existing) - val view = ViewFactory.create(content) + Disposer.dispose(existing) + val view = view(content) + val item = wrapPrompt(view) + view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view - add(view, at) + add(item, at) syncBorder() refresh() } /** Remove the renderer for [contentId] if present. */ + @RequiresEdt fun removePart(contentId: String) { + if (aliases.remove(contentId) != null) { + sources.remove(contentId) + return + } val view = parts.remove(contentId) ?: return + if (view is PromptAttachmentView) { + view.remove(contentId) + if (!view.isEmpty()) { + refresh() + return + } + attachments = null + } + aliases.values.removeAll { it == contentId } + sources.keys.removeAll { it !in aliases } + detach(view) remove(view) + Disposer.dispose(view) syncBorder() refresh() } @@ -127,8 +271,12 @@ class MessageView( * pending/running question tool part linked to the active question. */ private fun isHidden(content: Content): Boolean { - val ref = hidden ?: return false + if (isPromptMention(content)) return true if (content !is Tool) return false + if (role == SessionUiStyle.View.Message.USER_ROLE && content.name == "read") return true + if (content.name == "todoread") return true + if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true + val ref = hidden ?: return false if (content.name != "question") return false if (content.state != ToolExecState.PENDING && content.state != ToolExecState.RUNNING) return false return msg.info.id == ref.messageId && content.callId == ref.callId @@ -138,35 +286,85 @@ class MessageView( * Clear and rebuild all part views from [msg.parts]. * Called only when the hidden ref changes to avoid unnecessary rebuilds. */ + @RequiresEdt private fun rebuildParts() { - parts.values.forEach { remove(it) } + parts.values.distinct().forEach { + detach(it) + remove(it) + Disposer.dispose(it) + } parts.clear() + aliases.clear() + sources.clear() + attachments = null + prompt = null + promptBox = null + promptToolbar = null + promptHover = false for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } syncBorder() refresh() } + @RequiresEdt private fun syncBorder() { if (msg.info.role != SessionUiStyle.View.Message.ASSISTANT_ROLE) return border = assistantBorder() } + private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { + ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg)) { openAttachment(msg.info.id, it) } + } else { + ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) } + } + + private fun syncPromptMentions() { + val mentions = promptMentions(msg) + for (view in parts.values) { + if (view is PromptView) view.setMentions(mentions) + } + } + + private fun isPromptMention(content: Content): Boolean { + if (role != SessionUiStyle.View.Message.USER_ROLE) return false + if (content !is FileAttachment) return false + return content.source != null && content.mime.lowercase().startsWith("text/plain") + } + /** Append a streaming delta to the renderer for [contentId]. */ - fun appendDelta(contentId: String, delta: String) { - val part = parts[contentId] ?: return + @RequiresEdt + fun appendDelta(contentId: String, delta: String): Boolean { + val id = aliases[contentId] + if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta + val part = parts[id ?: contentId] ?: return false part.appendDelta(delta) - refresh() + syncPromptToolbar() + return true + } + + @RequiresEdt + fun syncCopyToolbar(copyId: String?) { + if (role == SessionUiStyle.View.Message.USER_ROLE) return + for ((id, view) in parts) { + if (view is TextView) view.setCopyToolbar(id == copyId) + } + } + + @RequiresEdt + fun latestAssistantCopyId(): String? { + if (role != SessionUiStyle.View.Message.ASSISTANT_ROLE) return null + for ((id, view) in parts.entries.reversed()) { + if (view is TextView && view.markdown().isNotBlank()) return id + } + return null } /** Look up a renderer by part id. */ - fun part(id: String): PartView? = parts[id] + fun part(id: String): PartView? = parts[aliases[id] ?: id] /** Ordered part ids — stable for test assertions. */ fun partIds(): List = parts.keys.toList() @@ -174,24 +372,146 @@ class MessageView( /** Compact dump for test assertions. */ fun dump(): String = parts.values.joinToString(", ") { it.dumpLabel() } + @RequiresEdt + fun setPromptHovered(value: Boolean) { + if (role != SessionUiStyle.View.Message.USER_ROLE) return + if (promptHover == value) return + promptHover = value + syncPromptToolbar() + } + + @RequiresEdt + fun paintsPromptToolbar() = promptToolbar?.paints() == true + + @RequiresEdt + fun promptToolbarAlignment() = promptToolbar?.alignment() + + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground for (view in parts.values) view.applyStyle(style) refresh() } + @RequiresEdt + override fun dispose() { + parts.values.forEach { + detach(it) + remove(it) + Disposer.dispose(it) + } + parts.clear() + aliases.clear() + sources.clear() + prompt = null + promptBox = null + promptToolbar = null + promptHover = false + hidden = null + } + + override fun paintComponent(g: Graphics) { + if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) { + super.paintComponent(g) + return + } + val box = promptBox + if (box != null) { + paintPromptBox(g, box) + super.paintComponent(g) + return + } + paintPromptBox(g, this) + super.paintComponent(g) + } + + private fun paintPromptBox(g: Graphics, box: JComponent) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) + val pt = if (box === this) Point() else SwingUtilities.convertPoint(box, Point(), this) + val x = pt.x + val y = pt.y + val w = box.width - 1 + val h = box.height - 1 + g2.color = style.editorScheme.defaultBackground + g2.fillRoundRect(x, y, box.width, box.height, arc, arc) + g2.color = SessionUiStyle.View.Outline.color() + if (w > 0 && h > 0) g2.drawRoundRect(x, y, w, h, arc, arc) + } finally { + g2.dispose() + } + } + + @RequiresEdt private fun refresh() { revalidate() repaint() } - private fun userBorder() = JBUI.Borders.compound( - RoundedLineBorder(SessionUiStyle.View.line(), JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_ARC)), - JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_HORIZONTAL_PADDING), - ), - )!! + @RequiresEdt + private fun detach(view: PartView) { + view.setHovered(false) + view.hover = null + } + + @RequiresEdt + private fun syncPromptToolbar() { + promptToolbar?.paint(promptHover) + } + + @RequiresEdt + private fun wrapPrompt(view: PartView): JComponent { + if (role != SessionUiStyle.View.Message.USER_ROLE) return view + if (view !is PromptView) return view + prompt = view + val bar = promptToolbar ?: MessageToolbar(BorderLayout.LINE_END) { prompt?.copyMarkdown(trim = false) }.also { promptToolbar = it } + val box = JPanel(BorderLayout()).also { + it.isOpaque = false + it.add(view, BorderLayout.CENTER) + promptBox = it + } + bar.paint(false) + return JPanel(BorderLayout()).also { + it.isOpaque = false + it.add(box, BorderLayout.CENTER) + it.add(bar, BorderLayout.SOUTH) + installPromptHover(it) + } + } + + @RequiresEdt + private fun installPromptHover(root: JComponent) { + val mouse = object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + setPromptHovered(true) + } + + override fun mouseExited(e: MouseEvent) { + val point = root.mousePosition + if (point != null && root.contains(point)) return + if (inside(root, e)) return + setPromptHovered(false) + } + } + visit(root) { it.addMouseListener(mouse) } + } + + @RequiresEdt + private fun inside(root: JComponent, e: MouseEvent): Boolean { + val point = SwingUtilities.convertPoint(e.component, e.point, root) + return root.contains(point) + } + + @RequiresEdt + private fun visit(root: Container, fn: (JComponent) -> Unit) { + if (root is JComponent) fn(root) + for (child in root.components) { + if (child is Container) visit(child, fn) + } + } private fun assistantBorder() = JBUI.Borders.empty() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt new file mode 100644 index 00000000000..421b19f551d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -0,0 +1,87 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer +import java.awt.BorderLayout + +class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSelection? = null) : PartView() { + constructor(tool: Tool, openFile: (String) -> Unit) : this(tool, openFile, null) + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED + } + + override val contentId: String = tool.id + + private var item = tool + private val md = MdViewFactory.create(SessionEditorStyle.current(), selection) + + init { + layout = BorderLayout() + isOpaque = false + Disposer.register(this, md) + md.addLinkListener { openFile(it.href) } + add(md.component, BorderLayout.CENTER) + applyStyle(SessionEditorStyle.current()) + sync() + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + sync() + } + + override fun applyStyle(style: SessionEditorStyle) { + md.applyStyle(style) + md.font = style.transcriptFont + md.codeFont = style.editorFamily + md.foreground = style.editorForeground + refresh() + } + + fun markdown(): String = md.markdown() + + internal fun simulateLink(href: String) = md.simulateLink(href) + + private fun sync() { + val plan = plan(item) + val text = listOf(KiloBundle.message("session.part.plan.ready"), link(plan)) + .filterNotNull() + .joinToString(" ") + md.set(text) + refresh() + } + + private fun refresh() { + revalidate() + repaint() + } + + override fun dumpLabel() = "PlanExitView#$contentId" +} + +private fun plan(tool: Tool): String { + tool.metadata["plan"]?.takeIf { it.isNotBlank() }?.let { return it } + val out = tool.output ?: return "" + return Regex("Plan is ready at (.+?)(?:\\. Ending planning turn\\.|$)") + .find(out) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?: "" +} + +private fun link(plan: String): String? { + if (plan.isBlank()) return null + val text = plan.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + val href = plan.replace(" ", "%20").replace("(", "%28").replace(")", "%29") + return "[$text]($href)" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt new file mode 100644 index 00000000000..a01148a25fc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt @@ -0,0 +1,133 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +class PromptAttachmentView( + messageId: String, + private val openAttachment: (FileAttachment) -> Unit, +) : PartView() { + override val contentId: String = "attachments:$messageId" + + private val items = LinkedHashMap() + private val cards = LinkedHashMap() + private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) + private val scroll = JBScrollPane(row).apply { + border = null + isOpaque = false + viewport.isOpaque = false + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + + init { + isOpaque = false + border = JBUI.Borders.empty( + 0, + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + add(scroll) + } + + fun contains(id: String) = items.containsKey(id) + + fun isEmpty() = items.isEmpty() + + fun ids(): List = items.keys.toList() + + fun scrollPane(): JBScrollPane = scroll + + @RequiresEdt + fun upsert(item: FileAttachment) { + val old = items[item.id] + items[item.id] = item + if (old != null && same(old, item)) return + val next = card(item) + val prev = cards.put(item.id, next) + if (prev == null) { + row.next(next) + refresh() + return + } + val at = row.components.indexOfFirst { it === prev }.takeIf { it >= 0 } ?: return refresh() + row.remove(prev) + row.add(next, at) + refresh() + } + + @RequiresEdt + fun remove(id: String): Boolean { + val item = items.remove(id) ?: return false + cards.remove(item.id)?.let { row.remove(it) } + refresh() + return true + } + + override fun update(content: Content) { + if (content is FileAttachment) upsert(content) + } + + override fun getPreferredSize(): Dimension { + val ins = insets + val pref = scroll.preferredSize + return Dimension(0, pref.height + bar() + ins.top + ins.bottom) + } + + override fun getMinimumSize() = preferredSize + + override fun doLayout() { + val ins = insets + scroll.setBounds( + ins.left, + ins.top, + maxOf(0, width - ins.left - ins.right), + maxOf(0, height - ins.top - ins.bottom), + ) + } + + override fun dispose() { + row.removeAll() + cards.clear() + items.clear() + } + + override fun dumpLabel(): String = "PromptAttachmentView#$contentId[${items.keys.joinToString(",")}]" + + private fun refresh() { + revalidate() + repaint() + } + + private fun card(item: FileAttachment) = AttachmentCard( + AttachmentCardItem(name(item), item.mime, item.url), + open = { openAttachment(item) }, + ) + + private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename + + private fun bar() = scroll.horizontalScrollBar.preferredSize.height + + private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } + ?: tail(item.url).takeIf { it.isNotBlank() } + ?: "attachment" + + private fun tail(value: String): String { + val clean = value.trimEnd('/', '\\') + val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + if (index < 0) return clean + return clean.substring(index + 1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt new file mode 100644 index 00000000000..a9a3af505d7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt @@ -0,0 +1,71 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.model.Message + +data class PromptMention( + val token: String, + val path: String, + val start: Int, + val end: Int, + val attachment: FileAttachment? = null, +) + +fun promptMentions(msg: Message): List = msg.parts.values.mapNotNull { part -> + if (part !is FileAttachment) return@mapNotNull null + if (!part.mime.lowercase().startsWith("text/plain")) return@mapNotNull null + val source = part.source ?: return@mapNotNull null + val path = source.path?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + PromptMention( + token = source.text.value, + path = path, + start = source.text.start.toInt(), + end = source.text.end.toInt(), + attachment = part, + ) +} + +fun linkifyMentions(text: String, mentions: List): String { + if (mentions.isEmpty()) return text + val ranges = mutableListOf>() + val used = mutableListOf() + for (mention in mentions) { + val range = exact(text, mention)?.takeUnless { overlaps(it, used) } + ?: fallback(text, mention, used) + ?: continue + ranges.add(range to mention) + used.add(range) + } + val out = StringBuilder(text) + for ((range, mention) in ranges.sortedByDescending { it.first.first }) { + val link = link(mention) + out.replace(range.first, range.last + 1, link) + } + return out.toString() +} + +private fun exact(text: String, mention: PromptMention): IntRange? { + if (mention.start < 0 || mention.end > text.length || mention.start >= mention.end) return null + if (text.substring(mention.start, mention.end) != mention.token) return null + return mention.start until mention.end +} + +private fun fallback(text: String, mention: PromptMention, used: List): IntRange? { + if (mention.token.isEmpty()) return null + var at = text.indexOf(mention.token) + while (at >= 0) { + val range = at until at + mention.token.length + if (!overlaps(range, used)) return range + at = text.indexOf(mention.token, at + 1) + } + return null +} + +private fun overlaps(range: IntRange, used: List): Boolean = + used.any { range.first <= it.last && it.first <= range.last } + +private fun link(mention: PromptMention): String { + val text = mention.token.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + val href = mention.path.replace(" ", "%20").replace("(", "%28").replace(")", "%29") + return "[$text]($href)" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt new file mode 100644 index 00000000000..958a8c6cb1d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt @@ -0,0 +1,83 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.model.Content +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.util.ui.JBUI + +class PromptView( + text: Text, + private val openFile: (String) -> Unit = {}, + private val openAttachment: (FileAttachment) -> Unit = {}, + openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, + mentions: List = emptyList(), +) : TextView(text, transparent = true, openUrl = openUrl, selection = selection) { + + private var mentions = mentions + private val buffer = StringBuilder(text.content) + + init { + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + sync() + } + + override fun update(content: Content) { + if (content !is Text) return + buffer.clear() + buffer.append(content.content) + sync() + } + + override fun appendDelta(delta: String) { + if (delta.isEmpty()) return + buffer.append(delta) + sync() + } + + fun setMentions(list: List) { + if (mentions == list) return + mentions = list + sync() + } + + override fun onLink(href: String) { + val mention = mentions.firstOrNull { it.path == href || path(it.path) == href } + if (mention != null) { + mention.attachment?.let { + openAttachment(it) + return + } + openFile(mention.path) + return + } + super.onLink(href) + } + + override fun applyStyle(style: SessionEditorStyle) { + super.applyStyle(style) + val color = style.editorScheme.getAttributes(DefaultLanguageHighlighterColors.METADATA)?.foregroundColor + if (color == null || md.linkColor == color) return + md.linkColor = color + } + + override fun styleFont(style: SessionEditorStyle) = style.editorFont + + override fun styleBackground(style: SessionEditorStyle) = style.editorBackground + + private fun sync() { + md.set(linkifyMentions(buffer.toString(), mentions)) + refresh() + } + + private fun path(value: String) = value.replace(" ", "%20").replace("(", "%28").replace(")", "%29") + + override fun dumpLabel() = "PromptView#$contentId" +} 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 7faf0e3ac9b..796e18bddbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -6,234 +6,196 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView -import com.intellij.icons.AllIcons +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel 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.Cursor import java.awt.Dimension import java.awt.Font import java.awt.Rectangle -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable import javax.swing.SwingUtilities -/** Renders reasoning as a VS Code-style collapsible block. */ -class ReasoningView(reasoning: Reasoning) : PartView() { +/** Renders reasoning as a secondary collapsible block. */ +class ReasoningView( + reasoning: Reasoning, + private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val parts: ReasoningParts = reasoningParts(selection), +) : + SecondarySessionPartView( + parts.header, + { parts.scroll(openUrl) }, + expanded = reasoning.content.isNotBlank() && !reasoning.done, + ) { override val contentId: String = reasoning.id - val md: MdView = MdView.html() - - private val arrow = JBLabel() - private val body = TrackPanel().apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val scroll = JBScrollPane(body).apply { - border = SessionUiStyle.View.cardTop() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = true - background = SessionUiStyle.View.header() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { - foreground = UiStyle.Colors.weak() - } - private val icon = JBLabel(AllIcons.General.InspectionsEye).apply { - foreground = UiStyle.Colors.weak() - } + /** Lazily creates, registers, populates, and styles the editor-backed body on first access. */ + val md: MdView + @RequiresEdt + get() { + val fresh = !parts.bodyCreated() + val view = parts.md(openUrl) + if (!fresh) return view + registerBody(view) + view.set(source) + view.applyStyle(style) + apply(view) + return view + } private var style = SessionEditorStyle.current() private var source = reasoning.content.toString() + private var done = reasoning.done + private var registered = false + private var following = false - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (!canExpand()) return - toggle() - } + init { + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + bindHeader(parts.title, parts.icon) + applyStyle(style) + if (bodyVisible()) syncBody() + syncBorder() + sync() } - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - setHover(true) - } - - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHover(false) - } + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBorder() + syncBody() + applyBodyStyle() + return true } - init { - layout = BorderLayout() - isOpaque = false - border = SessionUiStyle.View.card() - - val left = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = false - add(icon, BorderLayout.WEST) - add(title, BorderLayout.CENTER) - } - - header.add(left, BorderLayout.CENTER) - header.add(arrow, BorderLayout.EAST) - listOf(header, left, title, icon, arrow).forEach { - it.addMouseListener(click) - it.addMouseListener(mouse) - } - - applyStyle(SessionEditorStyle.current()) - md.opaque = false - md.set(source) - body.add(md.component, BorderLayout.CENTER) - - add(header, BorderLayout.NORTH) - sync() + @RequiresEdt + override fun collapse(): Boolean { + val changed = super.collapse() + if (!changed) return false + syncBorder() + return true } + @RequiresEdt override fun update(content: Content) { if (content !is Reasoning) return var changed = false val next = content.content.toString() + val follow = tailVisible() + if (done != content.done) { + done = content.done + changed = true + } if (source != next) { source = next - md.set(source) + if (parts.bodyCreated()) { + md.set(source) + followTail(follow) + } changed = true } changed = sync() || changed if (changed) refresh() } + @RequiresEdt override fun appendDelta(delta: String) { if (delta.isEmpty()) return + val follow = tailVisible() source += delta - md.append(delta) + if (parts.bodyCreated()) { + md.append(delta) + followTail(follow) + } val changed = sync() if (changed || bodyVisible()) refresh() } + @RequiresEdt fun markdown(): String = source - - fun isExpanded(): Boolean = bodyVisible() - + @RequiresEdt fun hasToggle(): Boolean = arrow.isVisible - - fun headerText(): String = title.text - - internal fun headerFont() = title.font - - internal fun bodyVisible() = scroll.parent === this - - internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy - + @RequiresEdt + fun headerText(): String = parts.title.text + @RequiresEdt + internal fun headerFont() = parts.title.font + @RequiresEdt + internal fun bodyVisible() = parts.scrollOrNull?.parent === this + @RequiresEdt + internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES - - internal fun bodyCreated() = true - + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyScrollValue() = parts.scrollOrNull?.verticalScrollBar?.value ?: 0 + @RequiresEdt + internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 + + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false - if (title.font != style.smallEditorFont) { - title.font = style.smallEditorFont + if (parts.title.font != style.smallEditorFont) { + parts.title.font = style.smallEditorFont changed = true } - changed = apply(md) || changed - if (changed) refresh() - } - - fun toggle() { - if (!canExpand()) return - var changed = if (bodyVisible()) collapse() else expand() - changed = sync() || changed + changed = applyBodyStyle() || changed if (changed) refresh() } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = header.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (header.background?.rgb == color.rgb) return - header.background = color - header.repaint() - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - private fun canExpand(): Boolean = source.isNotBlank() private fun sync(): Boolean { - val expand = canExpand() - if (!expand) collapse() var changed = false - changed = setVisible(arrow, expand) || changed - changed = syncArrow() || changed - val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() - listOf(header, title, icon, arrow).forEach { - if (it.cursor?.type != cursor.type) { - it.cursor = cursor - changed = true - } + val visible = source.isNotBlank() + if (isVisible != visible) { + isVisible = visible + changed = true + } + changed = syncExpandable(canExpand()) || changed + if (visible && !done && !parts.bodyCreated()) { + changed = expand() || changed + changed = syncExpandable(canExpand()) || changed } return changed } - private fun setVisible(component: JBLabel, visible: Boolean): Boolean { - if (component.isVisible == visible) return false - component.isVisible = visible - return true - } - - private fun setIcon(label: JBLabel, icon: javax.swing.Icon): Boolean { - if (label.icon === icon) return false - label.icon = icon - return true - } - - private fun syncArrow(): Boolean { - val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight - return setIcon(arrow, icon) - } - - private fun expand(): Boolean { - if (bodyVisible()) return false - add(scroll, BorderLayout.CENTER) - return true - } - - private fun collapse(): Boolean { - val attached = scroll.parent === this - if (attached) remove(scroll) - return attached + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + 0, + SessionUiStyle.View.Outline.width(), + 0, + 0, + ) + return + } + border = JBUI.Borders.empty(0, 1, 0, 0) } private fun apply(md: MdView): Boolean { @@ -248,13 +210,55 @@ class ReasoningView(reasoning: Reasoning) : PartView() { return changed } - private fun refresh() { - revalidate() - repaint() + @RequiresEdt + private fun syncBody() { + val md = md + registerBody(md) + md.set(source) + followTail(true) } - private fun bodyMaxHeight(): Int = md.component.getFontMetrics(md.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) + private fun applyBodyStyle(): Boolean { + if (!parts.bodyCreated()) return false + val md = md + registerBody(md) + md.applyStyle(style) + return apply(md) + } + + private fun registerBody(md: MdView) { + if (registered) return + registered = true + Disposer.register(this, md) + } + + private fun bodyMaxHeight(): Int { + if (!parts.bodyCreated()) return 0 + val md = md + return md.component.getFontMetrics(md.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + @RequiresEdt + private fun tailVisible(): Boolean { + if (!bodyVisible()) return false + val scroll = parts.scrollOrNull ?: return false + val bar = scroll.verticalScrollBar + return bar.value >= bar.maximum - bar.visibleAmount + } + + @RequiresEdt + private fun followTail(follow: Boolean) { + if (!follow || !bodyVisible() || following) return + val scroll = parts.scrollOrNull ?: return + following = true + SwingUtilities.invokeLater { + following = false + if (!bodyVisible()) return@invokeLater + val bar = scroll.verticalScrollBar + bar.value = bar.maximum - bar.visibleAmount + } + } override fun dumpLabel(): String { val state = if (bodyVisible()) "open" else "closed" @@ -262,7 +266,67 @@ class ReasoningView(reasoning: Reasoning) : PartView() { } } -private class TrackPanel : JPanel(BorderLayout()), Scrollable { +class ReasoningParts( + val header: JPanel, + val title: JBLabel, + val icon: JBLabel, + private val selection: SessionSelection?, +) { + private var body: ReasoningBody? = null + val scrollOrNull: JBScrollPane? get() = body?.scroll + + fun bodyCreated() = body != null + + fun md(openUrl: (String) -> Unit): MdView = body(openUrl).md + + fun scroll(openUrl: (String) -> Unit): JBScrollPane = body(openUrl).scroll + + private fun body(openUrl: (String) -> Unit): ReasoningBody { + val item = body + if (item != null) return item + val md = MdViewFactory.create(SessionEditorStyle.current(), selection).apply { + opaque = false + addLinkListener { openUrl(it.href) } + } + val panel = TrackPanel().apply { + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + 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 = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + return ReasoningBody(md, panel, scroll).also { body = it } + } +} + +class ReasoningBody( + val md: MdView, + val panel: TrackPanel, + val scroll: JBScrollPane, +) + +private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { + val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } + val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() } + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + add(icon, BorderLayout.WEST) + add(title, BorderLayout.CENTER) + } + return ReasoningParts(header, title, icon, selection) +} + +class TrackPanel : JPanel(BorderLayout()), Scrollable { override fun getScrollableTracksViewportWidth() = true override fun getScrollableTracksViewportHeight() = false override fun getPreferredScrollableViewportSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt new file mode 100644 index 00000000000..4ce4d3b423d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -0,0 +1,28 @@ +package ai.kilocode.client.session.views + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +object SessionViewIcons { + val brain = icon("brain") + val bubble = icon("bubble-5") + val bulletList = icon("bullet-list") + val checklist = icon("checklist") + val chevronDown: Icon = icon("chevron-down") + val chevronLeft = icon("chevron-left") + val chevronRight = icon("chevron-right") + val chevronCollapsed: Icon = chevronRight + val chevronExpanded: Icon = chevronDown + val code = icon("code") + val codeLines = icon("code-lines") + val console = icon("console") + val eye = icon("eye") + val glasses = icon("glasses") + val mcp = icon("mcp") + val search = icon("magnifying-glass-menu") + val task = icon("task") + val warning = icon("warning") + val windowCursor = icon("window-cursor") + + private fun icon(name: String) = IconLoader.getIcon("/icons/views/$name.svg", SessionViewIcons::class.java) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 86ab85cdbec..8eceececd97 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -3,56 +3,124 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.BorderLayout +import javax.swing.JButton /** * Renders a [Text] part as markdown using [MdView]. * * Supports both full-replacement ([update]) and streaming append ([appendDelta]). */ -class TextView(text: Text) : PartView() { +open class TextView( + text: Text, + transparent: Boolean = true, + private val openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, +) : PartView() { override val contentId: String = text.id - val md: MdView = MdView.html() + val md: MdView = MdViewFactory.create(SessionEditorStyle.current(), selection) + private var mode: CopyMode? = null + private val toolbar = MessageToolbar { copyText() } init { layout = BorderLayout() isOpaque = false + Disposer.register(this, md) + md.opaque = !transparent + md.addLinkListener { onLink(it.href) } applyStyle(SessionEditorStyle.current()) add(md.component, BorderLayout.CENTER) + add(toolbar, BorderLayout.SOUTH) if (text.content.isNotEmpty()) md.set(text.content.toString()) + syncToolbar() } override fun update(content: Content) { if (content !is Text) return md.set(content.content.toString()) + syncToolbar() refresh() } override fun appendDelta(delta: String) { if (delta.isEmpty()) return md.append(delta) + syncToolbar() refresh() } + @RequiresEdt + fun setCopyToolbar(enabled: Boolean, trim: Boolean = true) { + mode = if (enabled) CopyMode(trim) else null + syncToolbar() + } + + @RequiresEdt + fun hasCopyToolbar() = toolbar.isVisible + + @RequiresEdt + fun copyButton(): JButton = toolbar.copyButton() + + @RequiresEdt + fun copyMarkdown(trim: Boolean = true): String { + val text = md.markdown() + return if (trim) text.trim() else text + } + /** Current markdown source — used by tests to assert rendered content. */ fun markdown(): String = md.markdown() + internal fun simulateLink(href: String) = md.simulateLink(href) + + internal fun contentOpaque() = md.opaque + + protected open fun onLink(href: String) = openUrl(href) + override fun applyStyle(style: SessionEditorStyle) { - val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily - if (md.font != style.transcriptFont) md.font = style.transcriptFont + val font = styleFont(style) + val bg = styleBackground(style) + val changed = md.font != font || + md.codeFont != style.editorFamily || + md.foreground != style.editorForeground || + md.background != bg + md.applyStyle(style) + if (md.font != font) md.font = font if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily + if (md.foreground != style.editorForeground) md.foreground = style.editorForeground + if (md.background != bg) md.background = bg if (!changed) return refresh() } - private fun refresh() { + protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont + + protected open fun styleBackground(style: SessionEditorStyle) = style.editorBackground + + protected fun refresh() { revalidate() repaint() } + @RequiresEdt + private fun syncToolbar() { + toolbar.sync(copyText()?.isNotEmpty() == true) + } + + @RequiresEdt + private fun copyText(): String? { + val item = mode ?: return null + return copyMarkdown(item.trim) + } + override fun dumpLabel() = "TextView#$contentId" + + private data class CopyMode(val trim: Boolean) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt deleted file mode 100644 index 2cb3a4f30b4..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ /dev/null @@ -1,509 +0,0 @@ -@file:Suppress("TooManyFunctions") - -package ai.kilocode.client.session.views - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.model.Content -import ai.kilocode.client.session.model.Tool -import ai.kilocode.client.session.model.ToolExecState -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.views.base.PartView -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.components.JBTextArea -import com.intellij.util.ui.JBUI -import com.intellij.xml.util.XmlStringUtil -import java.awt.BorderLayout -import java.awt.Color -import java.awt.Component -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Box -import javax.swing.Icon -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.ScrollPaneConstants -import javax.swing.SwingUtilities - -/** Renders tool calls with VS Code-inspired rows/cards. */ -class ToolView(tool: Tool) : PartView() { - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - - private val root = JPanel(BorderLayout()).apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } - private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = true - background = SessionUiStyle.View.header() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val glyph = JBLabel() - private val title = JBLabel() - private val sub = JBLabel().apply { - foreground = UiStyle.Colors.weak() - } - private val state = JBLabel().apply { - foreground = UiStyle.Colors.weak() - } - private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = false - } - private val controls: JComponent = Box.createHorizontalBox().apply { - add(state) - add(arrow) - } - private val text = JBTextArea().apply { - isEditable = false - caret.isVisible = false - caret.isSelectionVisible = false - lineWrap = true - wrapStyleWord = true - foreground = bodyColor() - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val scroll = JBScrollPane(text).apply { - border = SessionUiStyle.View.cardTop() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (!canExpand(item)) return - toggle() - } - } - - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - setHover(true) - } - - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHover(false) - } - } - - init { - layout = BorderLayout() - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(sub, BorderLayout.CENTER) - header.add(glyph, BorderLayout.WEST) - header.add(center, BorderLayout.CENTER) - header.add(controls, BorderLayout.EAST) - root.add(header, BorderLayout.NORTH) - - listOf(header, glyph, title, sub, state, arrow, center, controls).forEach { - bind(it) - it.addMouseListener(click) - } - text.text = preview(item) - applyStyle(SessionEditorStyle.current()) - add(root, BorderLayout.CENTER) - sync() - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = header.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - val was = item.name - item = content - var changed = false - if (was != content.name || !canExpand(content)) changed = detach() || changed - changed = sync() || changed - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(title.text, sub.text, state.text).filter { it.isNotBlank() }.joinToString(" ") - - fun commandText(): String = command(item) - - fun outputText(): String = output(item) - - fun bodyText(): String = body(item) - - internal fun previewText(): String = text.text - - fun isExpanded(): Boolean = bodyVisible() - - fun hasToggle(): Boolean = arrow.isVisible - - internal fun bodyFont() = text.font - - internal fun titleFont() = title.font - - internal fun subtitleFont() = sub.font - - internal fun stateFont() = state.font - - internal fun bodyEditable() = text.isEditable - - internal fun bodyCaretVisible() = text.caret.isVisible - - internal fun bodyVisible() = scroll.parent === root - - internal fun controlCount() = if (arrow.isVisible) 1 else 0 - - internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy - - internal fun bodyWrap() = text.lineWrap - - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - - internal fun bodyCreated() = true - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - var changed = false - changed = setFont(title, style.boldEditorFont) || changed - changed = setFont(sub, style.smallEditorFont) || changed - changed = setFont(state, style.smallEditorFont) || changed - changed = setFont(text, style.transcriptFont) || changed - if (changed) refresh() - } - - fun toggle() { - if (!canExpand(item)) return - var changed = if (bodyVisible()) detach() else attach() - changed = syncArrow() || changed - if (changed) refresh() - } - - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (same(header.background, color)) return - header.background = color - header.repaint() - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - - private fun bind(component: Component) { - component.addMouseListener(mouse) - } - - private fun sync(): Boolean { - val expand = canExpand(item) - val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() - var changed = false - changed = syncCursor(cursor) || changed - changed = setVisible(arrow, expand) || changed - changed = setVisible(state, !expand) || changed - changed = syncArrow() || changed - changed = syncLabels() || changed - changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun syncLabels(): Boolean { - var changed = false - changed = setIcon(glyph, icon(item)) || changed - changed = setForeground(glyph, color(item)) || changed - changed = setText(title, title(item)) || changed - changed = setText(sub, subtitle(item)) || changed - changed = setForeground(title, titleColor(item)) || changed - changed = setText(state, stateText(item)) || changed - changed = setForeground(state, color(item)) || changed - return changed - } - - private fun syncArrow(): Boolean { - val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight - return setIcon(arrow, icon) - } - - private fun syncBody(): Boolean { - var changed = false - val value = preview(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 - changed = true - } - changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun attach(): Boolean { - if (bodyVisible()) return false - syncBody() - root.add(scroll, BorderLayout.CENTER) - return true - } - - private fun detach(): Boolean { - val attached = scroll.parent === root - if (attached) root.remove(scroll) - return attached - } - - private fun syncCursor(cursor: Cursor): Boolean { - var changed = false - listOf(header, glyph, title, sub, state, arrow, center, controls).forEach { - if (it.cursor?.type != cursor.type) { - it.cursor = cursor - changed = true - } - } - return changed - } - - private fun refresh() { - revalidate() - repaint() - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ToolView#$contentId(${labelText()})" -} - -private fun icon(tool: Tool) = when (tool.name) { - "read" -> AllIcons.Actions.Preview - "bash" -> AllIcons.Debugger.Console - else -> when (tool.state) { - ToolExecState.PENDING -> AllIcons.Process.Step_1 - ToolExecState.RUNNING -> AllIcons.Process.Step_2 - ToolExecState.COMPLETED -> AllIcons.Actions.Checked - ToolExecState.ERROR -> AllIcons.General.Error - } -} - -private fun title(tool: Tool) = when (tool.name) { - "read" -> KiloBundle.message("session.part.tool.read") - "bash" -> KiloBundle.message("session.part.tool.shell") - else -> toolTitle(tool) -} - -private fun subtitle(tool: Tool) = when (tool.name) { - "read" -> readPath(tool) - "bash" -> shellTitle(tool) - else -> toolSubtitle(tool) -} - -private fun setText(label: JBLabel, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) - if (label.text == value) return false - label.text = value - return true -} - -private fun setIcon(label: JBLabel, icon: Icon): Boolean { - if (label.icon === icon) return false - label.icon = icon - return true -} - -private fun setVisible(component: JComponent, visible: Boolean): Boolean { - if (component.isVisible == visible) return false - component.isVisible = visible - return true -} - -private fun setForeground(component: JComponent, color: Color): Boolean { - if (same(component.foreground, color)) return false - component.foreground = color - return true -} - -private fun setFont(component: JComponent, font: Font): Boolean { - if (component.font == font) return false - component.font = font - return true -} - -private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb - -private fun color(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() - ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() - ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() - ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() -} - -private fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { - UiStyle.Colors.errorLabelForeground() -} else { - UiStyle.Colors.fg() -} - -private fun stateText(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") - ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") - ToolExecState.COMPLETED -> "" - ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") -} - -private fun readPath(tool: Tool): String { - val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name - return tail(path).ifBlank { path } -} - -private fun shellTitle(tool: Tool): String = - tool.input["description"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } - ?: tool.title?.takeIf { it.isNotBlank() } - ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } - ?: "" - -private fun command(tool: Tool): String = - tool.input["command"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun output(tool: Tool): String = - tool.output?.takeIf { it.isNotBlank() } - ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) - -private fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) - -private fun shellPreview(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - sep() - append(out) - } - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun shellBody(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return buildString { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - if (isNotEmpty()) append("\n\n") - append(out) - } - if (err != null) { - if (isNotEmpty()) append("\n\n") - append(err) - } - } -} - -private fun plainPreview(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (out.isNotBlank()) append(out) - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun plainBody(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") -} - -private fun canExpand(tool: Tool): Boolean { - if (tool.name == "bash") { - return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() - } - return output(tool).isNotBlank() || !tool.error.isNullOrBlank() -} - -private fun toolTitle(tool: Tool): String = - tool.title?.takeIf { it.isNotBlank() } - ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } - -private fun toolSubtitle(tool: Tool): String { - val base = listOf("description", "query", "url", "filePath", "path", "name") - .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } - .firstOrNull() - val args = listOf("pattern", "include", "offset", "limit") - .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } - return listOfNotNull(base).plus(args).joinToString(" ") -} - -private fun tail(path: String): String { - val value = path.trimEnd('/', '\\') - val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) - if (index < 0) return value - return value.substring(index + 1) -} - -private class Preview { - private val text = StringBuilder() - private var cut = false - - fun append(value: String): Preview { - if (cut) return this - val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length - if (value.length <= rem) { - text.append(value) - return this - } - if (rem > 0) text.append(value, 0, rem) - cut = true - return this - } - - fun sep(): Preview { - if (text.isNotEmpty()) append("\n\n") - return this - } - - fun build(): String { - if (!cut) return text.toString() - if (text.isNotEmpty()) text.append("\n\n") - text.append(KiloBundle.message("session.part.tool.truncated")) - return text.toString() - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 57538a8f19d..2723c754bde 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -1,11 +1,18 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.ui.SessionLayoutPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.PartView +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Top-level transcript item representing one conversational turn. @@ -18,10 +25,17 @@ import com.intellij.util.ui.JBUI */ class TurnView( val id: String, + private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), -) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { + private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) }, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, +) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { - constructor(id: String) : this(id, SessionEditorStyle.current()) + constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) private val messages = LinkedHashMap() @@ -31,9 +45,10 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, style) + val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover) messages[msg.info.id] = view add(view) + syncCopyToolbars() revalidate() return view } @@ -42,9 +57,17 @@ class TurnView( fun removeMessage(msgId: String) { val view = messages.remove(msgId) ?: return remove(view) + Disposer.dispose(view) + syncCopyToolbars() revalidate() } + @RequiresEdt + fun syncCopyToolbars() { + val id = messages.values.reversed().firstNotNullOfOrNull { it.latestAssistantCopyId() } + for (view in messages.values) view.syncCopyToolbar(id) + } + /** Look up a nested [MessageView] by message id. */ fun messageView(id: String): MessageView? = messages[id] @@ -57,7 +80,16 @@ class TurnView( override fun applyStyle(style: SessionEditorStyle) { this.style = style for (view in messages.values) view.applyStyle(style) + syncCopyToolbars() revalidate() repaint() } + + override fun dispose() { + messages.values.forEach { + remove(it) + Disposer.dispose(it) + } + messages.clear() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 59c9b360dc2..871004d2c2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -3,13 +3,21 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.views.todo.TodoWriteView /** * Creates the appropriate [PartView] for a given [Content] subtype. @@ -20,15 +28,55 @@ import ai.kilocode.client.session.model.Tool * 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error. */ object ViewFactory { - fun create(content: Content): PartView = when (content) { - is Text -> TextView(content) - is Reasoning -> ReasoningView(content) - is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content) + fun create( + content: Content, + openFile: (String) -> Unit, + ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) + + fun create( + content: Content, + openFile: (String) -> Unit, + openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, + repo: String? = null, + openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, + ): PartView = when (content) { + is Text -> TextView(content, openUrl = openUrl, selection = selection) + is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) + is FileAttachment -> AttachmentView(content, openAttachment) + is Tool -> when { + TodoWriteView.canRender(content) -> TodoWriteView(content) + PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) + QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + ShellToolView.canRender(content) -> ShellToolView(content, selection = selection) + GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) + SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) + ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) + else -> ToolView(content, selection = selection) + } is Compaction -> CompactionView(content) is StepFinish -> error("step-finish is timeline-only") is Generic -> GenericView(content) } + fun createUser( + content: Content, + openFile: (String) -> Unit, + ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) + + fun createUser( + content: Content, + openFile: (String) -> Unit, + openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, + repo: String? = null, + mentions: List = emptyList(), + openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, + ): PartView = when (content) { + is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions) + else -> create(content, openFile, openUrl, selection, repo, openAttachment) + } + /** * Returns true when [view] must be replaced by a new renderer for [content]. * This happens when a running question tool (rendered as [ToolView]) completes @@ -36,7 +84,19 @@ object ViewFactory { */ fun shouldReplace(view: PartView, content: Content): Boolean { if (content !is Tool) return false + if (view is TodoWriteView) return !TodoWriteView.canRender(content) + if (view !is TodoWriteView && TodoWriteView.canRender(content)) return true + if (view is PlanExitView) return !PlanExitView.canRender(content) + if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) + if (view is ShellToolView) return !ShellToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is ShellToolView && ShellToolView.canRender(content)) return true + if (view is GlobToolView) return !GlobToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is GlobToolView && GlobToolView.canRender(content)) return true + if (view is SearchToolView) return !SearchToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is SearchToolView && SearchToolView.canRender(content)) return true + if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content) + if (view is ToolView && ReadToolView.canRender(content)) return true if (view is ToolView) return QuestionResultView.canRender(content) return false } 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 new file mode 100644 index 00000000000..df67cbbca67 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -0,0 +1,171 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities + +abstract class AbstractSessionPartView( + header: JComponent, + private val makeBody: () -> JComponent, + expanded: Boolean = false, + private val expandable: Boolean = true, +) : PartView() { + + constructor( + header: JComponent, + body: JComponent, + expanded: Boolean = false, + expandable: Boolean = true, + ) : this(header, { body }, expanded, expandable) + + protected val arrow = JBLabel() + protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) + private val bound = linkedSetOf() + private var body: JComponent? = null + + private val click = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (!arrow.isVisible) return + toggle() + } + } + private val mouse = object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + setHovered(true) + } + + override fun mouseExited(e: MouseEvent) { + if (inside(e)) return + setHovered(false) + } + } + + init { + layout = BorderLayout() + isOpaque = false + row.add(header, BorderLayout.CENTER) + row.add(arrow, BorderLayout.EAST) + add(row, BorderLayout.NORTH) + bindHeader(row, header, arrow) + if (expanded && expandable) add(body(), BorderLayout.CENTER) + if (!expandable) syncExpandable(false) else syncArrow() + } + + fun isExpanded(): Boolean = body?.parent === this + + fun toggle() { + if (!expandable || !arrow.isVisible) return + val changed = toggleLocal() + if (!changed) return + syncArrow() + refresh() + } + + open fun expand(): Boolean { + if (!expandable) return false + if (isExpanded()) return false + add(body(), BorderLayout.CENTER) + return true + } + + open fun collapse(): Boolean { + val item = body ?: return false + if (item.parent !== this) return false + remove(item) + return true + } + + protected fun hasBody(): Boolean = body != null + + protected fun bodyComponent(): JComponent = body() + + private fun toggleLocal(): Boolean { + val fn = resize ?: return toggleBody() + val expanded = isExpanded() + fn(this) { toggleBody() } + return expanded != isExpanded() + } + + private fun toggleBody(): Boolean = if (isExpanded()) collapse() else expand() + + fun syncExpandable(expandable: Boolean): Boolean { + val active = this.expandable && expandable + val changed = setVisible(arrow, active) + val detached = if (active) false else collapse() + val cursor = if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + val moved = syncCursor(cursor) + val icon = syncArrow() + return changed || detached || moved || icon + } + + protected fun bindHeader(vararg items: Component) { + items.forEach { bind(it) } + } + + protected fun refresh() { + revalidate() + repaint() + } + + protected open fun hoverColor(value: Boolean): Color? = null + + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) + val color = hoverColor(value) ?: return + if (row.background?.rgb == color.rgb) return + row.background = color + row.repaint() + } + + private fun inside(e: MouseEvent): Boolean { + val point = SwingUtilities.convertPoint(e.component, e.point, row) + return row.contains(point) + } + + private fun bind(component: Component) { + if (bound.contains(component)) return + bound.add(component) + component.addMouseListener(click) + component.addMouseListener(mouse) + } + + private fun body(): JComponent { + val item = body + if (item != null) return item + return makeBody().also { body = it } + } + + private fun syncCursor(cursor: Cursor): Boolean { + var changed = false + bound.forEach { + if (it.cursor?.type != cursor.type) { + it.cursor = cursor + changed = true + } + } + return changed + } + + private fun syncArrow(): Boolean { + val icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed + if (arrow.icon === icon) return false + arrow.icon = icon + return true + } + + private fun setVisible(component: JComponent, visible: Boolean): Boolean { + if (component.isVisible == visible) return false + component.isVisible = visible + return true + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 58c379568d8..3a73b355a65 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -1,10 +1,12 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextArea @@ -14,10 +16,9 @@ import java.awt.BorderLayout import java.awt.Color import java.awt.Component import java.awt.Dimension -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.Icon +import java.awt.Rectangle import javax.swing.JButton +import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel @@ -32,12 +33,16 @@ import javax.swing.JPanel * outer card shell so they share the same background, padding, and text * styling without duplicating the setup. * - * The column always contains (in order): optional top, header row with the - * header text, description text, optional content, optional action footer. + * The root uses BorderLayout regions: optional top and header in north, + * optional view content in center, and optional action controls in south. * Call [setTopPanel], [setHeaderIcon], [setHeader], [setDescription], * [setContent], [setActions], or [setActionEnabled] to configure the card. */ -class BaseQuestionView : RoundedContentPanel( +class BaseQuestionView( + private val selection: SessionSelection? = null, +) : RoundedContentPanel( + UiStyle.Gap.pad(), + UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad(), ), SessionEditorStyleTarget { @@ -67,6 +72,10 @@ class BaseQuestionView : RoundedContentPanel( private val tracked = mutableListOf>() + private val north = Stack.vertical() + + private val text = Stack.vertical() + private val header = object : JPanel(BorderLayout(UiStyle.Gap.sm(), 0)) { override fun getMaximumSize(): Dimension { val size = preferredSize @@ -74,11 +83,11 @@ class BaseQuestionView : RoundedContentPanel( } }.apply { isOpaque = false - alignmentX = Component.LEFT_ALIGNMENT } private val icon = JBLabel().apply { - border = JBUI.Borders.emptyRight(UiStyle.Gap.sm()) + horizontalAlignment = JBLabel.CENTER + verticalAlignment = JBLabel.CENTER isVisible = false } @@ -87,21 +96,28 @@ class BaseQuestionView : RoundedContentPanel( private var top: JComponent? = null private var content: JComponent? = null + private var actionLeft: JComponent? = null + private var gap = UiStyle.Gap.lg() - // action buttons keyed by id for enabled-state updates + // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() - private var actionFooter: JComponent? = null + private val actionHandlers = mutableMapOf Unit>() + private val actionOrder = mutableListOf() - private val col = JPanel().apply { + private val mainActions = Stack.horizontal(gap = UiStyle.Gap.sm()) + + private val sideActions = Stack.horizontal() + + private val footer = JPanel(BorderLayout()).apply { isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) } init { - header.add(icon, BorderLayout.WEST) - header.add(headerText, BorderLayout.CENTER) - addToCenter(col) - rebuildCol() + text.next(headerText).next(descriptionText) + header.add(text, BorderLayout.CENTER) + syncNorth() + add(north, BorderLayout.NORTH) } // ---- public text API ---- @@ -130,14 +146,13 @@ class BaseQuestionView : RoundedContentPanel( /** * Optional panel rendered above the header row (e.g. summary + nav in - * [ai.kilocode.client.session.views.question.QuestionView]). When set, - * it is inserted as the first child of the column; calling with `null` - * removes a previously set component. + * [ai.kilocode.client.session.views.question.QuestionView]). Calling with + * `null` removes a previously set component. */ @RequiresEdt fun setTopPanel(top: JComponent?) { this.top = top - rebuildCol() + syncNorth() } /** @@ -149,8 +164,13 @@ class BaseQuestionView : RoundedContentPanel( this.icon.icon = icon this.icon.toolTipText = tooltip this.icon.isVisible = icon != null + val attached = this.icon.parent === header + if (icon != null && !attached) header.add(this.icon, BorderLayout.WEST) + if (icon == null && attached) header.remove(this.icon) this.icon.revalidate() this.icon.repaint() + header.revalidate() + header.repaint() } /** @@ -159,45 +179,50 @@ class BaseQuestionView : RoundedContentPanel( */ @RequiresEdt fun setContent(content: JComponent?) { + this.content?.let { remove(it) } this.content = content - rebuildCol() + syncNorth() + content?.let { add(it, BorderLayout.CENTER) } + revalidate() + repaint() + } + + @RequiresEdt + fun setSpacing(top: Int, gap: Int) { + this.gap = gap + border = JBUI.Borders.empty(top, UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) + syncNorth() + revalidate() + repaint() } /** * Configure the action buttons shown in the card's right-aligned footer. * - * All buttons are created fresh; stable button references across calls can be - * maintained by the caller through [setActionEnabled] using the [Action.id]. + * Buttons are retained by stable [Action.id] when possible and updated in place. * Pass an empty list to remove the footer entirely. */ @RequiresEdt fun setActions(actions: List) { - actionButtons.clear() - actionFooter = if (actions.isEmpty()) { - null - } else { - val row = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.X_AXIS) - alignmentX = Component.LEFT_ALIGNMENT - } - for ((idx, action) in actions.withIndex()) { - if (idx > 0) row.add(Box.createHorizontalStrut(UiStyle.Gap.sm())) - val btn = makeButton(action.text, action.primary).apply { - isEnabled = action.enabled - addActionListener { action.handler() } - } - actionButtons[action.id] = btn - row.add(btn) - } - val footer = JPanel(BorderLayout()).apply { - isOpaque = false - alignmentX = Component.LEFT_ALIGNMENT - } - footer.add(row, BorderLayout.EAST) - footer + val ids = actions.map { it.id }.toSet() + val stale = actionButtons.keys - ids + stale.forEach { + actionButtons.remove(it) + actionHandlers.remove(it) + } + actionOrder.clear() + mainActions.removeAll() + for (action in actions) { + val btn = actionButtons[action.id] ?: makeButton(action.id, action.text).also { actionButtons[action.id] = it } + actionHandlers[action.id] = action.handler + btn.text = action.text + btn.isEnabled = action.enabled + btn.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, if (action.primary) true else null) + actionButtons[action.id] = btn + actionOrder.add(action.id) + mainActions.next(btn) } - rebuildCol() + syncFooter() } /** @@ -209,6 +234,49 @@ class BaseQuestionView : RoundedContentPanel( actionButtons[id]?.isEnabled = enabled } + /** + * Optional component rendered on the left side of the action footer. + * Pass `null` to remove a previously set component. + */ + @RequiresEdt + fun setActionLeft(component: JComponent?) { + actionLeft = component + sideActions.removeAll() + component?.let { + it.isOpaque = false + sideActions.next(it).fill(UiStyle.Gap.pad()) + } + syncFooter() + } + + /** + * Show or hide a specific action button identified by [id]. + * No-ops if the id is not found. + */ + @RequiresEdt + fun setActionVisible(id: String, visible: Boolean) { + val btn = actionButtons[id] ?: return + if (btn.isVisible == visible) return + btn.isVisible = visible + mainActions.revalidate() + mainActions.repaint() + } + + /** + * Update a specific action button label identified by [id]. + * No-ops if the id is not found. + */ + @RequiresEdt + fun setActionText(id: String, text: String) { + val btn = actionButtons[id] ?: return + if (btn.text == text) return + btn.text = text + } + + /** Returns the retained action component for focus management, or this card when absent. */ + @RequiresEdt + fun preferredActionComponent(id: String): JComponent = actionButtons[id] ?: this + // ---- SessionEditorStyleTarget ---- @RequiresEdt @@ -219,42 +287,45 @@ class BaseQuestionView : RoundedContentPanel( // ---- contentColor override ---- - override fun contentColor(): Color = SessionUiStyle.View.surface() - - override fun outlineColor(): Color = SessionUiStyle.View.line() - - // ---- internal test helpers ---- - - /** Returns the font currently applied to the header text area. For tests only. */ - internal fun headerFont() = headerText.font + override fun contentColor(): Color = SessionUiStyle.View.Surface.bgColor() - /** Returns the font currently applied to the description text area. For tests only. */ - internal fun descriptionFont() = descriptionText.font - - /** Returns all action buttons as generic JButton, keyed by their action id. For tests only. */ - internal fun actionButtonsForTest(): Map = actionButtons.toMap() + override fun outlineColor(): Color = SessionUiStyle.View.Outline.brightColor() // ---- private helpers ---- - private fun rebuildCol() { - col.removeAll() - top?.let { col.add(it) } - col.add(header) - col.add(descriptionText) - content?.let { - col.add(gap()) - col.add(it) + private fun syncNorth() { + north.removeAll() + top?.let { north.next(it) } + north.next(header) + if (content != null) north.fill(gap) + north.revalidate() + north.repaint() + } + + private fun syncFooter() { + val layout = footer.layout as BorderLayout + val west = layout.getLayoutComponent(BorderLayout.WEST) + val east = layout.getLayoutComponent(BorderLayout.EAST) + if (actionLeft == null) { + if (west != null) footer.remove(west) + } else if (west == null) { + footer.add(sideActions, BorderLayout.WEST) } - actionFooter?.let { - col.add(gap()) - col.add(it) + if (actionOrder.isEmpty()) { + if (east != null) footer.remove(east) + } else if (east == null) { + footer.add(mainActions, BorderLayout.EAST) } - col.revalidate() - col.repaint() - } - private fun gap(): Component = Box.createVerticalStrut(UiStyle.Gap.lg()).apply { - setAlignmentX(Component.LEFT_ALIGNMENT) + val root = this.layout as BorderLayout + val attached = root.getLayoutComponent(BorderLayout.SOUTH) === footer + val needed = actionLeft != null || actionOrder.isNotEmpty() + if (needed && !attached) add(footer, BorderLayout.SOUTH) + if (!needed && attached) remove(footer) + footer.revalidate() + footer.repaint() + revalidate() + repaint() } private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { @@ -266,6 +337,8 @@ class BaseQuestionView : RoundedContentPanel( return Dimension(Int.MAX_VALUE, size.height) } + override fun scrollRectToVisible(aRect: Rectangle) {} + private fun withWidth(fallback: Int): Dimension { val w = availableWidth() if (w <= 0) return Dimension(super.getPreferredSize().width, fallback) @@ -300,6 +373,7 @@ class BaseQuestionView : RoundedContentPanel( alignmentX = Component.LEFT_ALIGNMENT } tracked.add(area to bold) + selection?.register(area) applyFont(area, bold) return area } @@ -309,10 +383,9 @@ class BaseQuestionView : RoundedContentPanel( if (area.font != font) area.font = font } - private fun makeButton(text: String, primary: Boolean): JButton { + private fun makeButton(id: String, text: String): JButton { val btn = object : JButton(text) { init { - if (primary) putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) syncBackground() } @@ -322,9 +395,10 @@ class BaseQuestionView : RoundedContentPanel( } private fun syncBackground() { - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() } } + btn.addActionListener { actionHandlers[id]?.invoke() } return btn } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 0dcfb05e209..7f535048a70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel -import java.awt.BorderLayout /** * Fallback renderer for part types that have no dedicated view. @@ -14,20 +13,20 @@ import java.awt.BorderLayout * confusing empty gaps), this shows a dim label with the raw type name. * This makes it easy to spot new part types that need a proper renderer. */ -class GenericView(content: Generic) : PartView() { +class GenericView private constructor( + content: Generic, + private val label: JBLabel, +) : SecondarySessionPartView(label, JBLabel()) { - override val contentId: String = content.id + constructor(content: Generic) : this(content, JBLabel("[${content.type}]")) - private val label = JBLabel("[${content.type}]").apply { - foreground = UiStyle.Colors.weak() - border = com.intellij.util.ui.JBUI.Borders.empty(UiStyle.Gap.xs(), 0) - } + override val contentId: String = content.id init { - layout = BorderLayout() - isOpaque = false + label.foreground = UiStyle.Colors.weak() applyStyle(SessionEditorStyle.current()) - add(label, BorderLayout.CENTER) + syncExpandable(false) + border = null } override fun update(content: Content) {} // generic content has no updatable state diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 9cfacf9b41c..b1b0256110f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import com.intellij.openapi.Disposable +import javax.swing.JComponent import javax.swing.JPanel /** @@ -14,11 +16,15 @@ import javax.swing.JPanel * * All methods must be called on the EDT. */ -abstract class PartView : JPanel(), SessionEditorStyleTarget { +abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { /** Stable [Content.id] this renderer was created for. */ abstract val contentId: String + var resize: ((JComponent, () -> Unit) -> Unit)? = null + + var hover: ((PartView, Boolean) -> Unit)? = null + /** * Apply a full content update — replace, not append. * Called when [ai.kilocode.client.session.model.SessionModelEvent.ContentUpdated] fires. @@ -32,8 +38,12 @@ abstract class PartView : JPanel(), SessionEditorStyleTarget { */ open fun appendDelta(delta: String) {} + open fun setHovered(value: Boolean) {} + override fun applyStyle(style: SessionEditorStyle) {} + override fun dispose() {} + /** Readable name for test dumps. */ open fun dumpLabel(): String = javaClass.simpleName } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt new file mode 100644 index 00000000000..d82a3e11628 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import javax.swing.JComponent + +abstract class PrimarySessionPartView( + header: JComponent, + content: JComponent, + expanded: Boolean = false, + expandable: Boolean = true, +) : AbstractSessionPartView(header, content, expanded, expandable) { + init { + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + row.isOpaque = true + row.background = SessionUiStyle.View.Surface.headerBgColor() + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + syncBorder() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed + } + + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return + } + border = JBUI.Borders.empty(1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt new file mode 100644 index 00000000000..d0a0d3808e5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -0,0 +1,52 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import javax.swing.JComponent + +abstract class SecondarySessionPartView( + header: JComponent, + content: () -> JComponent, + expanded: Boolean = false, + expandable: Boolean = true, +) : AbstractSessionPartView(header, content, expanded, expandable) { + + constructor( + header: JComponent, + content: JComponent, + expanded: Boolean = false, + expandable: Boolean = true, + ) : this(header, { content }, expanded, expandable) + init { + row.isOpaque = true + row.background = SessionUiStyle.View.Surface.headerBgColor() + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + syncBorder() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed + } + + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return + } + border = JBUI.Borders.empty(1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index 886cf788e4e..e8f52e97dec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -6,28 +6,32 @@ import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.ui.style.SessionUiStyle.View.CARD_LAYOUT_GAP +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.ColorUtil import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration import com.intellij.ui.components.JBHtmlPaneStyleConfiguration import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout -import java.awt.Component +import java.awt.Container import java.awt.FlowLayout -import javax.swing.BoxLayout +import javax.swing.JButton import javax.swing.JPanel import javax.swing.text.html.StyleSheet @@ -40,22 +44,20 @@ import javax.swing.text.html.StyleSheet */ class PermissionView( private val reply: (String, PermissionReplyDto) -> Unit, + private val selection: SessionSelection? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { override val sessionViewKind = SessionView.Kind.Default private var requestId: String? = null private var style = SessionEditorStyle.current() - private val card = BaseQuestionView() + private val card = BaseQuestionView(selection) - private val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = Component.LEFT_ALIGNMENT - } + private val body = Stack.vertical() // Track target panes for style updates private val panes = mutableListOf() + private val regs = mutableListOf() private val diffViews = mutableListOf() private val ID_DENY = "deny" @@ -65,7 +67,7 @@ class PermissionView( isOpaque = false isVisible = false - card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title")) + card.setHeaderIcon(SessionViewIcons.warning, KiloBundle.message("session.permission.title")) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") }, @@ -81,6 +83,7 @@ class PermissionView( card.setHeader(KiloBundle.message("session.permission.title")) body.removeAll() + disposeRegs() panes.clear() diffViews.clear() @@ -104,6 +107,7 @@ class PermissionView( fun hideView() { requestId = null body.removeAll() + disposeRegs() panes.clear() diffViews.clear() isVisible = false @@ -123,9 +127,8 @@ class PermissionView( /** Adds a three-column permission detail row: tool, target, and changes. */ private fun addDetailRow(action: String, target: String?, diffs: List) { - val row = JPanel(BorderLayout(CARD_LAYOUT_GAP, 0)).apply { + val row = JPanel(BorderLayout(SessionUiStyle.View.Layout.GAP, 0)).apply { isOpaque = false - alignmentX = Component.LEFT_ALIGNMENT } val actionLbl = JBLabel(action).apply { @@ -164,12 +167,13 @@ class PermissionView( isOpaque = true this.text = "
    ${XmlStringUtil.escapeString(text)}
    " applyTargetPane(this) + selection?.register(this)?.let(regs::add) } private fun applyTargetPane(pane: JBHtmlPane) { pane.font = style.transcriptFont pane.foreground = style.editorForeground - pane.background = SessionUiStyle.View.headerHover() + pane.background = SessionUiStyle.View.Surface.headerHoverBgColor() pane.reloadCssStylesheets() } @@ -177,7 +181,7 @@ class PermissionView( val sheet = StyleSheet() val font = style.transcriptFont val fg = ColorUtil.toHtmlColor(style.editorForeground) - val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.Surface.headerHoverBgColor()) val family = font.name.replace("\\", "\\\\").replace("'", "\\'") sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }") sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }") @@ -207,7 +211,6 @@ class PermissionView( val label = JBLabel(msg).apply { border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) - alignmentX = Component.LEFT_ALIGNMENT } body.add(label) } @@ -248,10 +251,33 @@ class PermissionView( parent?.repaint() } + private fun disposeRegs() { + regs.forEach(Disposer::dispose) + regs.clear() + } + // Test helpers - internal fun runButtonForTest() = card.actionButtonsForTest()[ID_RUN]!! - internal fun denyButtonForTest() = card.actionButtonsForTest()[ID_DENY]!! + internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.run") } + internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.deny") } internal fun codeLabelsForTest() = panes.toList() internal fun diffViewsForTest() = diffViews.toList() - internal fun headerFontForTest() = card.headerFont() + internal fun headerFontForTest() = textAreas(card).first { it.font.isBold }.font + + private fun buttons(root: Container): List { + val result = mutableListOf() + if (root is JButton) result.add(root) + for (child in root.components) { + if (child is Container) result.addAll(buttons(child)) + } + return result + } + + private fun textAreas(root: Container): List { + val result = mutableListOf() + if (root is JBTextArea) result.add(root) + for (child in root.components) { + if (child is Container) result.addAll(textAreas(child)) + } + return result + } } 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 e33303ede14..0b50d798269 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 @@ -3,12 +3,15 @@ package ai.kilocode.client.session.views.question import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PartView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI @@ -18,44 +21,46 @@ import java.awt.Component import java.awt.Cursor import java.awt.Dimension import java.awt.Font +import java.awt.Rectangle import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.BoxLayout import javax.swing.JPanel import javax.swing.SwingUtilities -class QuestionResultView(tool: Tool) : PartView() { +class QuestionResultView(tool: Tool, private val selection: SessionSelection? = null) : PartView() { override val contentId: String = tool.id private var result = QuestionResultParser.parse(tool) ?: QuestionResult(emptyList(), emptyList()) private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + private val regs = mutableListOf() private val root = object : JPanel(BorderLayout()) { override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty(1) } } - private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)) { + private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.header() + background = SessionUiStyle.View.Surface.headerBgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) } } - private val glyph = JBLabel(AllIcons.General.Balloon) + private val glyph = JBLabel(SessionViewIcons.bubble) private val title = JBLabel() private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } private var pane: JPanel? = null @@ -65,10 +70,10 @@ class QuestionResultView(tool: Tool) : PartView() { } private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { setHover(true) } + override fun mouseEntered(e: MouseEvent) { setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -94,6 +99,7 @@ class QuestionResultView(tool: Tool) : PartView() { add(root, BorderLayout.CENTER) syncLabels() syncArrow() + syncBorder() } override fun update(content: Content) { @@ -117,13 +123,18 @@ class QuestionResultView(tool: Tool) : PartView() { } fun toggle() { + resize?.invoke(this) { toggleBody() } ?: toggleBody() + syncArrow() + refresh() + } + + private fun toggleBody() { if (isExpanded()) { pane?.let { root.remove(it) } } else { root.add(body(), BorderLayout.CENTER) } - syncArrow() - refresh() + syncBorder() } fun isExpanded(): Boolean = pane?.parent === root @@ -142,6 +153,11 @@ class QuestionResultView(tool: Tool) : PartView() { fun titleFont(): Font = title.font fun subFont(): Font = sub.font + override fun dispose() { + disposeRegs() + texts.clear() + } + override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})" companion object { @@ -154,10 +170,19 @@ class QuestionResultView(tool: Tool) : PartView() { override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.compound( + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), + JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ), ) } }.apply { @@ -178,6 +203,7 @@ class QuestionResultView(tool: Tool) : PartView() { private fun syncBody() { val panel = pane ?: return panel.removeAll() + disposeRegs() texts.clear() for ((i, q) in result.questions.withIndex()) { @@ -213,6 +239,8 @@ class QuestionResultView(tool: Tool) : PartView() { return Dimension(Int.MAX_VALUE, size.height) } + override fun scrollRectToVisible(aRect: Rectangle) {} + private fun withWidth(fallback: Int): Dimension { val width = space() if (width <= 0) return Dimension(super.getPreferredSize().width, fallback) @@ -246,19 +274,39 @@ class QuestionResultView(tool: Tool) : PartView() { border = JBUI.Borders.empty() } texts.add(area to bold) + selection?.register(area)?.let(regs::add) setFont(area, bold) return area } + private fun disposeRegs() { + regs.forEach(Disposer::dispose) + regs.clear() + } + private fun syncArrow() { - arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed } - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (header.background?.rgb == color.rgb) return - header.background = color - header.repaint() + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) + val color = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + if (header.background?.rgb != color.rgb) { + header.background = color + header.repaint() + } + } + + private fun syncBorder() { + if (isExpanded()) { + root.border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + ) + return + } + root.border = JBUI.Borders.empty(1) } private fun inside(e: MouseEvent): Boolean { 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 2613300e2df..d4e04515aae 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 @@ -6,14 +6,18 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto -import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel @@ -27,6 +31,7 @@ import java.awt.Color import java.awt.Component import java.awt.Dimension import java.awt.GridBagLayout +import java.awt.Rectangle import java.awt.event.FocusAdapter import java.awt.event.FocusEvent import java.awt.event.MouseAdapter @@ -38,13 +43,16 @@ import javax.swing.ButtonGroup import javax.swing.JPanel import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.util.concurrency.annotations.RequiresEdt /** Question tool form rendered inside the session transcript. */ class QuestionView( private val project: Project, - private val reply: (String, QuestionReplyDto) -> Unit, + private val reply: (String, QuestionReplyDto, List>) -> Unit, private val reject: (String) -> Unit, - private val scroll: () -> Unit = {}, + private val follow: () -> Boolean = { true }, + private val scroll: (Boolean) -> Unit = {}, + private val selection: SessionSelection? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { override val sessionViewKind = SessionView.Kind.Default @@ -58,11 +66,12 @@ class QuestionView( private var customOpen = emptyList() private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + private val regs = mutableListOf() // The custom editor for the currently shown question; null when not shown. private var customEditor: SessionEditorTextField? = null private var customFocus: FocusAdapter? = null - private val card = BaseQuestionView() + private val card = BaseQuestionView(selection) private val summary = JBLabel() private val nav = JPanel().apply { @@ -70,14 +79,14 @@ class QuestionView( layout = BoxLayout(this, BoxLayout.X_AXIS) } private val back = HoverIcon().apply { - val ico = AllIcons.Actions.Back + val ico = SessionViewIcons.chevronLeft icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.back") addActionListener { goBack() } } private val fwd = HoverIcon().apply { - val ico = AllIcons.Actions.Forward + val ico = SessionViewIcons.chevronRight icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.next") @@ -85,7 +94,7 @@ class QuestionView( } private val topPanel = JPanel(BorderLayout()).apply { isOpaque = false - border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + border = JBUI.Borders.empty() alignmentX = Component.LEFT_ALIGNMENT } private val body = JPanel().apply { @@ -113,6 +122,7 @@ class QuestionView( add(card, BorderLayout.CENTER) } + @RequiresEdt fun show(q: Question) { if (q.items.isEmpty()) { hideView() @@ -121,13 +131,17 @@ class QuestionView( request = q.id question = q idx = 0 + val tail = follow() selections = List(q.items.size) { mutableSetOf() } customTexts = List(q.items.size) { "" } customOpen = List(q.items.size) { false } isVisible = true + applyStyle(SessionEditorStyle.current()) syncPage() + scroll(tail) } + @RequiresEdt fun hideView() { request = null question = null @@ -135,8 +149,9 @@ class QuestionView( selections = emptyList() customTexts = emptyList() customOpen = emptyList() - customEditor = null + disposeCustomEditor() customFocus = null + disposeRegs() texts.clear() body.removeAll() card.setActions(emptyList()) @@ -144,11 +159,12 @@ class QuestionView( refresh() } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style card.applyStyle(style) customEditor?.let { ed -> - ed.font = style.transcriptFont + ed.font = style.editorFont ed.getEditor(false)?.let(style::applyToEditor) ed.background = style.editorScheme.defaultBackground } @@ -157,10 +173,12 @@ class QuestionView( refresh() } + @RequiresEdt private fun syncPage() { val q = question ?: return + disposeRegs() texts.clear() - customEditor = null + disposeCustomEditor() customFocus = null body.removeAll() if (review(q)) { @@ -180,14 +198,24 @@ class QuestionView( refresh() } + @RequiresEdt private fun syncHeader(q: Question) { val total = q.items.size val shown = minOf(idx + 1, total) summary.text = KiloBundle.message("session.question.summary", shown, total) summary.foreground = UiStyle.Colors.weak() + summary.isVisible = total > 1 nav.isVisible = total > 1 + topPanel.isVisible = total > 1 + if (total > 1) { + topPanel.border = JBUI.Borders.empty(0, 0, UiStyle.Gap.sm(), 0) + card.setSpacing(UiStyle.Gap.sm(), UiStyle.Gap.pad()) + return + } + card.setSpacing(UiStyle.Gap.xl(), UiStyle.Gap.pad()) } + @RequiresEdt private fun syncFooter(q: Question) { val actions = mutableListOf() actions.add(BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.question.dismiss"), primary = false) { doReject() }) @@ -213,6 +241,7 @@ class QuestionView( card.setActions(actions) } + @RequiresEdt private fun syncControls(q: Question) { val ready = isReady(idx) back.isEnabled = idx > 0 @@ -255,12 +284,16 @@ class QuestionView( } } + private fun optionAnswers(i: Int): List = selections.getOrNull(i)?.toList() ?: emptyList() + + @RequiresEdt private fun addContent(item: QuestionItem, set: MutableSet) { val opts = optionList(item, set) opts.alignmentX = Component.LEFT_ALIGNMENT body.add(opts) } + @RequiresEdt private fun addReview(q: Question) { for ((i, item) in q.items.withIndex()) { val row = reviewRow(item, i) @@ -271,6 +304,7 @@ class QuestionView( (body.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty() } + @RequiresEdt private fun reviewRow(item: QuestionItem, i: Int): JPanel { val row = JPanel().apply { isOpaque = false @@ -293,6 +327,7 @@ class QuestionView( return row } + @RequiresEdt private fun optionList(item: QuestionItem, set: MutableSet): JPanel { val panel = JPanel().apply { isOpaque = false @@ -315,6 +350,7 @@ class QuestionView( return panel } + @RequiresEdt private fun customRow(item: QuestionItem, set: MutableSet): JPanel { val open = customOpen.getOrElse(idx) { false } val existing = customTexts.getOrElse(idx) { "" }.trim() @@ -426,12 +462,14 @@ class QuestionView( return row } + @RequiresEdt internal fun testFocusCustomEditor() { val ed = customEditor ?: return val focus = customFocus ?: return focus.focusGained(FocusEvent(ed, FocusEvent.FOCUS_GAINED)) } + @RequiresEdt private fun selectCustom(item: QuestionItem, set: MutableSet) { if (customOpen.getOrElse(idx) { false }) return if (!item.multiple) set.clear() @@ -448,8 +486,9 @@ class QuestionView( * the first time the component becomes visible, satisfying the platform's * read-context requirement without any additional wrapping here. */ + @RequiresEdt private fun buildCustomEditor(): SessionEditorTextField { - val ed = SessionEditorTextField(project) + val ed = SessionEditorTextField(project, selection = selection) ed.border = JBUI.Borders.empty() ed.setFontInheritedFromLAF(false) ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) @@ -467,7 +506,8 @@ class QuestionView( ex.settings.isAdditionalPageAtBottom = false ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER } - ed.font = style.transcriptFont + selection?.register(ed)?.let(regs::add) + ed.font = style.editorFont ed.background = style.editorScheme.defaultBackground // Pre-fill with saved text. This call also forces lazy document creation so @@ -480,13 +520,14 @@ class QuestionView( // The document was already created above (ed.text = saved ensures getDocument() // was called), so installDocumentListener succeeds. ed.addDocumentListener(object : DocumentListener { + @RequiresEdt override fun documentChanged(e: DocumentEvent) { val txt = ed.text customTexts = customTexts.toMutableList().also { it[idx] = txt } syncEditorHeight(ed) question?.let(::syncControls) refresh() - scroll() + scroll(follow()) } }) @@ -494,6 +535,14 @@ class QuestionView( return ed } + @RequiresEdt + private fun disposeCustomEditor() { + val ed = customEditor ?: return + customEditor = null + ed.getEditor(false)?.let { EditorFactory.getInstance().releaseEditor(it) } + } + + @RequiresEdt private fun syncEditorHeight(ed: SessionEditorTextField) { val editor = ed.getEditor(false) val estimated = estimatedLines(ed) @@ -504,6 +553,7 @@ class QuestionView( ed.minimumSize = Dimension(0, height) } + @RequiresEdt private fun estimatedLines(ed: SessionEditorTextField): Int { val width = space(ed) if (width <= 0) return (ed.text.count { it == '\n' } + 1).coerceAtLeast(1) @@ -514,6 +564,7 @@ class QuestionView( }.coerceAtLeast(1) } + @RequiresEdt private fun space(component: Component): Int { if (component.width > 0) return component.width var node = component.parent @@ -528,6 +579,7 @@ class QuestionView( } /** Re-syncs the current page after the custom row toggle changes. */ + @RequiresEdt private fun refreshCustomRow() { val q = question ?: return syncPage() @@ -536,9 +588,10 @@ class QuestionView( customEditor?.requestFocusInWindow() } syncControls(q) - scroll() + scroll(follow()) } + @RequiresEdt private fun radioRow(opt: QuestionOption, set: MutableSet, group: ButtonGroup): JPanel { val radio = JBRadioButton().apply { actionCommand = opt.label @@ -560,6 +613,7 @@ class QuestionView( return optionRow(radio, opt) } + @RequiresEdt private fun checkboxRow(opt: QuestionOption, set: MutableSet): JPanel { val box = JBCheckBox().apply { actionCommand = opt.label @@ -573,6 +627,7 @@ class QuestionView( return optionRow(box, opt) } + @RequiresEdt private fun optionRow(toggle: AbstractButton, opt: QuestionOption): JPanel { val row = JPanel(BorderLayout()).apply { isOpaque = false @@ -615,6 +670,7 @@ class QuestionView( return row } + @RequiresEdt private fun text(value: String, color: Color, bold: Boolean = false): JBTextArea { val area = object : JBTextArea(value) { override fun getPreferredSize() = withWidth(super.getPreferredSize().height) @@ -624,6 +680,8 @@ class QuestionView( return Dimension(Int.MAX_VALUE, size.height) } + override fun scrollRectToVisible(aRect: Rectangle) {} + private fun withWidth(fallback: Int): Dimension { val width = space() if (width <= 0) return Dimension(super.getPreferredSize().width, fallback) @@ -657,10 +715,16 @@ class QuestionView( border = JBUI.Borders.empty() } texts.add(area to bold) + selection?.register(area)?.let(regs::add) setFont(area, bold) return area } + private fun disposeRegs() { + regs.forEach(Disposer::dispose) + regs.clear() + } + private fun single(q: Question): Boolean = q.items.size == 1 && !q.items[0].multiple private fun review(q: Question): Boolean = !single(q) && idx == q.items.size @@ -669,13 +733,15 @@ class QuestionView( private fun direct(q: Question): Boolean = single(q) + @RequiresEdt private fun goBack() { if (idx <= 0) return idx-- syncPage() - scroll() + scroll(true) } + @RequiresEdt private fun goForward() { val q = question ?: return if (idx >= q.items.size || !isReady(idx)) return @@ -686,39 +752,47 @@ class QuestionView( if (!toReview) { idx++ syncPage() - scroll() + scroll(true) } } + @RequiresEdt private fun goReview() { val q = question ?: return if (idx == q.items.size - 1 && isReady(idx)) { idx = q.items.size syncPage() - scroll() + scroll(true) } } + @RequiresEdt private fun refreshSelection() { question?.let(::syncControls) refresh() - scroll() + scroll(follow()) } + @RequiresEdt private fun doReply() { val id = request ?: return if ((question?.items?.indices ?: return).any { !isReady(it) }) return val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) } - reply(id, QuestionReplyDto(answers)) + val opts = (question?.items?.indices ?: return).map { optionAnswers(it) } + reply(id, QuestionReplyDto(answers), opts) hideView() + scroll(follow()) } + @RequiresEdt private fun doReject() { val id = request ?: return reject(id) hideView() + scroll(follow()) } + @RequiresEdt private fun setFont(area: JBTextArea, bold: Boolean): Boolean { val font = if (bold) style.boldFont else style.regularFont if (area.font == font) return false @@ -726,6 +800,7 @@ class QuestionView( return true } + @RequiresEdt private fun refresh() { revalidate() repaint() 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 new file mode 100644 index 00000000000..b38b613cabf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -0,0 +1,140 @@ +package ai.kilocode.client.session.views.todo + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.TodoDto +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import javax.swing.BoxLayout +import javax.swing.JPanel + +class TodoListPanel( + todos: List = emptyList(), + private var before: Int = 0, + private var after: Int = 0, +) : JPanel() { + + private var items = todos + private var style = SessionEditorStyle.current() + private val rows = mutableListOf() + private val prior = JBLabel() + private val later = JBLabel() + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()) + add(prior) + add(later) + applyStyle(style) + sync() + } + + fun update(todos: List, hiddenBefore: Int = 0, hiddenAfter: Int = 0) { + val size = todos.size != items.size + items = todos + before = hiddenBefore + after = hiddenAfter + if (size) sync() + rows.forEachIndexed { index, row -> row.update(items[index], style) } + syncHidden() + revalidate() + repaint() + } + + fun applyStyle(style: SessionEditorStyle) { + this.style = style + prior.font = style.smallFont + later.font = style.smallFont + prior.foreground = UiStyle.Colors.weak() + later.foreground = UiStyle.Colors.weak() + rows.forEachIndexed { index, row -> row.update(items[index], style) } + syncHidden() + } + + internal fun rowCount() = rows.size + + internal fun rowText(index: Int) = rows[index].text.text + + internal fun rowChecked(index: Int) = rows[index].check.isSelected + + internal fun rowCheckboxOpaque(index: Int) = rows[index].check.isOpaque + + internal fun rowFont(index: Int) = rows[index].text.font + + internal fun rowForeground(index: Int) = rows[index].text.foreground + + internal fun hiddenText() = listOf(prior, later).filter { it.isVisible }.joinToString(" ") { it.text } + + private fun sync() { + removeAll() + rows.clear() + add(prior) + items.forEach { todo -> + val row = Row(todo, style) + rows.add(row) + add(row.panel) + } + add(later) + syncHidden() + } + + private fun syncHidden() { + prior.text = hidden(before, true) + prior.isVisible = before > 0 + later.text = hidden(after, false) + later.isVisible = after > 0 + } + + private fun hidden(count: Int, earlier: Boolean): String { + if (count <= 0) return "" + val key = when { + earlier && count == 1 -> "session.part.todo.hidden.earlier.one" + earlier -> "session.part.todo.hidden.earlier.many" + count == 1 -> "session.part.todo.hidden.later.one" + else -> "session.part.todo.hidden.later.many" + } + return KiloBundle.message(key, count) + } + + private class Row(todo: TodoDto, style: SessionEditorStyle) { + val check = JBCheckBox().apply { + isFocusable = false + isEnabled = false + isOpaque = false + } + val text = JBLabel() + val panel = JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0) + add(check, BorderLayout.WEST) + add(text, BorderLayout.CENTER) + } + + init { + update(todo, style) + } + + fun update(todo: TodoDto, style: SessionEditorStyle) { + val done = todo.status == "completed" + check.isSelected = done + text.text = label(todo.content, done) + text.font = if (todo.changed) style.boldFont else style.regularFont + text.foreground = when { + !done -> style.editorForeground + todo.changed -> style.editorForeground + else -> UiStyle.Colors.weak() + } + } + + private fun label(value: String, done: Boolean): String { + val text = XmlStringUtil.escapeString(value) + if (!done) return "$text" + return "$text" + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt new file mode 100644 index 00000000000..78ff1af8fae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -0,0 +1,130 @@ +package ai.kilocode.client.session.views.todo + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.PrimarySessionPartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Font +import javax.swing.Box +import javax.swing.JComponent +import javax.swing.JPanel + +class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : + PrimarySessionPartView(parts.header, parts.list, expanded = true) { + + override val contentId = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) + parts.list.border = JBUI.Borders.compound( + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), + JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), + ) + applyStyle(style) + sync() + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + sync() + } + + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + parts.list.applyStyle(style) + if (changed) refresh() + } + + fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ") + internal fun rowCount() = parts.list.rowCount() + internal fun rowText(index: Int) = parts.list.rowText(index) + internal fun rowChecked(index: Int) = parts.list.rowChecked(index) + internal fun rowCheckboxOpaque(index: Int) = parts.list.rowCheckboxOpaque(index) + internal fun rowForeground(index: Int) = parts.list.rowForeground(index) + internal fun hiddenText() = parts.list.hiddenText() + internal fun titleFont() = parts.title.font + internal fun subtitleFont() = parts.sub.font + + override fun dumpLabel() = "TodoWriteView#$contentId(${labelText()})" + + private fun sync() { + parts.sub.text = subtitle(item) + val view = item.todoView + val compact = view?.mode == "compact" + val rows = if (compact) view.todos else item.todos + parts.list.update( + rows, + hiddenBefore = if (compact) view.hiddenBefore else 0, + hiddenAfter = if (compact) view.hiddenAfter else 0, + ) + syncExpandable(true) + refresh() + } + + companion object { + fun canRender(tool: Tool) = tool.name == "todowrite" && tool.state == ToolExecState.COMPLETED + } +} + +class TodoParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val center: JPanel, + val controls: JComponent, + val list: TodoListPanel, +) + +private fun todoParts(): TodoParts { + val glyph = JBLabel(SessionViewIcons.checklist) + val title = JBLabel(KiloBundle.message("session.part.todo.title")) + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + isOpaque = false + add(title, BorderLayout.WEST) + add(sub, BorderLayout.CENTER) + } + val controls = Box.createHorizontalBox() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel()) +} + +private fun subtitle(tool: Tool): String { + val total = tool.todos.size + if (total == 0) return "" + val done = tool.todos.count { it.status == "completed" } + return "$done/$total" +} + +private fun setFont(component: JComponent, font: Font): Boolean { + if (component.font == font) return false + component.font = font + return true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt new file mode 100644 index 00000000000..24d36cff643 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -0,0 +1,188 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.selection.SessionSelection +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 ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.Icon + +abstract class BaseSearchToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts, + private val repo: String? = null, +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + protected var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + private var disposed = false + + protected abstract fun toolIcon(tool: Tool): Icon + protected abstract fun toolTitle(tool: Tool): String + protected abstract fun targets(tool: Tool, repo: String?): List + protected abstract fun viewName(): String + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.targets.forEach { bindHeader(it) } + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } + @RequiresEdt + internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun hasToggle() = arrow.isVisible + @RequiresEdt + internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt + internal fun titleFont() = parts.title.font + @RequiresEdt + internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.regularFont + @RequiresEdt + internal fun stateFont() = parts.state.font + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun scrollComponent() = parts.scroll + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy + @RequiresEdt + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy + @RequiresEdt + internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt + internal fun headerComponent() = parts.header + @RequiresEdt + internal fun centerComponent() = parts.center + @RequiresEdt + internal fun targetComponents() = parts.targets + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + parts.targets.forEach { changed = setFont(it, style.regularFont) || changed } + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, item.state != ToolExecState.COMPLETED) || changed + changed = setIcon(parts.glyph, toolIcon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, toolTitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = syncTargets() || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun syncTargets(): Boolean { + val values = targets(item, repo) + var changed = false + parts.targets.forEachIndexed { index, label -> + val text = values.getOrNull(index) ?: "" + changed = setVisible(label, text.isNotBlank()) || changed + changed = setTargetText(label, text) || changed + changed = setForeground(label, UiStyle.Colors.fg()) || changed + } + return changed + } + + private fun syncBody(): Boolean { + val body = parts.content ?: return false + val value = plainBody(item) + if (body.text != value) { + body.text = value + return true + } + return false + } + + private fun applyBodyStyle(): Boolean { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { + registered = true + body.register(selection, this) + } + return body.applyStyle(style) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val body = parts.content ?: return 0 + return body.lineHeight() * SessionUiStyle.View.Tool.BODY_LINES + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt new file mode 100644 index 00000000000..ccb204da04f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection + +/** Renders glob calls with a stacked, collapsible search-result header. */ +class GlobToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(2), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "glob" + } + + override fun toolIcon(tool: Tool) = icon(tool) + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") + override fun targets(tool: Tool, repo: String?) = listOf(globDirectory(tool, repo), globPattern(tool)).filter { it.isNotBlank() } + override fun viewName() = "GlobToolView" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt new file mode 100644 index 00000000000..d2c2a623ead --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -0,0 +1,170 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.session.ui.selection.SessionSelection +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 ai.kilocode.client.ui.UiStyle +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders read calls with secondary, borderless chrome. */ +class ReadToolView( + tool: Tool, + openFile: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, openFile), +) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ + } + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + parts.text?.let { selection?.register(it, this) } + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.text?.text = preview(item) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun hasToggle() = arrow.isVisible + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyWrap() = parts.text?.lineWrap ?: false + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + @RequiresEdt + internal fun linkVisible() = parts.link.isVisible + @RequiresEdt + internal fun linkText() = parts.label + @RequiresEdt + internal fun linkMarkup() = parts.link.text ?: "" + @RequiresEdt + internal fun linkForeground() = parts.link.foreground + @RequiresEdt + internal fun linkFont() = parts.link.font + @RequiresEdt + internal fun subtitleForeground() = parts.sub.foreground + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun linkHref() = parts.href + @RequiresEdt + internal fun openLink() = parts.openLink() + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.transcriptFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + var changed = false + changed = syncExpandable(false) || changed + changed = setVisible(parts.state, true) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = syncSubtitle() || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + return changed + } + + private fun syncSubtitle(): Boolean { + val target = target(item)?.takeIf { it.type == "file" } + if (target != null) { + var changed = false + if (parts.href != target.path) { + parts.href = target.path + changed = true + } + changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed + changed = show(parts, true) || changed + return changed + } + + var changed = false + if (parts.href != null) { + parts.href = null + changed = true + } + changed = setText(parts.sub, subtitle(item)) || changed + changed = show(parts, false) || changed + return changed + } + + private fun syncBody(): Boolean { + val value = preview(item) + val text = parts.text ?: return false + if (text.text == value) return false + text.text = value + text.caretPosition = 0 + return true + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt new file mode 100644 index 00000000000..83084495e6b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt @@ -0,0 +1,24 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.views.SessionViewIcons + +/** Renders grep/content-search calls with stacked, clipped search targets. */ +class SearchToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(3), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "grep" + } + + override fun toolIcon(tool: Tool) = SessionViewIcons.search + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") + override fun targets(tool: Tool, repo: String?) = searchTargets(tool, repo) + override fun viewName() = "SearchToolView" +} 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 new file mode 100644 index 00000000000..2593c941a08 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -0,0 +1,312 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +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 ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdViewFactory +import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +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.Dimension +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +class ShellToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool), + private val holder: ShellHolder = ShellHolder(tool, selection), +) : SecondarySessionPartView(parts.header, { holder.body().panel }), UiDataProvider { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + holder.parent = this + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { holder.shell?.markdown() ?: fallbackText() } + } + + private fun fallbackText() = ShellContent(item).body + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + holder.shell?.applyStyle(style) + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + (holder.shell?.panel?.preferredSize?.height ?: 0) + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val was = item.name + item = content + var changed = false + if (was != content.name || !canExpand(content)) changed = collapse() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun commandText(): String = command(item) + + @RequiresEdt + fun outputText(): String = clean(output(item)) + + @RequiresEdt + fun errorText(): String = clean(item.error.orEmpty()) + + @RequiresEdt + fun bodyText(): String = ShellContent(item).body + + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + + @RequiresEdt + internal fun bodyCreated() = holder.shell != null + + @RequiresEdt + internal fun bodyVisible() = holder.shell?.panel?.parent === this + + @RequiresEdt + internal fun markdown() = holder.shell?.markdown() ?: ShellContent(item).markdown + + @RequiresEdt + internal fun codeEditors(): List = holder.shell?.codeEditors() ?: emptyList() + + @RequiresEdt + internal fun commandFont() = codeEditors().firstOrNull()?.font ?: style.editorFont + + @RequiresEdt + internal fun titleFont() = parts.title.font + + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + + @RequiresEdt + internal fun subtitleForeground() = parts.sub.foreground + + @RequiresEdt + internal fun stateFont() = parts.state.font + + @RequiresEdt + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + + @RequiresEdt + internal fun mdComponent() = holder.shell?.mdComponent() + + @RequiresEdt + internal fun horizontalPolicy() = holder.shell?.scrolls()?.firstOrNull()?.horizontalScrollBarPolicy + ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + holder.shell?.let { changed = it.applyStyle(style) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + return changed + } + + private fun syncBody(): Boolean { + val body = holder.shell ?: return false + return body.update(item) + } + + override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" + + companion object { + fun canRender(tool: Tool) = tool.name == "bash" + } +} + +class ShellHolder( + private val tool: Tool, + private val selection: SessionSelection?, +) { + var parent: Disposable? = null + var shell: ShellBody? = null + + @RequiresEdt + fun body(): ShellBody { + val current = shell + if (current != null) return current + val owner = parent ?: error("Shell holder has no parent") + return ShellBody(tool, selection, owner).also { + shell = it + Disposer.register(owner, it) + } + } +} + +class ShellBody( + tool: Tool, + selection: SessionSelection?, + parent: Disposable, +) : Disposable { + private val md = MdViewFactory.create( + SessionEditorStyle.current(), + selection, + MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Bottom, + maxLines = 15, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + val panel = md.component + + init { + Disposer.register(parent, md) + applyStyle(SessionEditorStyle.current()) + update(tool) + } + + @RequiresEdt + fun update(tool: Tool): Boolean { + val content = ShellContent(tool) + if (md.markdown() == content.markdown) return false + md.set(content.markdown) + styleShell() + return true + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = md.font + md.applyStyle(style) + md.font = style.transcriptFont + md.foreground = style.editorForeground + md.background = style.editorBackground + md.preBg = style.editorBackground + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + styleShell() + return before != md.font + } + + @RequiresEdt + 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)) + } + } + + @RequiresEdt + fun markdown() = md.markdown() + + @RequiresEdt + fun mdComponent() = md.component + + @RequiresEdt + fun scrolls(): List = (md.component as? JPanel)?.components?.filterIsInstance() ?: emptyList() + + @RequiresEdt + fun codeEditors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + override fun dispose() = Unit +} + +private data class ShellContent( + val command: String, + val output: String, + val error: String, + val rawOutput: String = output, + val rawError: String = error, +) { + constructor(tool: Tool) : this( + command(tool), + clean(output(tool)), + clean(tool.error.orEmpty()), + output(tool), + tool.error.orEmpty(), + ) + + val body: String = listOf(command, output, error).filter { it.isNotBlank() }.joinToString("\n\n") + + val markdown: String = buildString { + section(KiloBundle.message("session.part.tool.shell.command"), command, "shell-command") + section(KiloBundle.message("session.part.tool.shell.output"), rawOutput, outputLang(rawOutput)) + section(KiloBundle.message("session.part.tool.shell.error"), rawError, "ansi-stderr") + } +} + +private fun outputLang(text: String): String = if (MdTerminal.hasAnsi(text)) "ansi-stdout" else "shell-output" + +private fun StringBuilder.section(title: String, text: String, lang: String) { + if (text.isBlank()) return + if (isNotEmpty()) append("\n\n") + val fence = fence(text) + append("**").append(title).append("**\n\n") + append(fence).append(lang).append("\n") + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) +} + +private fun fence(text: String): String { + val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0 + return "`".repeat(maxOf(3, size + 1)) +} + +private fun clean(text: String): String = MdTerminal.strip(MdTerminal.reduce(text, keepSgr = false)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt new file mode 100644 index 00000000000..5b0654b56e1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -0,0 +1,701 @@ +@file:Suppress("TooManyFunctions") + +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.selection.SessionCopyTarget +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.cli.KiloCliParser +import ai.kilocode.log.KiloLog +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.util.io.OSAgnosticPathUtil +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension +import com.intellij.util.ui.JBUI +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color +import java.awt.Cursor +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +private val LOG = KiloLog.create(ToolParts::class.java) + +enum class ToolBodyMode { EDITOR, TEXT } + +class ToolParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val link: JBLabel, + val slot: JPanel, + val state: JBLabel, + val center: JPanel, + val controls: JComponent, + private val open: ((String) -> Unit)? = null, + val extra: JBLabel? = null, + val targets: List = emptyList(), + private val mode: ToolBodyMode = ToolBodyMode.EDITOR, +) { + var href: String? = null + var label: String = "" + private var body: ToolBody? = null + + val text: JBTextArea? + @RequiresEdt + get() = body?.area + + val content: ToolBody? + @RequiresEdt + get() = body + + val scroll: JBScrollPane? + @RequiresEdt + get() = body?.scroll + + @RequiresEdt + fun scroll(tool: Tool): JBScrollPane = body(tool).scroll + + @RequiresEdt + fun bodyCreated() = body != null + + @RequiresEdt + fun openLink() { + val value = href ?: return + open?.invoke(value) + } + + @RequiresEdt + private fun body(tool: Tool): ToolBody { + val item = body + if (item != null) return item + val body = when (mode) { + ToolBodyMode.EDITOR -> ToolBody.editor(tool) + ToolBodyMode.TEXT -> ToolBody.text(tool) + } + return body.also { this.body = it } + } +} + +class ToolBody private constructor( + val area: JBTextArea?, + val ed: EditorTextField?, + val scroll: JBScrollPane, + private val disposable: Disposable?, +) : Disposable { + var text: String + @RequiresEdt + get() = area?.text ?: ed?.text ?: "" + @RequiresEdt + set(value) { + if (text == value) return + area?.text = value + ed?.text = value + caretStart() + size() + } + + var font: Font + @RequiresEdt + get() = area?.font ?: ed?.font ?: SessionEditorStyle.current().editorFont + @RequiresEdt + set(value) { + area?.font = value + ed?.font = value + size() + } + + var foreground: Color + @RequiresEdt + get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg() + @RequiresEdt + set(value) { + area?.foreground = value + ed?.foreground = value + } + + val editable: Boolean get() = area?.isEditable ?: false + val caretVisible: Boolean get() = area?.caret?.isVisible ?: false + val lineWrap: Boolean get() = area?.lineWrap ?: false + val editor: EditorTextField? get() = ed + + @RequiresEdt + fun caretStart() { + area?.caretPosition = 0 + ed?.getEditor(false)?.caretModel?.moveToOffset(0) + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = font + area?.font = style.transcriptFont + ed?.font = style.editorFont + ed?.getEditor(false)?.let(style::applyToEditor) + size() + return before != font + } + + @RequiresEdt + fun register(selection: SessionSelection, parent: Disposable) { + val field = ed + if (field != null) { + (field as? ToolField)?.selection = selection + selection.register(field, parent) + return + } + area?.let { + (it as? ToolArea)?.selection = selection + selection.register(it, parent) + } + } + + @RequiresEdt + fun lineHeight(): Int = ed?.getEditor(false)?.lineHeight ?: scroll.viewport.view.getFontMetrics(font).height + + override fun dispose() { + disposable?.let(Disposer::dispose) + } + + private fun size() { + val view = scroll.viewport.view as? JComponent ?: return + val height = height(view) + val width = width(view) + view.preferredSize = JBUI.size(width, height) + view.minimumSize = JBUI.size(0, height) + view.maximumSize = JBDimension(Int.MAX_VALUE, height) + val inset = scroll.viewportBorder?.getBorderInsets(scroll) ?: JBUI.emptyInsets() + val pane = height + scroll.insets.top + scroll.insets.bottom + inset.top + inset.bottom + + scroll.horizontalScrollBar.preferredSize.height + scroll.preferredSize = JBUI.size(0, pane) + scroll.minimumSize = JBUI.size(0, pane) + scroll.maximumSize = JBDimension(Int.MAX_VALUE, pane) + } + + private fun width(view: JComponent): Int { + val metrics = view.getFontMetrics(font) + return (text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0) + + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun height(view: JComponent): Int { + ed?.ensureWillComputePreferredSize() + val rows = text.lineSequence().count().coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + return maxOf(view.preferredSize.height, lineHeight() * rows) + } + + companion object { + @RequiresEdt + fun editor(tool: Tool): ToolBody { + val disposable = Disposer.newDisposable("Tool body") + val body = runCatching { + val field = ToolField(preview(tool), SessionEditorStyle.current()).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + } + ToolBody(null, field, pane(field, true), disposable) + }.getOrElse { err -> + LOG.warn("kind=tool codeEditor=true failed message=${err.message}", err) + val area = area(tool, false) + ToolBody(area, null, pane(area, true), disposable) + } + body.size() + return body + } + + @RequiresEdt + fun text(tool: Tool): ToolBody { + val area = area(tool, true) + val body = ToolBody(area, null, pane(area, false), null) + body.size() + return body + } + + private fun area(tool: Tool, wrap: Boolean) = ToolArea().apply { + isEditable = false + caret.isVisible = false + caret.isSelectionVisible = true + lineWrap = wrap + wrapStyleWord = wrap + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + } + + private fun pane(view: JComponent, scrolls: Boolean) = JBScrollPane(view).apply { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ) + viewportBorder = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ).takeIf { scrolls } + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() + horizontalScrollBarPolicy = if (scrolls) { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } +} + +private class ToolArea : JBTextArea(), UiDataProvider, SessionCopyTarget { + var selection: SessionSelection? = null + override val copyAnchor: JComponent get() = this + + override fun copyText() = text + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { copyText() } + } +} + +private class ToolField(value: String, private var style: SessionEditorStyle) : EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + PlainTextFileType.INSTANCE, + true, + false, +), SessionCopyTarget { + var selection: SessionSelection? = null + override val copyAnchor: JComponent get() = this + + override fun copyText() = text + + init { + setFontInheritedFromLAF(false) + font = style.editorFont + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.background = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.viewport.background = SessionUiStyle.View.Surface.bgColor() + ed.settings.isUseSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { copyText() } + } +} + +private const val SUB_CARD = "sub" +private const val LINK_CARD = "link" + +@RequiresEdt +internal fun toolParts( + tool: Tool, + openFile: ((String) -> Unit)? = null, + mode: ToolBodyMode = ToolBodyMode.TEXT, +): ToolParts { + lateinit var parts: ToolParts + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val link = JBLabel().apply { + isVisible = false + isFocusable = false + foreground = UiStyle.Colors.fg() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + setRequestFocusEnabled(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + parts.openLink() + } + }) + } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + center.add(title, BorderLayout.WEST) + center.add(slot, BorderLayout.CENTER) + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile, mode = mode) + return parts.also { + controls.add(it.state) + } +} + +@RequiresEdt +internal fun searchParts(count: Int): ToolParts { + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val targets = List(count) { + JBLabel().apply { + foreground = UiStyle.Colors.fg() + minimumSize = JBUI.size(0, minimumSize.height) + } + } + val link = JBLabel().apply { isVisible = false } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } + val target = stack.align(HAlign.TRACK, VAlign.CENTER) + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + isOpaque = false + minimumSize = JBUI.size(0, minimumSize.height) + add(title, BorderLayout.WEST) + add(target, BorderLayout.CENTER) + } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also { + controls.add(it.state) + } +} + +internal fun icon(tool: Tool) = when (tool.name) { + "read" -> SessionViewIcons.glasses + "list" -> SessionViewIcons.bulletList + "glob", "grep" -> SessionViewIcons.search + "webfetch", "websearch" -> SessionViewIcons.windowCursor + "codesearch" -> SessionViewIcons.code + "task" -> SessionViewIcons.task + "bash" -> SessionViewIcons.console + "edit", "write", "apply_patch" -> SessionViewIcons.codeLines + "todowrite", "todoread" -> SessionViewIcons.checklist + "question" -> SessionViewIcons.bubble + "skill" -> SessionViewIcons.brain + else -> SessionViewIcons.mcp +} + +internal fun title(tool: Tool) = when (tool.name) { + "read" -> KiloBundle.message("session.part.tool.read") + "bash" -> KiloBundle.message("session.part.tool.shell") + else -> toolTitle(tool) +} + +internal fun subtitle(tool: Tool) = when (tool.name) { + "read" -> readPath(tool) + "bash" -> shellTitle(tool) + else -> toolSubtitle(tool) +} + +@RequiresEdt +internal fun setText(label: JBLabel, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) + if (label.text == value) return false + label.text = value + return true +} + +@RequiresEdt +internal fun setTargetText(label: JBLabel, text: String): Boolean { + if (label.text == text) return false + label.text = text + return true +} + +@RequiresEdt +internal fun setLinkText(parts: ToolParts, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") + if (parts.label == text && parts.link.text == value) return false + parts.label = text + parts.link.text = value + return true +} + +@RequiresEdt +internal fun show(parts: ToolParts, link: Boolean): Boolean { + if (parts.link.isVisible == link && parts.sub.isVisible != link) return false + (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) + return true +} + +internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text + +@RequiresEdt +internal fun setIcon(label: JBLabel, icon: Icon): Boolean { + if (label.icon === icon) return false + label.icon = icon + return true +} + +@RequiresEdt +internal fun setVisible(component: JComponent, visible: Boolean): Boolean { + if (component.isVisible == visible) return false + component.isVisible = visible + return true +} + +@RequiresEdt +internal fun setForeground(component: JComponent, color: Color): Boolean { + if (same(component.foreground, color)) return false + component.foreground = color + return true +} + +@RequiresEdt +internal fun setFont(component: JComponent, font: Font): Boolean { + if (component.font == font) return false + component.font = font + return true +} + +private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb + +internal fun color(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() + ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() + ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() + ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() +} + +internal fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { + UiStyle.Colors.errorLabelForeground() +} else { + UiStyle.Colors.fg() +} + +internal fun stateText(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") + ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") + ToolExecState.COMPLETED -> "" + ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") +} + +private fun readPath(tool: Tool): String { + val target = target(tool) + if (target != null) { + if (target.type == "file") return tail(target.path).ifBlank { target.path } + return target.path + } + val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name + return tail(path).ifBlank { path } +} + +internal fun searchPath(path: String, repo: String?): String { + val text = path.takeIf { it.isNotBlank() } ?: return "" + val root = repo?.takeIf { it.isNotBlank() }?.let(::norm) + if (root == null) return text.takeUnless { it == "." } ?: "" + val full = if (OSAgnosticPathUtil.isAbsolute(text)) norm(text) else norm(FileUtil.join(root, text)) + if (full == root) return "" + if (!OSAgnosticPathUtil.startsWith(full, root)) return full + return FileUtil.getRelativePath(root, full, '/') ?: full +} + +private fun norm(path: String): String = FileUtil.toCanonicalPath(FileUtil.toSystemIndependentName(path), '/', true) + +internal fun globDirectory(tool: Tool, repo: String?): String = + searchPath( + tool.input["path"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: "", + repo, + ) + +internal fun globPattern(tool: Tool): String = + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" + +internal fun searchTargets(tool: Tool, repo: String?): List = listOfNotNull( + tool.input["path"]?.takeIf { it.isNotBlank() }?.let { searchPath(it, repo) }?.takeIf { it.isNotBlank() }, + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, + tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, +) + +internal data class Target( + val path: String, + val type: String, +) + +internal fun target(tool: Tool): Target? { + val out = output(tool) + if (out.isBlank()) return null + val path = KiloCliParser.tag(out, "path") ?: return null + val type = KiloCliParser.tag(out, "type") ?: return null + return Target(path, type.lowercase()) +} + +private fun shellTitle(tool: Tool): String = + tool.input["description"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } + ?: "" + +internal fun command(tool: Tool): String = + tool.input["command"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun output(tool: Tool): String = + tool.output?.takeIf { it.isNotBlank() } + ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) + +internal fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) + +private fun shellPreview(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + sep() + append(out) + } + if (err != null) { + sep() + append(err) + } + }.build() +} + +private fun shellBody(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return buildString { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + if (isNotEmpty()) append("\n\n") + append(out) + } + if (err != null) { + if (isNotEmpty()) append("\n\n") + append(err) + } + } +} + +private fun plainPreview(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (out.isNotBlank()) append(out) + if (err != null) { + sep() + append(err) + } + }.build() +} + +internal fun plainBody(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") +} + +internal fun canExpand(tool: Tool): Boolean { + if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() + return output(tool).isNotBlank() || !tool.error.isNullOrBlank() +} + +private fun toolTitle(tool: Tool): String = + tool.title?.takeIf { it.isNotBlank() } + ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } + +private fun toolSubtitle(tool: Tool): String { + val base = listOf("description", "query", "url", "filePath", "path", "name") + .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } + .firstOrNull() + val args = listOf("pattern", "include", "offset", "limit") + .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } + return listOfNotNull(base).plus(args).joinToString(" ") +} + +internal fun tail(path: String): String { + val value = path.trimEnd('/', '\\') + val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) + if (index < 0) return value + return value.substring(index + 1) +} + +private class Preview { + private val text = StringBuilder() + private var cut = false + + fun append(value: String): Preview { + if (cut) return this + val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length + if (value.length <= rem) { + text.append(value) + return this + } + if (rem > 0) text.append(value, 0, rem) + cut = true + return this + } + + fun sep(): Preview { + if (text.isNotEmpty()) append("\n\n") + return this + } + + fun build(): String { + if (!cut) return text.toString() + if (text.isNotEmpty()) text.append("\n\n") + text.append(KiloBundle.message("session.part.tool.truncated")) + return text.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt new file mode 100644 index 00000000000..1f04865526f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -0,0 +1,193 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.selection.SessionSelection +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 ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders non-read tool calls with VS Code-inspired rows/cards. */ +class ToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }), UiDataProvider { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + private var disposed = false + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { parts.content?.text ?: fallbackText() } + } + + private fun fallbackText() = listOf(commandText(), outputText()).filter { it.isNotBlank() }.joinToString("\n\n") + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val was = item.name + item = content + var changed = false + if (was != content.name || !canExpand(content)) changed = collapse() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun commandText(): String = command(item) + @RequiresEdt + fun outputText(): String = output(item) + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun previewText(): String = parts.content?.text ?: preview(item) + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt + internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt + internal fun titleFont() = parts.title.font + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun stateFont() = parts.state.font + @RequiresEdt + internal fun bodyEditable() = parts.content?.editable ?: false + @RequiresEdt + internal fun bodyCaretVisible() = parts.content?.caretVisible ?: false + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = syncLabels() || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun syncLabels(): Boolean { + var changed = false + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + return changed + } + + private fun syncBody(): Boolean { + var changed = false + val body = parts.content ?: return false + val value = preview(item) + if (body.text != value) { + body.text = value + changed = true + } + if (body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun applyBodyStyle(): Boolean { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { + registered = true + body.register(selection, this) + } + return body.applyStyle(style) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val body = parts.content ?: return 0 + return body.lineHeight() * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index 586be860e38..9999da22fc1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -1,16 +1,17 @@ package ai.kilocode.client.settings import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.models.ModelsConfigurable +import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.ui.layout.Stack import com.intellij.ide.DataManager import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.options.ex.Settings import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI -import javax.swing.BoxLayout import javax.swing.JComponent -import javax.swing.JPanel /** * Root settings entry under Settings -> Tools -> Kilo Code. @@ -31,13 +32,12 @@ class KiloSettingsConfigurable : SearchableConfigurable { override fun getDisplayName(): String = KiloBundle.message("settings.kilo.displayName") override fun createComponent(): JComponent { - val panel = JPanel() - panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + val panel = Stack.vertical() panel.border = JBUI.Borders.empty(8, 0, 0, 0) val desc = JBLabel(KiloBundle.message("settings.kilo.description")) desc.border = JBUI.Borders.emptyBottom(12) - panel.add(desc) + panel.next(desc) val link = ActionLink(KiloBundle.message("settings.profile.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink @@ -45,7 +45,23 @@ class KiloSettingsConfigurable : SearchableConfigurable { open(settings, UserProfileConfigurable.ID) } link.border = JBUI.Borders.emptyBottom(4) - panel.add(link) + panel.next(link) + + val models = ActionLink(KiloBundle.message("settings.models.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, ModelsConfigurable.ID) + } + models.border = JBUI.Borders.emptyBottom(4) + panel.next(models) + + val providers = ActionLink(KiloBundle.message("settings.providers.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, ProvidersConfigurable.ID) + } + providers.border = JBUI.Borders.emptyBottom(4) + panel.next(providers) return panel } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt new file mode 100644 index 00000000000..9c180606bad --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt @@ -0,0 +1,21 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.project.Project + +internal object KiloSettingsSelection { + // IntelliJ persists the selected settings page with SettingsEditor.SELECTED_CONFIGURABLE. + const val SELECTED_CONFIGURABLE_KEY = "settings.editor.selected.configurable" + + fun target(project: Project): String { + val id = PropertiesComponent.getInstance(project).getValue(SELECTED_CONFIGURABLE_KEY) + if (id != null && isKilo(id)) return id + return UserProfileConfigurable.ID + } + + private fun isKilo(id: String?): Boolean { + if (id == KiloSettingsConfigurable.ID) return true + return id?.startsWith("${KiloSettingsConfigurable.ID}.") == true + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt new file mode 100644 index 00000000000..4c1cd6c1d4b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt @@ -0,0 +1,219 @@ +package ai.kilocode.client.settings.auth + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.AsyncProcessIcon +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.FlowLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.Point +import java.awt.datatransfer.StringSelection +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.SwingConstants + +internal data class DeviceOAuthInfo( + val url: String, + val code: String?, + val expiresIn: Int, + val started: Long, +) + +internal data class DeviceOAuthText( + val title: String, + val qrDescription: String, +) + +internal class DeviceOAuthPanel( + private val copy: DeviceOAuthText, + private val cancel: () -> Unit, + private val browse: (String) -> Unit, + private val prefix: String, + private val timers: UiTimerSource = UiTimers, +) : JPanel(GridBagLayout()) { + val urlField = JBTextField().apply { + isEditable = false + name = "$prefix.url" + columns = 30 + addFocusListener(object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectAll() + }) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) = selectAll() + }) + } + + val qrLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + name = "$prefix.qr" + accessibleContext.accessibleName = KiloBundle.message("profile.login.qr") + accessibleContext.accessibleDescription = copy.qrDescription + } + + private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser")) + private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel")).also { it.addActionListener { cancel() } } + private val copyUrlBtn = HoverIcon().apply { + icon = AllIcons.Actions.Copy + toolTipText = KiloBundle.message("profile.login.copyUrl") + } + private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { + name = "$prefix.codePanel" + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val c = code ?: return + copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@DeviceOAuthPanel) + } + }) + } + private val codeLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + font = UiStyle.Fonts.large() + } + private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + } + private val waitIcon = AsyncProcessIcon("KiloOAuth") + private val waitLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + } + private var step2: SimpleColoredComponent? = null + private var code: String? = null + private var started = 0L + private var expires = 900 + private var last: String? = null + private val timer = timers.timer(1000) { syncTime() } + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + codePanel.add(codeLabel, BorderLayout.CENTER) + codePanel.add(codeHint, BorderLayout.SOUTH) + build() + } + + private fun build() { + var row = 0 + add(JBLabel(copy.title).apply { + font = UiStyle.Fonts.heading() + horizontalAlignment = SwingConstants.CENTER + }, gbc(row++)) + add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")), gbc(row++, UiStyle.Gap.md())) + add(urlRow(), gbc(row++, UiStyle.Gap.sm())) + add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered()) + val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code")) + step2 = s2 + add(s2, gbc(row++, UiStyle.Gap.md())) + add(codePanel, gbc(row++, UiStyle.Gap.sm())) + add(JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(waitIcon) + add(waitLabel) + }, gbc(row++, UiStyle.Gap.xl())) + add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered()) + } + + private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply { + append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) + append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + + private fun urlRow(): JPanel { + val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)) + row.add(urlField, BorderLayout.CENTER) + row.add(JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(copyUrlBtn) + add(openBtn) + }, BorderLayout.EAST) + return row + } + + @RequiresEdt + fun update(info: DeviceOAuthInfo) { + code = info.code + urlField.text = info.url + urlField.toolTipText = info.url + if (info.url != last) { + last = info.url + openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) } + openBtn.addActionListener { browse(info.url) } + copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) } + copyUrlBtn.addActionListener { copyToClipboard(info.url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn) } + qrLabel.icon = try { + QrCode.icon(info.url, JBUI.scale(160)) + } catch (_: Exception) { + null + } + } + codePanel.isVisible = info.code != null + step2?.isVisible = info.code != null + if (info.code != null) codeLabel.text = spaced(info.code) + started = info.started + expires = info.expiresIn + syncTime() + waitIcon.resume() + timer.restart() + } + + @RequiresEdt + fun dispose() { + timer.stop() + waitIcon.suspend() + last = null + } + + @RequiresEdt + private fun syncTime() { + val elapsed = ((timers.now() - started) / 1000).toInt() + val remain = (expires - elapsed).coerceAtLeast(0) + val min = remain / 60 + val sec = remain % 60 + waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}") + } + + private fun gbc(y: Int, top: Int = 0) = GridBagConstraints().apply { + gridx = 0 + gridy = y + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + insets = JBUI.insetsTop(top) + } + + private fun GridBagConstraints.centered(): GridBagConstraints = apply { + fill = GridBagConstraints.NONE + anchor = GridBagConstraints.CENTER + } + + private fun spaced(code: String): String = code.map { it.toString() }.joinToString(" ") +} + +internal fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) { + CopyPasteManager.getInstance().setContents(StringSelection(text)) + if (anchor is javax.swing.JComponent) { + val point = RelativePoint(anchor, Point(anchor.width / 2, 0)) + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder(msg, null, null, null) + .createBalloon() + .show(point, Balloon.Position.above) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/QrCode.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/QrCode.kt new file mode 100644 index 00000000000..d5bfee40e00 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/QrCode.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.settings.auth + +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import java.awt.Color +import java.awt.image.BufferedImage +import javax.swing.ImageIcon + +internal object QrCode { + fun image(text: String, size: Int = 160): BufferedImage { + require(text.isNotBlank()) { "QR text must not be blank" } + val hints = mapOf(EncodeHintType.MARGIN to 2) + val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints) + val img = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB) + for (y in 0 until size) { + for (x in 0 until size) { + img.setRGB(x, y, if (matrix[x, y]) Color.BLACK.rgb else Color.WHITE.rgb) + } + } + return img + } + + fun icon(text: String, size: Int = 160): ImageIcon = ImageIcon(image(text, size)) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseContentPanel.kt new file mode 100644 index 00000000000..3dca098631f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseContentPanel.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil + +internal open class BaseContentPanel : Stack(StackAxis.VERTICAL) { + fun section(title: String, description: String? = null): SettingsRows { + next(TitledSeparator(title)) + if (description != null) { + next(JBLabel(description).apply { + foreground = UIUtil.getContextHelpForeground() + font = UiStyle.Fonts.small() + }) + } + val rows = SettingsRows() + next(rows) + return rows + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt new file mode 100644 index 00000000000..727cf0b0e85 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -0,0 +1,321 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelStateDto +import com.intellij.ide.DataManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.options.ex.Settings +import com.intellij.openapi.project.ProjectManager +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.function.Predicate +import javax.swing.JComponent + +internal abstract class BaseSettingsUi( + protected val scope: CoroutineScope, + initial: D, + private val app: KiloAppService = service(), + private val workspaces: KiloWorkspaceService = service(), + private val hint: String? = null, + private val loginBanner: Boolean = true, +) : SettingsPanel() { + protected lateinit var form: C + private set + protected val jobs = mutableListOf() + protected var draft = initial + protected val saving get() = save + protected val saveError get() = error + protected var appState: KiloAppStateDto = app.state.value + private set + protected var modelState: ModelStateDto = app.models.value + private set + protected var projectDirectory: String? = null + private set + protected val hasProjectDirectory get() = projectDirectory != null || hint != null + protected var workspaceLoading = false + private set + protected var workspaceLoaded = false + private set + + private var baseline = initial + private var pending: D? = null + private var save = false + private var error: String? = null + private var disposed = false + + @RequiresEdt + protected fun startSettings(content: C) { + form = content + setContent(content) + syncContent() + start() + } + + private fun start() { + jobs += scope.launch { + app.state.collect { state -> withContext(edt) { updateApp(state) } } + } + jobs += scope.launch { + app.models.collect { state -> withContext(edt) { updateModels(state) } } + } + jobs += scope.launch { app.connect() } + val path = hint ?: return + jobs += scope.launch { + val dir = workspaces.resolveProjectDirectory(path) + withContext(edt) { + projectDirectory = dir + workspaceLoaded = false + syncContent() + load() + } + } + } + + @RequiresEdt + private fun updateApp(state: KiloAppStateDto) { + appState = state + if (state.status != KiloAppStatusDto.READY) { + workspaceLoading = false + unavailable(state) + syncContent() + return + } + acceptBase(draft(state)) + syncContent() + load() + } + + @RequiresEdt + private fun updateModels(state: ModelStateDto) { + modelState = state + models(state) + syncContent() + } + + @RequiresEdt + private fun load() { + val root = projectDirectory ?: return + if (appState.status != KiloAppStatusDto.READY || workspaceLoading || workspaceLoaded) return + workspaceLoading = true + clearWorkspaceError() + syncContent() + jobs += scope.launch { + val state = loadWorkspace(root) + withContext(edt) { + applyWorkspace(state) + workspaceLoaded = true + workspaceLoading = false + acceptBase(draft(appState)) + syncContent() + } + } + } + + @RequiresEdt + fun modified(): Boolean { + checkEdt() + return draft != (pending ?: baseline) + } + + @RequiresEdt + fun resetDraft() { + checkEdt() + draft = pending ?: baseline + error = null + if (!save) clearProgress() + syncContent() + } + + @RequiresEdt + fun applyDraft() { + checkEdt() + val prev = baseline + val next = draft + val change = change(prev, next) ?: return + logSaveStarted(change) + pending = next + save = true + error = null + showProgress(pendingText()) + syncContent() + save(change) { result -> + ApplicationManager.getApplication().invokeLater({ + if (disposed) { + if (result == null) { + logSaveFailedAfterDispose(change) + onSaveFailedAfterDispose(change) + } else { + logSaveCompletedAfterDispose(change) + } + return@invokeLater + } + if (result != null) { + logSaveCompleted(change) + val edit = draft + val base = base(result) + baseline = if (saved(base, next)) base else next + draft = if (edit == next) baseline else edit + pending = null + save = false + error = null + clearProgress() + syncContent() + return@invokeLater + } + val edit = draft + baseline = prev + draft = if (edit == next) next else edit + pending = null + save = false + error = failedText() + logSaveFailed(change) + syncContent() + }, ModalityState.any()) + } + } + + @RequiresEdt + fun dispose() { + checkEdt() + disposed = true + jobs.forEach { it.cancel() } + jobs.clear() + scope.cancel() + } + + @RequiresEdt + protected fun updateDraft(fn: D.() -> D) { + checkEdt() + draft = draft.fn() + error = null + syncContent() + } + + @RequiresEdt + protected fun acceptBase(base: D) { + checkEdt() + val target = pending + if (target == null) { + val prev = baseline + val edit = draft + baseline = base + if (edit == prev) draft = base + return + } + if (!saved(base, target)) return + baseline = base + } + + @RequiresEdt + protected fun syncLoginBanner(login: Boolean, fallback: () -> Unit) { + checkEdt() + if (loginBanner && login) { + top.showNotLoggedIn { openProfile(it) } + return + } + fallback() + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Settings UI updates must run on EDT" } + } + + @RequiresEdt + protected abstract fun change(from: D, to: D): P? + + @RequiresEdt + protected abstract fun save(change: P, done: (R?) -> Unit) + + @RequiresEdt + protected abstract fun base(result: R): D + + @RequiresEdt + protected abstract fun syncContent() + + @RequiresEdt + protected abstract fun pendingText(): String + + @RequiresEdt + protected abstract fun failedText(): String + + @RequiresEdt + protected abstract fun draft(state: KiloAppStateDto): D + + @RequiresBackgroundThread + protected abstract suspend fun loadWorkspace(root: String): W + + @RequiresEdt + protected abstract fun applyWorkspace(result: W) + + @RequiresEdt + protected open fun saved(base: D, draft: D): Boolean = base == draft + + @RequiresEdt + protected open fun onSaveFailedAfterDispose(change: P) = KiloNotifications.error(failedText()) + + @RequiresEdt + protected open fun logSaveStarted(change: P) = Unit + + @RequiresEdt + protected open fun logSaveCompleted(change: P) = Unit + + @RequiresEdt + protected open fun logSaveFailed(change: P) = Unit + + @RequiresEdt + protected open fun logSaveFailedAfterDispose(change: P) = Unit + + @RequiresEdt + protected open fun logSaveCompletedAfterDispose(change: P) = Unit + + @RequiresEdt + protected open fun unavailable(state: KiloAppStateDto) = Unit + + @RequiresEdt + protected open fun models(state: ModelStateDto) = Unit + + @RequiresEdt + protected open fun clearWorkspaceError() = Unit + + private fun openProfile(src: JComponent) { + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) + if (settings != null) { + val cfg = settings.find(UserProfileConfigurable.ID) + if (cfg != null) { + settings.select(cfg) + return + } + } + + val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID + }, + { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, + ) + } + + private companion object { + val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt new file mode 100644 index 00000000000..2325b36172a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt @@ -0,0 +1,152 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import javax.swing.JComponent + +abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoScroll { + private var shell: SettingsOverlayPanel? = null + private var scope: CoroutineScope? = null + private var ready: JComponent? = null + + @RequiresEdt + override fun createComponent(): JComponent { + checkEdt() + val root = if (scrollReadyShell()) SettingsPanel() else SettingsOverlayPanel() + val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) + shell = root + scope = cs + setContent(root, unavailable()) + cs.launch { service().connect() } + cs.launch { + service().state.collect { state -> + withContext(edt) { update(state) } + } + } + return root + } + + override fun isModified(): Boolean = ready != null && isModifiedReady() + + override fun apply() { + if (ready != null) applyReady() + } + + override fun reset() { + if (ready != null) resetReady() + } + + override fun getPreferredFocusedComponent(): JComponent? = preferredReady() + + override fun focusOn(label: String) { + focusReady(label) + } + + override fun disposeUIResources() { + val panel = ready + val cs = scope + if (panel is SettingsOverlayPanel) panel.setOverlayHost(null) + shell = null + scope = null + ready = null + val cancel = cancelScopeBeforeReadyDispose() + if (panel != null && cancel) cs?.cancel() + val app = ApplicationManager.getApplication() + if (panel != null && app.isDispatchThread) { + disposeReadyComponent(panel) + if (!cancel) cs?.cancel() + return + } + if (panel != null) { + app.invokeLater({ + disposeReadyComponent(panel) + if (!cancel) cs?.cancel() + }, ModalityState.any()) + return + } + cs?.cancel() + } + + @RequiresEdt + private fun update(state: KiloAppStateDto) { + checkEdt() + if (state.status != KiloAppStatusDto.READY || ready != null) return + val cs = scope ?: return + val panel = createReadyComponent(cs) + ready = panel + val root = shell + if (panel is SettingsOverlayPanel) panel.setOverlayHost(root) + if (root != null) setContent(root, panel) + onReadyComponentCreated(panel) + } + + private fun setContent(root: SettingsOverlayPanel, component: JComponent) { + if (root is SettingsPanel) { + root.setContent(component) + return + } + root.content.removeAll() + root.content.add(component, BorderLayout.CENTER) + root.revalidate() + root.repaint() + } + + private fun unavailable(): JComponent { + val title = JBLabel(KiloBundle.message("settings.cli.unavailable.title")) + title.font = JBFont.h3().asBold() + val message = JBLabel(KiloBundle.message("settings.cli.unavailable.message")) + message.setAllowAutoWrapping(true) + return Stack.vertical(UiStyle.Gap.sm()).apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + next(title) + next(message) + } + } + + protected abstract fun createReadyComponent(cs: CoroutineScope): JComponent + + protected open fun isModifiedReady(): Boolean = false + + protected open fun applyReady() = Unit + + protected open fun resetReady() = Unit + + protected open fun preferredReady(): JComponent? = null + + protected open fun focusReady(label: String) = Unit + + protected open fun onReadyComponentCreated(component: JComponent) = Unit + protected open fun cancelScopeBeforeReadyDispose(): Boolean = false + protected open fun disposeReadyComponent(component: JComponent) = Unit + protected open fun scrollReadyShell(): Boolean = true + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Settings configurable UI must run on EDT" } + } + + private companion object { + val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt new file mode 100644 index 00000000000..5547a085923 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt @@ -0,0 +1,80 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.LayeredOverlayPanel +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Rectangle + +internal open class SettingsOverlayPanel : LayeredOverlayPanel() { + val progress = SettingsProgressOverlay() + private var host: SettingsOverlayPanel? = null + + init { + addOverlay(progress) { pane, child -> + val size = child.preferredSize + Rectangle( + ((pane.width - size.width) / 2).coerceAtLeast(0), + UiStyle.Gap.pad(), + size.width, + size.height, + ) + } + } + + @RequiresEdt + fun setOverlayHost(host: SettingsOverlayPanel?) { + if (host === this) { + this.host = null + return + } + this.host?.clearProgress() + this.host = host + progress.clearProgress() + syncOverlay() + } + + @RequiresEdt + fun showProgress(text: String) { + val panel = target() + panel.progress.showProgress(text) + panel.syncOverlay() + } + + @RequiresEdt + fun showProgress(text: String, cancelText: String, cancel: () -> Unit) { + val panel = target() + panel.progress.showProgress(text, cancelText, cancel) + panel.syncOverlay() + } + + @RequiresEdt + fun updateProgress(text: String) { + val panel = target() + panel.progress.updateProgress(text) + panel.syncOverlay() + } + + @RequiresEdt + fun showError(text: String) { + val panel = target() + panel.progress.showError(text) + panel.syncOverlay() + } + + @RequiresEdt + fun clearProgress() { + val panel = target() + panel.progress.clearProgress() + panel.syncOverlay() + } + + private fun target(): SettingsOverlayPanel = host ?: this + + private fun syncOverlay() { + overlay.revalidate() + overlay.repaint() + content.repaint() + revalidate() + repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt new file mode 100644 index 00000000000..cf87dd58ffb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt @@ -0,0 +1,52 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.ui.components.JBScrollPane +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants +import javax.swing.Scrollable + +internal open class SettingsPanel : SettingsOverlayPanel() { + val top = SettingsTop() + val settings = Stack.vertical() + + init { + val body = SettingsBody() + .next(top) + .gap(UiStyle.Gap.lg()) + .next(settings) + content.add(JBScrollPane(body).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + }, BorderLayout.CENTER) + } + + fun setContent(component: JComponent) { + settings.removeAll() + settings.next(component) + revalidate() + repaint() + } + +} + +private class SettingsBody : Stack(StackAxis.VERTICAL), Scrollable { + override fun getScrollableTracksViewportWidth() = true + override fun getScrollableTracksViewportHeight() = false + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + override fun getScrollableUnitIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = UiStyle.Gap.pad() + override fun getScrollableBlockIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = visibleRect.height +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt new file mode 100644 index 00000000000..8b7842878ae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt @@ -0,0 +1,128 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JButton +import javax.swing.JPanel + +internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0)) { + private enum class Kind { INFO, ERROR } + + private var label: JBLabel? = null + private var cancel: JButton? = null + private var kind: Kind? = null + + init { + val view = JBLabel() + val button = JButton() + label = view + cancel = button + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) + add(view, BorderLayout.CENTER) + add(button, BorderLayout.EAST) + button.isVisible = false + isVisible = false + syncColors() + UiStyle.Components.actionButton(button) + } + + fun showProgress(text: String) { + show(text, Kind.INFO, null, null) + } + + fun showProgress(text: String, cancelText: String, cancel: () -> Unit) { + show(text, Kind.INFO, cancelText, cancel) + } + + fun showError(text: String) { + show(text, Kind.ERROR, null, null) + } + + fun updateProgress(text: String) { + val view = requireNotNull(label) + if (view.text != text) view.text = text + revalidate() + repaint() + } + + private fun show(text: String, next: Kind, cancelText: String?, action: (() -> Unit)?) { + if (kind != next) { + kind = next + syncColors() + } + updateProgress(text) + syncCancel(cancelText, action) + if (!isVisible) isVisible = true + revalidate() + repaint() + } + + private fun syncCancel(text: String?, action: (() -> Unit)?) { + val button = requireNotNull(cancel) + button.actionListeners.toList().forEach { button.removeActionListener(it) } + if (text == null || action == null) { + button.text = "" + button.isVisible = false + return + } + button.text = text + button.addActionListener { action() } + UiStyle.Components.actionButton(button) + button.isVisible = true + } + + fun clearProgress() { + val view = requireNotNull(label) + if (!isVisible && view.text.isNullOrBlank()) return + view.text = "" + syncCancel(null, null) + isVisible = false + revalidate() + repaint() + } + + override fun updateUI() { + super.updateUI() + syncColors() + cancel?.let { UiStyle.Components.actionButton(it) } + } + + private fun syncColors() { + val current = kind ?: Kind.INFO + background = when (current) { + Kind.INFO -> UiStyle.Colors.infoOverlayBackground() + Kind.ERROR -> UiStyle.Colors.errorOverlayBackground() + } + foreground = when (current) { + Kind.INFO -> UiStyle.Colors.infoOverlayForeground() + Kind.ERROR -> UiStyle.Colors.errorOverlayForeground() + } + label?.foreground = foreground + } + + private fun borderColor() = when (kind ?: Kind.INFO) { + Kind.INFO -> UiStyle.Colors.infoOverlayBorder() + Kind.ERROR -> UiStyle.Colors.errorOverlayBorder() + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = UiStyle.Arc.component() + g2.color = background + g2.fillRoundRect(0, 0, width, height, arc, arc) + g2.color = borderColor() + g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) + } finally { + g2.dispose() + } + super.paintComponent(g) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsRow.kt new file mode 100644 index 00000000000..d2763fbab23 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsRow.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +class SettingsRow( + title: String, + description: String? = null, + value: JComponent? = null, +) : JPanel(BorderLayout()) { + + private val titleLabel = JBLabel(title).apply { font = UiStyle.Fonts.bold() } + private val descriptionLabel = JBLabel(descriptionHtml(description)).apply { + font = UiStyle.Fonts.hint() + foreground = UIUtil.getContextHelpForeground() + setAllowAutoWrapping(true) + isVisible = description != null + } + private val labels = Stack.vertical(UiStyle.Gap.sm()) + private val valuePanel = JPanel(BorderLayout()) + private var current: JComponent? = null + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0) + valuePanel.isOpaque = false + labels.next(titleLabel) + labels.next(descriptionLabel) + add(labels, BorderLayout.CENTER) + add(valuePanel, BorderLayout.EAST) + setValue(value) + } + + fun update( + title: String, + description: String? = null, + value: JComponent? = null, + ) { + if (titleLabel.text != title) titleLabel.text = title + val text = descriptionHtml(description) + if (descriptionLabel.text != text) descriptionLabel.text = text + val visible = description != null + if (descriptionLabel.isVisible != visible) descriptionLabel.isVisible = visible + setValue(value) + } + + private fun setValue(value: JComponent?) { + if (current === value) return + valuePanel.removeAll() + current = value + if (value != null) { + valuePanel.add(value.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) + } + valuePanel.revalidate() + valuePanel.repaint() + } +} + +private fun descriptionHtml(description: String?): String { + val text = description ?: return "" + return XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) +} + +class SettingsRows : Stack(StackAxis.VERTICAL) { + private val keyed = linkedMapOf() + + fun row(child: SettingsRow): SettingsRows { + next(child) + return this + } + + fun row(key: String, child: SettingsRow): SettingsRow { + keyed.remove(key)?.let { remove(it) } + keyed[key] = child + next(child) + return child + } + + fun update( + key: String, + title: String, + description: String? = null, + value: JComponent? = null, + ): SettingsRow? { + val row = keyed[key] ?: return null + row.update(title, description, value) + return row + } + + fun remove(key: String): SettingsRow? { + val row = keyed.remove(key) ?: return null + remove(row) + revalidate() + repaint() + return row + } + + fun retain(keys: Set) { + keyed.keys.toList().filter { it !in keys }.forEach { remove(it) } + } + + override fun removeAll() { + keyed.clear() + super.removeAll() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsTop.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsTop.kt new file mode 100644 index 00000000000..a9203cac3a6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsTop.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.InlineBanner +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +internal data class SettingsAction( + val text: String, + val run: (JComponent) -> Unit, +) + +internal enum class SettingsBannerKind { WARNING, ERROR } + +internal class SettingsTop : Stack(StackAxis.VERTICAL, UiStyle.Gap.md()) { + private val slot = JPanel(BorderLayout()) + private var spec: BannerSpec? = null + + init { + slot.isOpaque = false + slot.isVisible = false + next(slot) + isVisible = false + } + + fun showBanner( + text: String, + actions: List, + kind: SettingsBannerKind = SettingsBannerKind.WARNING, + ) { + val next = BannerSpec(text, actions.map { it.text }, kind) + if (spec != next) { + val banner = InlineBanner(text, status(kind)).showCloseButton(false) + actions.forEach { action -> + banner.addAction(action.text, Runnable { action.run(banner) }) + } + slot.removeAll() + slot.add(banner, BorderLayout.CENTER) + spec = next + } + show(slot) + } + + fun showNotLoggedIn(run: (JComponent) -> Unit) { + showBanner( + KiloBundle.message("settings.login.message"), + listOf(SettingsAction(KiloBundle.message("settings.login.action"), run)), + ) + } + + fun hideBanner() { + if (!slot.isVisible) return + slot.isVisible = false + sync(layout = true) + } + + private fun show(component: JComponent) { + if (component.isVisible) { + sync(layout = false) + return + } + component.isVisible = true + sync(layout = true) + } + + private fun sync(layout: Boolean) { + val visible = slot.isVisible + val changed = isVisible != visible + isVisible = visible + if (changed || layout) parent?.revalidate() + parent?.repaint() + } + + private fun status(kind: SettingsBannerKind) = when (kind) { + SettingsBannerKind.WARNING -> EditorNotificationPanel.Status.Warning + SettingsBannerKind.ERROR -> EditorNotificationPanel.Status.Error + } + + private data class BannerSpec( + val text: String, + val actions: List, + val kind: SettingsBannerKind, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt new file mode 100644 index 00000000000..da0831be360 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt @@ -0,0 +1,30 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis + +internal class ModelSettingPicker : Stack(StackAxis.HORIZONTAL) { + val picker = ModelPicker() + private var active = true + private var available = false + + init { + picker.allowEmpty = true + picker.emptyText = KiloBundle.message("settings.models.notSet") + next(picker) + } + + fun setItems(items: List, selected: String?) { + available = items.isNotEmpty() || picker.allowEmpty + picker.setItems(items, selected) + picker.isEnabled = active && available + } + + override fun setEnabled(value: Boolean) { + active = value + super.setEnabled(value) + picker.isEnabled = active && available + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt new file mode 100644 index 00000000000..99a267bb221 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.KiloReadyConfigurable +import com.intellij.openapi.project.ProjectManager +import kotlinx.coroutines.CoroutineScope +import javax.swing.JComponent + +class ModelsConfigurable : KiloReadyConfigurable() { + private var ui: ModelsSettingsUi? = null + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.models.displayName") + + override fun createReadyComponent(cs: CoroutineScope): JComponent { + val dir = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }?.basePath + val panel = ModelsSettingsUi(cs, directory = dir) + ui = panel + return panel + } + + override fun isModifiedReady(): Boolean = ui?.modified() == true + + override fun applyReady() { + ui?.applyDraft() + } + + override fun resetReady() { + ui?.resetDraft() + } + + override fun disposeReadyComponent(component: JComponent) { + val panel = ui ?: return + ui = null + panel.dispose() + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.models" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt new file mode 100644 index 00000000000..dc4b3cddc71 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt @@ -0,0 +1,90 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ProvidersDto + +internal data class ModelsDraft( + val model: String? = null, + val small: String? = null, + val subagent: String? = null, + val variant: String? = null, + val agents: Map = emptyMap(), +) + +internal fun modelsDraft(config: ConfigDto?, agents: List): ModelsDraft = ModelsDraft( + model = config?.model, + small = config?.smallModel, + subagent = config?.subagentModel, + variant = config?.subagentVariant, + agents = agents.associate { item -> item.name to config?.agent?.get(item.name)?.model }, +) + +internal fun patch(from: ModelsDraft, to: ModelsDraft): ConfigPatchDto { + val values = linkedMapOf() + if (from.model != to.model) values["model"] = to.model + if (from.small != to.small) values["small_model"] = to.small + if (from.subagent != to.subagent) { + values["subagent_model"] = to.subagent + if (to.subagent == null || from.variant != to.variant) values["subagent_variant"] = to.variant + } else if (from.variant != to.variant) { + values["subagent_variant"] = to.variant + } + + val agents = linkedMapOf() + for (name in (from.agents.keys + to.agents.keys).sorted()) { + if (from.agents[name] != to.agents[name]) agents[name] = AgentConfigPatchDto(model = to.agents[name]) + } + return ConfigPatchDto(values = values, agents = agents) +} + +internal fun parseSelection(key: String?): Pair? { + val idx = key?.indexOf('/') ?: return null + if (idx <= 0 || idx >= key.length - 1) return null + return key.substring(0, idx) to key.substring(idx + 1) +} + +internal fun key(provider: String, model: String): String = "$provider/$model" + +internal enum class ModelsStatus { + UNAVAILABLE, + LOADING, + LOAD_FAILED, + NO_PROVIDERS, + MODES_FAILED, + READY, + SAVING, +} + +internal fun modelsStatus( + ready: Boolean, + loading: Boolean, + providers: ProvidersDto?, + items: Int, + errors: List, + saving: Boolean, +): ModelsStatus { + if (saving) return ModelsStatus.SAVING + if (!ready) return ModelsStatus.UNAVAILABLE + if (loading) return ModelsStatus.LOADING + if (providers == null) return ModelsStatus.LOAD_FAILED + if (items == 0) return ModelsStatus.NO_PROVIDERS + if (errors.any { it.resource == "agents" }) return ModelsStatus.MODES_FAILED + return ModelsStatus.READY +} + +internal fun modelsLoginBannerVisible(ready: Boolean, authenticated: Boolean): Boolean = ready && !authenticated + +internal fun savedMatches(base: ModelsDraft, draft: ModelsDraft): Boolean { + if (base.model != draft.model) return false + if (base.small != draft.small) return false + if (base.subagent != draft.subagent) return false + if (base.variant != draft.variant) return false + for ((name, value) in draft.agents) { + if (base.agents[name] != value) return false + } + return true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt new file mode 100644 index 00000000000..11dba93cde4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -0,0 +1,312 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.ReasoningPicker +import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.session.ui.model.ModelText +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.BaseSettingsUi +import ai.kilocode.client.settings.base.SettingsBannerKind +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsRows +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelStateDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope + +internal class ModelsSettingsUi( + cs: CoroutineScope, + private val app: KiloAppService = service(), + private val workspaces: KiloWorkspaceService = service(), + directory: String? = null, +) : BaseSettingsUi( + cs, + ModelsDraft(), + app, + workspaces, + directory, +) { + + companion object { + private val LOG = KiloLog.create(ModelsSettingsUi::class.java) + } + + private val defaults get() = form.defaults + private val small get() = form.small + private val subagent get() = form.subagent + private val variant get() = form.variant + private val variantRow get() = form.variantRow + private val pickers get() = form.pickers + + private var providers: ProvidersDto? = null + private var agents: List = emptyList() + private var errors: List = emptyList() + private var allItems: List = emptyList() + + init { + startSettings(ModelsSettingsContent(app, { updateDraft(it) }, ::selectSubagent)) + } + + override fun change(from: ModelsDraft, to: ModelsDraft): ConfigPatchDto? = patch(from, to).takeIf { + it.values.isNotEmpty() || it.agents.isNotEmpty() + } + + override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { + app.updateConfigAsync(change, done) + } + + override fun base(result: KiloAppStateDto): ModelsDraft = modelsDraft(result.config, agents) + + override fun draft(state: KiloAppStateDto): ModelsDraft = modelsDraft(state.config, agents) + + override fun saved(base: ModelsDraft, draft: ModelsDraft): Boolean = savedMatches(base, draft) + + override fun pendingText(): String = KiloBundle.message("settings.models.save.pending") + + override fun failedText(): String = KiloBundle.message("settings.models.save.failed") + + override fun logSaveStarted(change: ConfigPatchDto) = LOG.info("model settings save: started ${summary(change)}") + + override fun logSaveCompleted(change: ConfigPatchDto) = LOG.info("model settings save: completed ${summary(change)}") + + override fun logSaveFailed(change: ConfigPatchDto) = LOG.warn("model settings save: failed ${summary(change)}") + + override fun logSaveFailedAfterDispose(change: ConfigPatchDto) = LOG.warn("model settings save: failed after dispose ${summary(change)}") + + override fun logSaveCompletedAfterDispose(change: ConfigPatchDto) = LOG.info("model settings save: completed after dispose ${summary(change)}") + + override fun unavailable(state: KiloAppStateDto) { + if (!workspaceLoaded && providers == null) { + agents = emptyList() + errors = emptyList() + } + } + + override fun models(state: ModelStateDto) = Unit + + override fun clearWorkspaceError() { + errors = emptyList() + } + + override suspend fun loadWorkspace(root: String): ModelsWorkspaceDto = workspaces.models(root) + + override fun applyWorkspace(result: ModelsWorkspaceDto) { + providers = result.providers + agents = result.agents?.agents ?: emptyList() + errors = result.errors + } + + @RequiresEdt + override fun syncContent() { + allItems = items(false) + val smallItems = items(true) + val state = modelsStatus( + ready = appState.status == KiloAppStatusDto.READY && hasProjectDirectory, + loading = workspaceLoading || (appState.status == KiloAppStatusDto.READY && !workspaceLoaded && hasProjectDirectory), + providers = providers, + items = allItems.size, + errors = errors, + saving = saving, + ) + val ready = state == ModelsStatus.READY || state == ModelsStatus.MODES_FAILED + val editable = !saving && (ready || state == ModelsStatus.LOADING) + val bannerVisible = modelsLoginBannerVisible( + ready = appState.status == KiloAppStatusDto.READY, + authenticated = appState.profile != null, + ) + syncModelBanner(state, bannerVisible) + val err = saveError + if (saving || state == ModelsStatus.SAVING) { + showProgress(KiloBundle.message("settings.models.save.pending")) + } else if (err != null) { + showError(err) + } else if (state == ModelsStatus.UNAVAILABLE || state == ModelsStatus.LOADING) { + showProgress(KiloBundle.message("settings.models.loading")) + } else { + clearProgress() + } + var layout = false + defaults.setItems(allItems, draft.model) + small.setItems(smallItems, draft.small) + subagent.setItems(allItems, draft.subagent) + listOf(defaults, small, subagent).forEach { it.isEnabled = editable } + layout = syncVariant(editable) || layout + layout = syncModes(editable) || layout + if (layout) { + revalidate() + repaint() + } + } + + @RequiresEdt + private fun syncModelBanner(state: ModelsStatus, login: Boolean) { + syncLoginBanner(login) { + if ((saving || state == ModelsStatus.LOADING || state == ModelsStatus.SAVING) && top.isVisible) return@syncLoginBanner + when (state) { + ModelsStatus.LOAD_FAILED -> top.showBanner( + KiloBundle.message("settings.models.load.failed"), + emptyList(), + SettingsBannerKind.ERROR, + ) + ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList()) + ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList()) + else -> top.hideBanner() + } + } + } + + private fun items(includeSmall: Boolean): List { + val cfg = providers ?: return emptyList() + return cfg.providers + .filter { it.id == KILO_PROVIDER || it.id in cfg.connected } + .flatMap { provider -> + provider.models.mapNotNull { (id, model) -> + val item = ModelPicker.Item( + id, + model.name, + provider.id, + provider.name, + model.recommendedIndex, + model.free, + model.byok, + model.variants, + mayTrainOnYourPrompts = model.mayTrainOnYourPrompts, + ) + if (!includeSmall && ModelText.small(item)) return@mapNotNull null + item + } + } + } + + @RequiresEdt + private fun syncVariant(ready: Boolean): Boolean { + val item = allItems.firstOrNull { it.key == draft.subagent || it.id == draft.subagent } + val valid = item?.variants.orEmpty() + if (draft.variant != null && draft.variant !in valid) draft = draft.copy(variant = valid.firstOrNull()) + if (draft.subagent != null && valid.isEmpty() && draft.variant != null) draft = draft.copy(variant = null) + variant.setItems(valid.map { ReasoningPicker.Item(it, variantTitle(it)) }, draft.variant) + variant.isEnabled = ready && valid.isNotEmpty() + val visible = valid.isNotEmpty() + val changed = variantRow.isVisible != visible + variantRow.isVisible = visible + variant.isVisible = visible + return changed + } + + @RequiresEdt + private fun syncModes(ready: Boolean): Boolean { + var layout = false + val names = agents.map { it.name } + if (names != pickers.keys.toList()) { + form.modes.removeAll() + pickers.clear() + agents.forEach { agent -> + val picker = ModelSettingPicker() + picker.picker.favorites = { app.favorites.value } + picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } + picker.picker.onSelect = { item -> updateDraft { copy(agents = this.agents + (agent.name to item.key)) } } + picker.picker.onClear = { updateDraft { copy(agents = this.agents + (agent.name to null)) } } + pickers[agent.name] = picker + form.modes.row(agent.name, SettingsRow( + agent.displayName ?: title(agent.name), + agent.description, + picker, + )) + } + layout = true + } + agents.forEach { agent -> + val name = agent.name + val picker = pickers[name] ?: return@forEach + form.modes.update(name, agent.displayName ?: title(name), agent.description, picker) + val value = draft.agents[name] + picker.setItems(allItems, value) + picker.isEnabled = ready + } + return layout + } + + private fun selectSubagent(item: ModelPicker.Item) { + val variant = if (draft.subagent == item.key && draft.variant in item.variants) draft.variant else item.variants.firstOrNull() + updateDraft { copy(subagent = item.key, variant = variant) } + } +} + +private const val KILO_PROVIDER = "kilo" + +private fun summary(patch: ConfigPatchDto): String { + val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } + return "values=$values agents=${patch.agents.size}" +} + +internal class ModelsSettingsContent( + app: KiloAppService, + update: (ModelsDraft.() -> ModelsDraft) -> Unit, + select: (ModelPicker.Item) -> Unit, +) : BaseContentPanel() { + val defaults = ModelSettingPicker() + val small = ModelSettingPicker() + val subagent = ModelSettingPicker() + val variant = ReasoningPicker() + val variantRow = SettingsRow( + KiloBundle.message("settings.models.subagentVariant.title"), + KiloBundle.message("settings.models.subagentVariant.description"), + variant.align(HAlign.RIGHT, VAlign.CENTER), + ) + val modes: SettingsRows + val pickers = linkedMapOf() + + init { + defaults.picker.onSelect = { update { copy(model = it.key) } } + defaults.picker.onClear = { update { copy(model = null) } } + small.picker.onSelect = { update { copy(small = it.key) } } + small.picker.onClear = { update { copy(small = null) } } + small.picker.includeSmall = true + subagent.picker.onSelect = { item -> select(item) } + subagent.picker.onClear = { update { copy(subagent = null, variant = null) } } + variant.onSelect = { item -> update { copy(variant = item.id) } } + listOf(defaults, small, subagent).forEach { picker -> + picker.picker.favorites = { app.favorites.value } + picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } + } + + val rows = section(KiloBundle.message("settings.models.displayName")) + rows.row(SettingsRow( + KiloBundle.message("settings.models.defaultModel.title"), + KiloBundle.message("settings.models.defaultModel.description"), + defaults, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.smallModel.title"), + KiloBundle.message("settings.models.smallModel.description"), + small, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.subagentModel.title"), + KiloBundle.message("settings.models.subagentModel.description"), + subagent, + )) + rows.row(variantRow) + modes = section( + KiloBundle.message("settings.models.modeModels.title"), + KiloBundle.message("settings.models.modeModels.description"), + ) + } +} + +private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + +private fun title(value: String): String = value.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.titlecase() } 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 aa74f1c5d31..c3019b12721 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 @@ -3,17 +3,20 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ProfileDto import com.intellij.icons.AllIcons import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.util.IconLoader import com.intellij.ui.RelativeFont import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel -import java.awt.GridBagConstraints -import java.awt.GridBagLayout import java.awt.KeyboardFocusManager import java.awt.event.FocusEvent import java.awt.event.FocusListener @@ -22,6 +25,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingConstants +import java.awt.BorderLayout /** * Retained logged-in UI. Labels, combo box, and buttons are built once and @@ -43,6 +47,10 @@ internal class LoggedInProfileUi( foreground = UiStyle.Colors.weak() setCopyable(true) } + private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedInProfileUi::class.java)).apply { + name = "kilo.profile.logo.loggedIn" + accessibleContext.accessibleName = KiloBundle.message("settings.kilo.displayName") + } private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply { foreground = UiStyle.Colors.weak() @@ -65,16 +73,10 @@ internal class LoggedInProfileUi( private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { name = "kilo.profile.balanceCard" addToTop(titleLabel) - addToCenter(JPanel(GridBagLayout()).apply { - isOpaque = false - add(valueLabel, GridBagConstraints().apply { - gridx = 0; gridy = 0; anchor = GridBagConstraints.CENTER - }) - add(refreshBtn, GridBagConstraints().apply { - gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER - insets = JBUI.insetsTop(UiStyle.Gap.pad()) - }) - }) + addToCenter(Stack.vertical(UiStyle.Gap.pad()) + .next(valueLabel) + .next(refreshBtn) + .align(HAlign.CENTER, VAlign.CENTER)) } private val comboModel = DefaultComboBoxModel() @@ -85,29 +87,23 @@ internal class LoggedInProfileUi( val logoutBtn = JButton(KiloBundle.message("profile.action.logout")) .also { it.addActionListener { logout() } } - private val actionRow = JPanel(GridBagLayout()).apply { - add(dashboardBtn, GridBagConstraints().apply { - gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST - }) - add(logoutBtn, GridBagConstraints().apply { - gridx = 1; gridy = 0; anchor = GridBagConstraints.WEST - insets = JBUI.insetsLeft(UiStyle.Gap.md()) - }) - } + private val actionRow = Stack.horizontal(UiStyle.Gap.md()) + .next(dashboardBtn) + .next(logoutBtn) - private val rows: List = listOf(nameLabel, emailLabel, combo, balanceCard, actionRow) + private val header = JPanel(BorderLayout()).apply { + isOpaque = false + add(Stack.vertical(UiStyle.Gap.lg()) + .next(nameLabel) + .next(emailLabel), BorderLayout.CENTER) + add(logoLabel, BorderLayout.EAST) + } - private val content = JPanel(GridBagLayout()).apply { - val gap = UiStyle.Gap.lg() - rows.forEachIndexed { i, comp -> - add(comp, GridBagConstraints().apply { - gridx = 0; gridy = i - weightx = 1.0 - fill = GridBagConstraints.HORIZONTAL - anchor = GridBagConstraints.WEST - insets = if (i == 0) JBUI.emptyInsets() else JBUI.insetsTop(gap) - }) - } + private val content = Stack.vertical(UiStyle.Gap.lg()).apply { + next(header) + next(combo) + next(balanceCard) + next(actionRow.align(HAlign.CENTER, VAlign.CENTER)) } private var applying = false 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 f15a3b85d24..6324247efa3 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 @@ -1,19 +1,15 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.ui.HoverIcon -import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.settings.auth.DeviceOAuthInfo +import ai.kilocode.client.settings.auth.DeviceOAuthPanel +import ai.kilocode.client.settings.auth.DeviceOAuthText import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.KiloAppStatusDto -import com.intellij.icons.AllIcons -import com.intellij.openapi.ide.CopyPasteManager -import com.intellij.openapi.ui.popup.Balloon -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.ui.SimpleColoredComponent -import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.awt.RelativePoint +import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBTextField import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.AsyncProcessIcon import com.intellij.util.ui.JBUI @@ -22,17 +18,10 @@ import java.awt.CardLayout import java.awt.FlowLayout import java.awt.GridBagConstraints import java.awt.GridBagLayout -import java.awt.Point -import java.awt.datatransfer.StringSelection -import java.awt.event.FocusAdapter -import java.awt.event.FocusEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingConstants -import javax.swing.Timer internal enum class OutMode { CONNECTING, APP_ERROR, INITIATING, AUTH, LOGIN_ERROR, EMPTY } @@ -46,6 +35,7 @@ internal class LoggedOutProfileUi( private val retry: () -> Unit, private val cancel: () -> Unit, private val browse: (String) -> Unit, + private val timers: UiTimerSource = UiTimers, ) : JPanel(BorderLayout()) { private val cards = JPanel(CardLayout()) @@ -65,63 +55,23 @@ internal class LoggedOutProfileUi( private val authRetryBtn = JButton(KiloBundle.message("profile.login.tryAgain")) .also { it.addActionListener { login() } } - private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel")) - .also { it.addActionListener { cancel() } } - - private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser")) - - private val copyUrlBtn = HoverIcon().apply { - icon = AllIcons.Actions.Copy - toolTipText = KiloBundle.message("profile.login.copyUrl") - } - - // -- retained auth card components -- - val urlField = JBTextField().apply { - isEditable = false - name = "kilo.login.url" - columns = 30 - // Select all on focus so clicking the field selects the whole URL - addFocusListener(object : FocusAdapter() { - override fun focusGained(e: FocusEvent) = selectAll() - }) - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) = selectAll() - }) - } - - val qrLabel = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - name = "kilo.login.qr" - accessibleContext.accessibleName = KiloBundle.message("profile.login.qr") - accessibleContext.accessibleDescription = KiloBundle.message("profile.login.qr.description") - } - - private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { - name = "kilo.login.codePanel" - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val c = rawCode ?: return - copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@LoggedOutProfileUi) - } - }) - } - - private val codeLabel = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - font = UiStyle.Fonts.large() - } - - private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply { - foreground = UiStyle.Colors.weak() - horizontalAlignment = SwingConstants.CENTER - } + private val auth = DeviceOAuthPanel( + DeviceOAuthText( + title = KiloBundle.message("profile.login.title"), + qrDescription = KiloBundle.message("profile.login.qr.description"), + ), + cancel = cancel, + browse = browse, + prefix = "kilo.login", + timers = timers, + ) private val initiatingIcon = AsyncProcessIcon("KiloInitiating").also { it.suspend() } - private val waitIcon = AsyncProcessIcon("KiloLogin") - - private val waitLabel = JBLabel().apply { - foreground = UiStyle.Colors.weak() + private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedOutProfileUi::class.java)).apply { + name = "kilo.profile.logo.loggedOut" + horizontalAlignment = SwingConstants.CENTER + accessibleContext.accessibleName = KiloBundle.message("settings.kilo.displayName") } private val errLabel = JBLabel().apply { @@ -129,28 +79,12 @@ internal class LoggedOutProfileUi( horizontalAlignment = SwingConstants.CENTER } - // -- step 2 label reference for visibility toggling -- - private var step2Label: SimpleColoredComponent? = null - - // -- countdown state -- - private var rawCode: String? = null - private var pendingStarted = 0L - private var pendingExpires = 900 - - // -- cached URL for listener/QR deduplication -- - private var lastPendingUrl: String? = null - - private val timer = Timer(1000) { syncTime() } - init { - codePanel.add(codeLabel, BorderLayout.CENTER) - codePanel.add(codeHint, BorderLayout.SOUTH) - cards.add(connectingCard(), OutMode.CONNECTING.name) cards.add(appErrorCard(), OutMode.APP_ERROR.name) cards.add(emptyCard(), OutMode.EMPTY.name) cards.add(initiatingCard(), OutMode.INITIATING.name) - cards.add(authCard(), OutMode.AUTH.name) + cards.add(auth, OutMode.AUTH.name) cards.add(loginErrorCard(), OutMode.LOGIN_ERROR.name) add(cards, BorderLayout.NORTH) } @@ -179,11 +113,12 @@ internal class LoggedOutProfileUi( private fun emptyCard(): JPanel { val p = padded() + p.add(logoLabel, gbc(0).centered()) p.add(JBLabel(KiloBundle.message("profile.notLoggedIn")).apply { foreground = UiStyle.Colors.weak() horizontalAlignment = SwingConstants.CENTER - }, gbc(0)) - p.add(loginBtn, gbc(1, UiStyle.Gap.sm()).centered()) + }, gbc(1, UiStyle.Gap.pad())) + p.add(loginBtn, gbc(2, UiStyle.Gap.sm()).centered()) return p } @@ -200,57 +135,6 @@ internal class LoggedOutProfileUi( return p } - private fun authCard(): JPanel { - val p = padded() - var row = 0 - - p.add(JBLabel(KiloBundle.message("profile.login.title")).apply { - font = UiStyle.Fonts.heading() - horizontalAlignment = SwingConstants.CENTER - }, gbc(row++)) - - p.add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")), - gbc(row++, UiStyle.Gap.md())) - - p.add(urlRow(), gbc(row++, UiStyle.Gap.sm())) - - p.add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered()) - - val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code")) - step2Label = s2 - p.add(s2, gbc(row++, UiStyle.Gap.md())) - - p.add(codePanel, gbc(row++, UiStyle.Gap.sm())) - - val waitRow = JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { - isOpaque = false - add(waitIcon) - add(waitLabel) - } - p.add(waitRow, gbc(row++, UiStyle.Gap.xl())) - - p.add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered()) - - return p - } - - private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply { - append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) - append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES) - } - - private fun urlRow(): JPanel { - val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)) - row.add(urlField, BorderLayout.CENTER) - val btns = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { - isOpaque = false - add(copyUrlBtn) - add(openBtn) - } - row.add(btns, BorderLayout.EAST) - return row - } - private fun loginErrorCard(): JPanel { val p = padded() p.add(errLabel, gbc(0)) @@ -266,46 +150,7 @@ internal class LoggedOutProfileUi( if (target == OutMode.AUTH && login is LoginState.Pending) { val auth = login.auth - val url = auth.verificationUrl - val code = auth.code - - rawCode = code - urlField.text = url - urlField.toolTipText = url - - // Wire listeners and generate QR only when URL changes (avoids re-wiring on every re-sync) - if (url != lastPendingUrl) { - lastPendingUrl = url - - openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) } - openBtn.addActionListener { browse(url) } - copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) } - copyUrlBtn.addActionListener { - copyToClipboard(url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn) - } - - // QR code — expensive; only regenerate when URL changes - try { - qrLabel.icon = QrCode.icon(url, JBUI.scale(160)) - } catch (_: Exception) { - qrLabel.icon = null - } - } - - // Code display - codePanel.isVisible = code != null - step2Label?.isVisible = code != null - if (code != null) { - codeLabel.text = spacedCode(code) - } - - // Countdown: only reset when entering auth for the first time for this pending - if (mode != OutMode.AUTH) { - pendingStarted = login.started - pendingExpires = auth.expiresIn - syncTime() - timer.restart() - } + this.auth.update(DeviceOAuthInfo(auth.verificationUrl, auth.code, auth.expiresIn, login.started)) } if (target == OutMode.LOGIN_ERROR && login is LoginState.Error) { @@ -314,16 +159,11 @@ internal class LoggedOutProfileUi( if (mode != target) { if (mode == OutMode.AUTH) { - timer.stop() - waitIcon.suspend() - lastPendingUrl = null + auth.dispose() } if (mode == OutMode.INITIATING) initiatingIcon.suspend() cardLayout.show(cards, target.name) mode = target - if (target == OutMode.AUTH) { - waitIcon.resume() - } if (target == OutMode.INITIATING) initiatingIcon.resume() revalidate() repaint() @@ -336,14 +176,12 @@ internal class LoggedOutProfileUi( /** Stop the timer and suspend all animated icons. Safe to call multiple times. */ @RequiresEdt fun dispose() { - timer.stop() - waitIcon.suspend() initiatingIcon.suspend() - lastPendingUrl = null + auth.dispose() } private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { - status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING -> OutMode.CONNECTING + status == KiloAppStatusDto.DISCONNECTED || 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 @@ -351,15 +189,6 @@ internal class LoggedOutProfileUi( else -> OutMode.EMPTY } - @RequiresEdt - private fun syncTime() { - val elapsed = ((System.currentTimeMillis() - pendingStarted) / 1000).toInt() - val remain = (pendingExpires - elapsed).coerceAtLeast(0) - val min = remain / 60 - val sec = remain % 60 - waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}") - } - // ---- helpers ---- private fun padded() = JPanel(GridBagLayout()).apply { @@ -378,18 +207,4 @@ internal class LoggedOutProfileUi( fill = GridBagConstraints.NONE anchor = GridBagConstraints.CENTER } - - private fun spacedCode(code: String): String = code.map { it.toString() }.joinToString(" ") -} - -/** Copy [text] to the platform clipboard and show a brief confirmation balloon anchored to [anchor]. */ -private fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) { - CopyPasteManager.getInstance().setContents(StringSelection(text)) - if (anchor is javax.swing.JComponent) { - val point = RelativePoint(anchor, Point(anchor.width / 2, 0)) - JBPopupFactory.getInstance() - .createHtmlTextBalloonBuilder(msg, null, null, null) - .createBalloon() - .show(point, Balloon.Position.above) - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt index 689f908e5e4..096f57fdbd7 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 @@ -2,6 +2,9 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.ProfileDto @@ -41,6 +44,7 @@ internal class ProfileUi( private val cs: CoroutineScope, private val app: KiloAppService = service(), private val browse: (String) -> Unit = { BrowserUtil.browse(it) }, + private val timers: UiTimerSource = UiTimers, ) : JPanel(BorderLayout()) { private val cards = JPanel(CardLayout()) @@ -51,9 +55,13 @@ internal class ProfileUi( retry = { app.retryAsync() }, cancel = ::cancel, browse = browse, + timers = timers, ) private val account = LoggedInProfileUi( - dashboard = { browse(DASHBOARD_URL) }, + dashboard = { + telemetry("Dashboard Opened", mapOf("surface" to "settings")) + browse(DASHBOARD_URL) + }, logout = ::logout, organization = ::organization, refresh = ::refreshProfile, @@ -145,7 +153,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 + val transientLoad = 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 @@ -180,17 +188,19 @@ internal class ProfileUi( private fun start() { val id = ++attempt login = LoginState.Initiating + telemetry("Account Connect Clicked", mapOf("surface" to "settings")) sync() cs.launch { try { val next = app.startLogin() withContext(edt) { if (id != attempt) return@withContext - login = LoginState.Pending(next, System.currentTimeMillis()) + login = LoginState.Pending(next, timers.now()) sync() browse(next.verificationUrl) } val profile = app.completeLogin() + telemetry("Account Connect Success", mapOf("surface" to "settings", "hasOrganizations" to ((profile?.organizations?.isNotEmpty()) == true).toString())) val state = app.state.value withContext(edt) { if (id != attempt) return@withContext @@ -200,6 +210,7 @@ internal class ProfileUi( } catch (e: CancellationException) { throw e } catch (e: Exception) { + telemetry("Account Connect Failed", mapOf("stage" to "complete", "errorClass" to e::class.java.name)) withContext(edt) { if (id != attempt) return@withContext login = LoginState.Error(compactLoginError(e)) @@ -212,14 +223,17 @@ internal class ProfileUi( private fun cancel() { attempt++ login = LoginState.Idle + telemetry("Account Connect Failed", mapOf("stage" to "cancel", "errorClass" to "cancelled")) sync() } private fun logout() { + telemetry("Account Logout Clicked", mapOf("surface" to "settings")) cs.launch { try { val ok = app.logout() if (!ok) return@launch + telemetry("Account Logout Success", mapOf("surface" to "settings")) withContext(edt) { login = LoginState.Idle applyState() @@ -238,6 +252,7 @@ internal class ProfileUi( cs.launch { try { val profile = app.setOrganization(org) + telemetry("Organization Switched", mapOf("target" to if (org == null) "personal" else "organization")) val state = app.state.value withContext(edt) { update(profile ?: state.profile, state.status) @@ -271,6 +286,10 @@ internal class ProfileUi( } } } + + private fun telemetry(event: String, props: Map) { + Telemetry.send(event, props) + } } private val HTML_MARKERS = listOf(" KiloBundle.optional(key)?.let { return it } } + return "" +} + +internal fun providerIcon(provider: ProviderSettingsProviderDto): Icon { + val id = provider.metadata?.icon ?: provider.id + return ProviderIcons.icon(id) +} + +private object ProviderIcons { + private val cache = ConcurrentHashMap() + + fun icon(id: String): Icon = cache.computeIfAbsent(id) { key -> + val icon = IconLoader.findIcon("/icons/providers/$key.svg", ProviderIcons::class.java) + FixedProviderIcon(icon ?: IconLoader.findIcon("/icons/providers/synthetic.svg", ProviderIcons::class.java) ?: AllIcons.Nodes.Plugin) + } +} + +private class FixedProviderIcon(private val icon: Icon) : Icon { + override fun getIconWidth() = JBUI.scale(20) + + override fun getIconHeight() = JBUI.scale(20) + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val width = icon.iconWidth.coerceAtLeast(1) + val height = icon.iconHeight.coerceAtLeast(1) + val size = min(iconWidth.toDouble() / width, iconHeight.toDouble() / height) + val w = (width * size).roundToInt().coerceAtLeast(1) + val h = (height * size).roundToInt().coerceAtLeast(1) + val copy = g.create() as Graphics2D + try { + copy.translate(x + (iconWidth - w) / 2, y + (iconHeight - h) / 2) + copy.scale(size, size) + icon.paintIcon(c, copy, 0, 0) + } finally { + copy.dispose() + } + } +} + +internal fun providerMethods(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto): List { + val methods = state.auth[provider.id] + if (!methods.isNullOrEmpty()) return methods + return listOf(ProviderAuthMethodDto("api", "API key")) +} + +internal fun providerOAuthMethodIndex(methods: List): String? { + val indexed = methods.withIndex().filter { it.value.type == "oauth" } + if (indexed.isEmpty()) return null + val remote = indexed.firstOrNull { entry -> + val label = entry.value.label.lowercase() + listOf("headless", "remote", "device", "vps").any { label.contains(it) } + } + return (remote ?: indexed.first()).index.toString() +} + +internal fun hiddenProvider(provider: ProviderSettingsProviderDto) = provider.id == "openai-compatible" + +internal fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set) = + provider.id in ids || provider.key != null || provider.source == "config" || provider.id in state.config diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt new file mode 100644 index 00000000000..c9a20c283ea --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt @@ -0,0 +1,176 @@ +package ai.kilocode.client.settings.providers + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.ui.CollectionListModel +import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.SwingConstants + +private const val ACTION_GAP = 8 + +internal class ProviderListRenderer( + private val model: CollectionListModel, +) : JPanel(BorderLayout()), ListCellRenderer { + companion object { + fun actionAt(list: JList<*>, bounds: Rectangle, point: Point, row: ProviderListRow, selected: Boolean): ProviderListAction? { + val height = buttonHeight(list) + val top = bounds.y + (bounds.height - height) / 2 + if (point.y !in top..(top + height)) return null + var edge = bounds.x + bounds.width - UiStyle.Gap.pad() + for (action in visibleActions(row, selected).asReversed()) { + val width = buttonWidth(list, action) + val left = edge - width + if (point.x in left..edge) return action.takeIf(row::enabled) + edge = left - JBUI.scale(ACTION_GAP) + } + return null + } + + internal fun actionBounds(list: JList<*>, bounds: Rectangle, row: ProviderListRow, selected: Boolean): Map { + val height = buttonHeight(list) + val top = bounds.y + (bounds.height - height) / 2 + var edge = bounds.x + bounds.width - UiStyle.Gap.pad() + val out = linkedMapOf() + for (action in visibleActions(row, selected).asReversed()) { + val width = buttonWidth(list, action) + val left = edge - width + out[action] = Rectangle(left, top, width, height) + edge = left - JBUI.scale(ACTION_GAP) + } + return out + } + + private fun buttonWidth(list: JList<*>, action: ProviderListAction): Int { + val text = text(action) + val metrics = list.getFontMetrics(list.font) + return metrics.stringWidth(text) + UiStyle.Gap.pad() * 2 + } + + private fun buttonHeight(list: JList<*>): Int { + val metrics = list.getFontMetrics(list.font) + return metrics.height + UiStyle.Gap.sm() * 2 + } + + internal fun text(action: ProviderListAction) = when (action) { + ProviderListAction.CONNECT -> KiloBundle.message("settings.providers.connect") + ProviderListAction.OAUTH -> KiloBundle.message("settings.providers.oauth") + ProviderListAction.DISCONNECT -> KiloBundle.message("settings.providers.disconnect") + ProviderListAction.ENABLE -> KiloBundle.message("settings.providers.enable") + } + + internal fun visibleActions(row: ProviderListRow, selected: Boolean): List { + if (row.disabled) return emptyList() + if (row.connected) return row.actions.filter { it == ProviderListAction.DISCONNECT } + if (!selected) return emptyList() + return row.actions + } + } + + private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()) + private val top = JPanel(BorderLayout()).apply { + border = JBUI.Borders.empty() + add(sep, BorderLayout.NORTH) + } + private val icon = JBLabel() + private val mark = icon.align(HAlign.CENTER, VAlign.TOP) + private val title = SimpleColoredComponent() + private val desc = JBLabel() + private val text = Stack.vertical().next(title).next(desc) + private val actions = Stack.horizontal(JBUI.scale(ACTION_GAP)) + private val actionPane = actions.align(HAlign.RIGHT, VAlign.CENTER) + private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + add(mark, BorderLayout.WEST) + add(text, BorderLayout.CENTER) + add(actionPane, BorderLayout.EAST) + } + private val wrap = PickerRow() + + init { + isOpaque = true + top.isOpaque = true + UiStyle.Components.transparent(row, mark, icon, title, text, desc, actions, actionPane) + row.border = JBUI.Borders.empty( + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + UiStyle.Gap.md(), + UiStyle.Gap.pad(), + ) + wrap.setContent(row) + add(top, BorderLayout.NORTH) + add(wrap, BorderLayout.CENTER) + } + + override fun getListCellRendererComponent( + list: JList, + value: ProviderListRow, + index: Int, + selected: Boolean, + focused: Boolean, + ): JPanel { + val focus = selected || list.hasFocus() || focused + val fg = UIUtil.getListForeground(selected, focus) + val weak = if (selected) fg else UiStyle.Colors.weak() + val current = model.items.getOrNull(index) + val section = if (current === value) providerListSectionTitle(model.items, index) else null + + background = list.background + top.background = list.background + wrap.update(list, selected, focus) + sep.caption = section + sep.setHideLine(index == 0) + top.isVisible = section != null + + title.clear() + title.append(value.provider.name, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) + icon.icon = providerIcon(value.provider) + val note = providerDescription(value.provider) + desc.text = note + desc.isVisible = note.isNotEmpty() + desc.foreground = weak + + actions.removeAll() + val visible = visibleActions(value, selected) + actions.isVisible = visible.isNotEmpty() + actionPane.isVisible = visible.isNotEmpty() + for (action in visible) { + actions.add(ActionLabel(action).apply { + isEnabled = value.enabled(action) + UiStyle.Components.actionLabel(this, isEnabled) + }) + } + top.invalidate() + return this + } + + internal fun actionTexts() = actions.components.filterIsInstance().map { it.text } + + internal fun descriptionText() = desc.text + + internal fun providerIconVisible() = icon.icon != null + + internal fun providerIconSize() = icon.icon?.let { Dimension(it.iconWidth, it.iconHeight) } + + private class ActionLabel(action: ProviderListAction) : JBLabel(text(action)) { + init { + horizontalAlignment = SwingConstants.CENTER + UiStyle.Components.actionLabel(this) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt new file mode 100644 index 00000000000..0ff1d77322f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRows.kt @@ -0,0 +1,84 @@ +package ai.kilocode.client.settings.providers + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.model.ModelSearch +import ai.kilocode.rpc.dto.ProviderSettingsDto +import ai.kilocode.rpc.dto.ProviderSettingsProviderDto + +internal enum class ProviderListAction { + CONNECT, + OAUTH, + DISCONNECT, + ENABLE, +} + +internal data class ProviderListRow( + val provider: ProviderSettingsProviderDto, + val section: String, + val actions: List, + val connected: Boolean = false, + val disabled: Boolean = false, +) { + val key: String get() = provider.id + + fun enabled(action: ProviderListAction) = !disabled && (action != ProviderListAction.DISCONNECT || provider.source != "env") +} + +internal fun providerListRows(state: ProviderSettingsDto, query: String, disabledRows: Boolean = false): List { + val q = query.trim() + val ids = state.connected.toSet() + val disabled = state.disabled.toSet() + val filtered = state.providers.filter { ModelSearch.matches(q, it.name) } + val connected = filtered + .filter { configured(it, state, ids) } + .sortedWith(compareBy { popularProviderIndex(it) }.thenBy { it.name.lowercase() }.thenBy { it.id }) + val connectedIds = connected.mapTo(mutableSetOf()) { it.id } + val popular = filtered + .filter { it.id !in connectedIds } + .filter { it.id !in disabled } + .filter { !hiddenProvider(it) } + .filter { isPopularProvider(it) } + .sortedWith(compareBy { popularProviderIndex(it) }.thenBy { it.name.lowercase() }.thenBy { it.id }) + val popularIds = popular.mapTo(mutableSetOf()) { it.id } + val all = filtered + .filter { it.id !in connectedIds } + .filter { it.id !in popularIds } + .filter { !hiddenProvider(it) } + .sortedWith(compareBy { it.name.lowercase() }.thenBy { it.id }) + val rows = mutableListOf() + rows += connected.map { ProviderListRow(it, KiloBundle.message("settings.providers.connected"), providerActions(it, state, disabled), connected = true, disabled = disabledRows) } + rows += popular.map { ProviderListRow(it, KiloBundle.message("settings.providers.popular"), providerActions(it, state, disabled), disabled = disabledRows) } + rows += all.map { ProviderListRow(it, KiloBundle.message("settings.providers.all"), providerActions(it, state, disabled), disabled = disabledRows) } + return rows +} + +internal fun providerListIndex(rows: List, key: String?): Int { + if (key == null) return if (rows.isEmpty()) -1 else 0 + return rows.indexOfFirst { it.key == key } +} + +internal fun providerListIndex(rows: List, index: Int): Int { + if (rows.isEmpty()) return -1 + return index.coerceIn(0, rows.lastIndex) +} + +internal fun providerListSectionTitle(rows: List, index: Int): String? { + val row = rows.getOrNull(index) ?: return null + val prev = rows.getOrNull(index - 1) + return if (prev?.section != row.section) row.section else null +} + +internal fun providerActions( + provider: ProviderSettingsProviderDto, + state: ProviderSettingsDto, + disabled: Set = state.disabled.toSet(), +): List { + if (provider.id in disabled) return listOf(ProviderListAction.ENABLE) + if (provider.id == KILO_PROVIDER_ID && configured(provider, state, state.connected.toSet())) return emptyList() + if (configured(provider, state, state.connected.toSet())) return listOf(ProviderListAction.DISCONNECT) + val methods = providerMethods(provider, state) + return buildList { + if (methods.any { it.type == "oauth" }) add(ProviderListAction.OAUTH) + if (methods.any { it.type == "api" }) add(ProviderListAction.CONNECT) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersConfigurable.kt new file mode 100644 index 00000000000..aba5d10ccc7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersConfigurable.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.settings.providers + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.KiloReadyConfigurable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.ProjectManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import javax.swing.JComponent + +class ProvidersConfigurable : KiloReadyConfigurable() { + private var ui: ProvidersSettingsUi? = null + + override fun getId(): String = ID + override fun getDisplayName(): String = KiloBundle.message("settings.providers.displayName") + + @RequiresEdt + override fun createReadyComponent(cs: CoroutineScope): JComponent { + checkEdt() + val dir = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }?.basePath.orEmpty() + val panel = ProvidersSettingsUi(cs, dir) + ui = panel + return panel + } + + @RequiresEdt + override fun resetReady() { + checkEdt() + ui?.reload() + } + + override fun cancelScopeBeforeReadyDispose(): Boolean = true + + override fun scrollReadyShell(): Boolean = false + + override fun disposeReadyComponent(component: JComponent) { + val panel = ui ?: return + ui = null + panel.dispose() + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Provider configurable UI must run on EDT" } + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.providers" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt new file mode 100644 index 00000000000..3a88760f321 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -0,0 +1,656 @@ +package ai.kilocode.client.settings.providers + +import ai.kilocode.client.app.KiloProviderService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsPanel +import ai.kilocode.client.settings.auth.DeviceOAuthInfo +import ai.kilocode.client.settings.auth.DeviceOAuthPanel +import ai.kilocode.client.settings.auth.DeviceOAuthText +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.CustomModelDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.ProviderAuthMethodDto +import ai.kilocode.rpc.dto.ProviderAuthOptionDto +import ai.kilocode.rpc.dto.ProviderConnectDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto +import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import ai.kilocode.rpc.dto.ProviderSettingsProviderDto +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonShortcuts +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.CollectionListModel +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.SearchTextField +import com.intellij.ui.ScrollingUtil +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComboBox +import javax.swing.JComponent +import javax.swing.DefaultListCellRenderer +import javax.swing.JList +import javax.swing.KeyStroke +import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent +import javax.swing.Icon +import javax.swing.Timer + +private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + +private val OAUTH_CODE_RE = Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE) + +private fun oauthCode(text: String?): String? = text?.let { OAUTH_CODE_RE.find(it)?.groupValues?.getOrNull(1) } + +internal class ProvidersSettingsUi( + private val cs: CoroutineScope, + private val directory: String, +) : SettingsPanel(), Disposable { + companion object { + val LOG = KiloLog.create(ProvidersSettingsUi::class.java) + } + + private val add = ProviderToolbarAction( + KiloBundle.message("settings.providers.addCustom"), + KiloBundle.message("settings.providers.addCustom.description"), + AllIcons.General.Add, + { !busy }, + ) { custom() } + private val refresh = ProviderToolbarAction( + KiloBundle.message("settings.providers.refresh"), + KiloBundle.message("settings.providers.refresh.description"), + AllIcons.Actions.Refresh, + { !busy }, + ) { reload() } + private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable) + private val search = SearchTextField(false).apply { + textEditor.emptyText.text = KiloBundle.message("settings.providers.search") + } + private var state = ProviderSettingsDto() + private var job: Job? = null + private var request = 0 + private var disposed = false + private var busy = false + private var timer: Timer? = null + private var oauth: DeviceOAuthPanel? = null + + init { + content.add(header(), BorderLayout.NORTH) + setContent(view) + reload() + } + + @RequiresEdt + fun reload() { + checkEdt() + LOG.info("provider settings ui reload: start dir=$directory") + if (!launch("reload") { id -> + val next = service().state(directory) + LOG.info("provider settings ui reload: state providers=${next.providers.size} errors=${next.errors.size}") + apply(id, next, null) + }) return + syncLoading() + } + + @RequiresEdt + private fun syncLoading() { + checkEdt() + showProgress(KiloBundle.message("settings.providers.loading")) + } + + @RequiresEdt + private fun connect(provider: ProviderSettingsProviderDto) { + checkEdt() + val methods = state.auth[provider.id].orEmpty().filter { it.type == "api" } + val dialog = ApiKeyDialog(provider.name, methods.firstOrNull()) + if (!dialog.showAndGet()) return + val key = dialog.key() + val metadata = dialog.metadata() + if (!launch("connect provider=${provider.id}") { id -> + val result = service().connect(ProviderConnectDto(directory, provider.id, key, metadata)) + apply(id, result.state, result.error) + }) return + syncLoading() + } + + @RequiresEdt + private fun oauth(provider: ProviderSettingsProviderDto) { + checkEdt() + val method = providerOAuthMethodIndex(state.auth[provider.id].orEmpty()) ?: return + if (!launch("authorize provider=${provider.id}") { id -> + val ready = service().authorize(ProviderOAuthAuthorizeDto(directory, provider.id, method)) + val code = withContext(edt) { + if (!active(id)) return@withContext null + ready.url?.let(BrowserUtil::browse) + if (ready.method == "code") { + val input = Messages.showInputDialog(this@ProvidersSettingsUi, ready.instructions ?: "Enter OAuth code", provider.name, null) + if (input.isNullOrBlank()) { + cancelOAuth(id) + return@withContext null + } + input + } else { + val url = ready.url + if (ready.method == "auto" && url != null) { + showOAuthDevice( + id, + provider, + DeviceOAuthInfo( + url = url, + code = oauthCode(ready.instructions), + expiresIn = (KiloProviderService.OAUTH_RPC_TIMEOUT_MS / 1000).toInt(), + started = System.currentTimeMillis(), + ), + ) + } + null + } + } + val current = withContext(edt) { active(id) } + if (!current) return@launch + withContext(edt) { + if (oauth == null) syncOAuthWaiting(id) + } + val result = service().callback(ProviderOAuthCallbackDto(directory, provider.id, method, code)) + apply(id, result.state, result.error) + }) return + showProgress( + KiloBundle.message("settings.providers.oauth.starting", provider.name), + KiloBundle.message("settings.providers.oauth.cancel"), + ) { cancelOAuth(request) } + } + + @RequiresEdt + private fun disconnect(provider: ProviderSettingsProviderDto) { + checkEdt() + if (!launch("disconnect provider=${provider.id}") { id -> + val result = service().disconnect(ProviderDisconnectDto(directory, provider.id)) + apply(id, result.state, result.error) + }) return + syncLoading() + } + + @RequiresEdt + private fun enable(provider: ProviderSettingsProviderDto) { + checkEdt() + if (!launch("enable provider=${provider.id}") { id -> + val result = service().enable(ProviderEnableDto(directory, provider.id)) + apply(id, result.state, result.error) + }) return + syncLoading() + } + + @RequiresEdt + private fun custom() { + checkEdt() + val dialog = CustomProviderDialog() + if (!dialog.showAndGet()) return + val input = dialog.input(directory) + if (!launch("save custom provider") { id -> + val result = service().saveCustom(input) + apply(id, result.state, result.error) + }) return + syncLoading() + } + + private fun toolbar(): JComponent { + add.registerCustomShortcutSet(CommonShortcuts.getNewForDialogs(), this) + ActionManager.getInstance().getAction("Refresh")?.shortcutSet?.let { refresh.registerCustomShortcutSet(it, this) } + val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(add, refresh), true) + toolbar.targetComponent = this + return toolbar.component + } + + private fun header(): JComponent { + search.textEditor.registerKeyboardAction( + { view.primary() }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.registerKeyboardAction( + { view.move(-1) }, + KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.registerKeyboardAction( + { view.move(1) }, + KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + view.filter(search.text) + } + }) + return Stack.vertical(UiStyle.Gap.sm()) + .next(toolbar()) + .next(search) + } + + @RequiresEdt + private fun launch(name: String, block: suspend (Int) -> Unit): Boolean { + checkEdt() + if (busy || disposed) return false + val id = ++request + setBusy(true) + job = cs.launch { + val start = System.currentTimeMillis() + LOG.info("provider settings ui $name: coroutine start dir=$directory") + try { + block(id) + LOG.info("provider settings ui $name: coroutine completed durationMs=${System.currentTimeMillis() - start}") + } catch (e: TimeoutCancellationException) { + LOG.warn("provider settings ui $name: coroutine timed out durationMs=${System.currentTimeMillis() - start}", e) + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + clearOAuthDevice() + clearProgress() + } + } catch (e: CancellationException) { + LOG.info("provider settings ui $name: coroutine cancelled durationMs=${System.currentTimeMillis() - start}") + throw e + } catch (e: Exception) { + LOG.warn("provider settings ui $name: coroutine failed durationMs=${System.currentTimeMillis() - start}", e) + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + clearOAuthDevice() + showError("${e::class.simpleName}: ${e.message}") + } + } + } + return true + } + + private suspend fun apply(id: Int, next: ProviderSettingsDto, error: String?) { + withContext(edt) { + if (!active(id)) return@withContext + LOG.info("provider settings ui apply: start providers=${next.providers.size} errors=${next.errors.size} message=${error != null}") + state = next + setBusy(false) + clearOAuthDevice() + view.update(next) + val text = error ?: next.errors.joinToString("; ") { it.detail ?: it.resource }.takeIf { it.isNotBlank() } + if (text != null) showError(text) else clearProgress() + LOG.info("provider settings ui apply: completed providers=${next.providers.size}") + } + } + + @RequiresEdt + private fun syncOAuthWaiting(id: Int) { + checkEdt() + if (!active(id)) return + val expiry = System.currentTimeMillis() + KiloProviderService.OAUTH_RPC_TIMEOUT_MS + fun text(): String { + val ms = (expiry - System.currentTimeMillis()).coerceAtLeast(0) + val remain = ((ms + 999) / 1000).toInt() + val min = remain / 60 + val sec = remain % 60 + return KiloBundle.message("settings.providers.oauth.waitingTimed", "$min:${sec.toString().padStart(2, '0')}") + } + stopTimer() + showProgress(text(), KiloBundle.message("settings.providers.oauth.cancel")) { cancelOAuth(id) } + timer = Timer(1000) { + if (!active(id)) { + stopTimer() + return@Timer + } + updateProgress(text()) + }.also { it.start() } + } + + @RequiresEdt + private fun cancelOAuth(id: Int) { + checkEdt() + if (!active(id)) return + request++ + job?.cancel() + job = null + stopTimer() + clearOAuthDevice() + setBusy(false) + clearProgress() + } + + @RequiresEdt + private fun showOAuthDevice(id: Int, provider: ProviderSettingsProviderDto, info: DeviceOAuthInfo) { + checkEdt() + if (!active(id)) return + clearProgress() + val panel = DeviceOAuthPanel( + DeviceOAuthText( + title = KiloBundle.message("settings.providers.oauth.starting", provider.name), + qrDescription = KiloBundle.message("profile.login.qr.description"), + ), + cancel = { cancelOAuth(id) }, + browse = { BrowserUtil.browse(it) }, + prefix = "kilo.provider.oauth", + ) + oauth?.dispose() + oauth = panel + panel.update(info) + setModalContent(panel) + } + + @RequiresEdt + private fun clearOAuthDevice() { + checkEdt() + oauth?.dispose() + oauth = null + setModalContent(null) + } + + @RequiresEdt + private fun stopTimer() { + checkEdt() + timer?.stop() + timer = null + } + + @RequiresEdt + private fun setBusy(next: Boolean) { + checkEdt() + if (busy == next) return + busy = next + if (!next) stopTimer() + search.isEnabled = !next + search.textEditor.isEnabled = !next + view.setBusy(next) + } + + @RequiresEdt + override fun dispose() { + checkEdt() + disposed = true + request++ + stopTimer() + job?.cancel() + job = null + setBusy(false) + } + + @RequiresEdt + private fun active(id: Int): Boolean { + checkEdt() + return !disposed && id == request + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Provider settings UI updates must run on EDT" } + } +} + +internal class ProvidersContent( + private val connect: (ProviderSettingsProviderDto) -> Unit, + private val oauth: (ProviderSettingsProviderDto) -> Unit, + private val disconnect: (ProviderSettingsProviderDto) -> Unit, + private val enable: (ProviderSettingsProviderDto) -> Unit, +) : BaseContentPanel() { + private val model = CollectionListModel() + private val list = JBList(model).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + emptyText.text = KiloBundle.message("settings.providers.noMatches") + } + private var state = ProviderSettingsDto() + private var filter = "" + private var busy = false + + init { + list.cellRenderer = ProviderListRenderer(model) + list.registerKeyboardAction( + { primary() }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + list.addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return + val idx = list.locationToIndex(e.point) + val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return + if (!bounds.contains(e.point)) return + val row = model.getElementAt(idx) + val action = ProviderListRenderer.actionAt(list, bounds, e.point, row, idx == list.selectedIndex) ?: return + activate(row, action) + e.consume() + } + }) + ScrollingUtil.installActions(list) + next(list) + } + + @RequiresEdt + fun update(state: ProviderSettingsDto) { + checkEdt() + val notes = state.providers.count { providerDescription(it).isNotBlank() } + ProvidersSettingsUi.LOG.info("provider settings content update: start providers=${state.providers.size} connected=${state.connected.size} disabled=${state.disabled.size} descriptions=$notes") + this.state = state + sync() + ProvidersSettingsUi.LOG.info("provider settings content update: completed rows=${model.size}") + } + + @RequiresEdt + fun setBusy(next: Boolean) { + checkEdt() + if (busy == next) return + busy = next + list.isEnabled = !next + sync() + } + + @RequiresEdt + fun filter(text: String) { + checkEdt() + if (filter == text) return + filter = text + sync() + } + + @RequiresEdt + private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null) { + checkEdt() + val rows = providerListRows(state, filter, disabledRows = busy) + model.replaceAll(rows) + val idx = at?.let { providerListIndex(rows, it) }?.takeIf { it >= 0 } + ?: providerListIndex(rows, prefer).takeIf { it >= 0 } + ?: rows.indices.firstOrNull() + ?: -1 + if (idx >= 0) choose(idx) + else list.clearSelection() + } + + @RequiresEdt + private fun choose(idx: Int) { + checkEdt() + list.selectedIndex = idx + ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + } + + @RequiresEdt + fun move(step: Int) { + checkEdt() + val size = model.size + if (size <= 0) return + val idx = ((list.selectedIndex.takeIf { it >= 0 } ?: 0) + step).coerceIn(0, size - 1) + choose(idx) + } + + @RequiresEdt + fun primary() { + checkEdt() + val row = list.selectedValue ?: return + val action = ProviderListRenderer.visibleActions(row, true).firstOrNull() ?: return + activate(row, action) + } + + @RequiresEdt + private fun activate(row: ProviderListRow, action: ProviderListAction) { + checkEdt() + if (!row.enabled(action)) return + when (action) { + ProviderListAction.CONNECT -> connect(row.provider) + ProviderListAction.OAUTH -> oauth(row.provider) + ProviderListAction.DISCONNECT -> disconnect(row.provider) + ProviderListAction.ENABLE -> enable(row.provider) + } + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Provider settings content updates must run on EDT" } + } +} + +private class ProviderToolbarAction( + text: String, + description: String, + icon: Icon, + private val enabled: () -> Boolean, + private val action: () -> Unit, +) : DumbAwareAction(text, description, icon) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + if (!enabled()) return + action() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = enabled() + } +} + +private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : DialogWrapper(true) { + private val key = JBPasswordField().apply { columns = 50 } + private val fields = method?.prompts.orEmpty().associateWith { prompt -> + if (prompt.options.isNotEmpty()) optionBox(prompt.options) as JComponent else JBTextField() + } + + init { + this.title = title + init() + initValidation() + } + + @RequiresEdt + fun key(): String = String(key.password) + + @RequiresEdt + fun metadata(): Map = fields.mapValues { (_, field) -> + when (field) { + is JComboBox<*> -> (field.selectedItem as? ProviderAuthOptionDto)?.value ?: field.selectedItem?.toString().orEmpty() + is JBTextField -> field.text + else -> "" + } + }.mapKeys { it.key.key }.filterValues { it.isNotBlank() } + + override fun createCenterPanel(): JComponent { + val panel = Stack.vertical(UiStyle.Gap.sm()) + panel.next(JBLabel(KiloBundle.message("settings.providers.apiKey"))) + panel.next(key) + fields.forEach { (prompt, field) -> + panel.next(JBLabel(prompt.label)) + panel.next(field) + } + return panel + } + + override fun doValidate(): ValidationInfo? { + if (key().isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.apiKeyRequired"), key) + return null + } + + private fun optionBox(options: List): JComboBox { + val box = JComboBox(options.toTypedArray()) + box.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent(list: JList<*>?, value: Any?, index: Int, selected: Boolean, focus: Boolean): java.awt.Component { + val item = value as? ProviderAuthOptionDto + return super.getListCellRendererComponent(list, item?.label.orEmpty(), index, selected, focus) + } + } + return box + } +} + +private class CustomProviderDialog : DialogWrapper(true) { + private val id = JBTextField() + private val name = JBTextField() + private val url = JBTextField() + private val key = JBPasswordField().apply { columns = 50 } + private val env = JBTextField() + private val models = JBTextField() + + init { + title = KiloBundle.message("settings.providers.customTitle") + init() + initValidation() + } + + @RequiresEdt + fun input(directory: String) = CustomProviderSaveDto( + directory = directory, + id = id.text.trim(), + name = name.text.trim(), + baseUrl = url.text.trim(), + apiKey = String(key.password).takeIf { it.isNotBlank() }, + envVar = env.text.trim().takeIf { it.isNotBlank() }, + models = models.text.split(',').mapNotNull { raw -> + raw.trim().takeIf { it.isNotBlank() }?.let { CustomModelDto(it, it) } + }, + ) + + override fun createCenterPanel(): JComponent { + val panel = Stack.vertical(UiStyle.Gap.sm()) + listOf( + KiloBundle.message("settings.providers.customId") to id, + KiloBundle.message("settings.providers.customName") to name, + KiloBundle.message("settings.providers.customUrl") to url, + KiloBundle.message("settings.providers.apiKey") to key, + KiloBundle.message("settings.providers.customEnv") to env, + KiloBundle.message("settings.providers.customModels") to models, + ).forEach { (label, field) -> + panel.next(JBLabel(label)) + panel.next(field) + } + return panel + } + + override fun doValidate(): ValidationInfo? { + if (id.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.customIdRequired"), id) + if (url.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.providers.customUrlRequired"), url) + return null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/telemetry/KiloTelemetryService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/telemetry/KiloTelemetryService.kt new file mode 100644 index 00000000000..5346fcface1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/telemetry/KiloTelemetryService.kt @@ -0,0 +1,74 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.telemetry + +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.TelemetryCaptureDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +object Telemetry { + fun send(event: String, properties: Map = emptyMap()) { + KiloTelemetryService.getInstance().send(event, properties) + } +} + +@Service(Service.Level.APP) +class KiloTelemetryService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloAppRpcApi?, +) { + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = KiloLog.create(KiloTelemetryService::class.java) + private const val MAX_PENDING = 64 + private const val TIMEOUT_MS = 5_000L + + fun getInstance(): KiloTelemetryService = service() + } + + private val pending = AtomicInteger() + private val warned = AtomicBoolean() + + fun send(event: String, properties: Map = emptyMap()) { + if (pending.incrementAndGet() > MAX_PENDING) { + pending.decrementAndGet() + if (warned.compareAndSet(false, true)) { + LOG.warn("telemetry backpressure: dropping events with more than $MAX_PENDING pending") + } + return + } + cs.launch { + try { + if (KiloLog.sandbox()) { + val payload = KiloLog.payload(LOG) + properties + LOG.info("event=$event ${payload.entries.joinToString(" ") { "${it.key}=${it.value}" }}") + return@launch + } + val dto = TelemetryCaptureDto(event, properties) + val sent = withTimeoutOrNull(TIMEOUT_MS) { + val api = rpc + if (api != null) api.captureTelemetry(dto) + else durable { KiloAppRpcApi.getInstance().captureTelemetry(dto) } + true + } + if (sent != true) LOG.warn("telemetry capture timed out: $event") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("telemetry capture failed: ${e.message}", e) + } finally { + pending.decrementAndGet() + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt index 7c740167225..8987154fa24 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -4,6 +4,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Component +import java.awt.Font import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints @@ -11,12 +12,12 @@ import java.awt.font.FontRenderContext import javax.swing.Icon internal class FilledBadgeIcon( - private val text: String, + internal val text: String, private val bg: Color, private val fg: Color, + private val font: Font = JBFont.small(), ) : Icon { override fun getIconWidth(): Int { - val font = JBFont.small() val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() return width + UiStyle.Gap.lg() * 2 } @@ -31,7 +32,7 @@ internal class FilledBadgeIcon( g2.color = bg g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight) g2.color = fg - g2.font = JBFont.small() + g2.font = font val fm = g2.fontMetrics val base = (iconHeight + fm.ascent - fm.descent) / 2 g2.drawString(text, UiStyle.Gap.lg(), base) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index 70f8b27af8e..8a2c945b93d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -9,7 +9,7 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JButton -class HoverIcon : JButton() { +class HoverIcon(private val fill: Boolean = false) : JButton() { private var over = false init { @@ -32,7 +32,7 @@ class HoverIcon : JButton() { override fun getMaximumSize(): Dimension = preferredSize override fun paintComponent(g: Graphics) { - if (isEnabled && over) paintHover(g) + if (isEnabled && (over || fill)) paintHover(g) super.paintComponent(g) } @@ -40,9 +40,19 @@ class HoverIcon : JButton() { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val base = UiStyle.Colors.bg() + val hover = UiStyle.Colors.actionHoverBackground() + g2.color = when { + over && fill -> UiStyle.Colors.blend(base, hover, hover.alpha / 255f) + over -> hover + else -> base + } val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) g2.fillRoundRect(0, 0, width, height, arc, arc) + if (fill) { + g2.color = UiStyle.Colors.contentBorder() + g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) + } } finally { g2.dispose() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt new file mode 100644 index 00000000000..1ff400346b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -0,0 +1,158 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Container +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.JPanel + +open class LayeredOverlayPanel( + content: JPanel = BorderLayoutPanel(), + overlay: Overlay = Overlay(), + blocker: Blocker = Blocker(), +) : JLayeredPane() { + + private val baseContent = content + + private val baseOverlay = overlay + + private val baseBlocker = blocker + + open val content: JPanel get() = baseContent + + open val overlay: Overlay get() = baseOverlay + + open val blocker: Blocker get() = baseBlocker + + init { + layout = null + add(baseContent) + setLayer(baseContent, DEFAULT_LAYER) + add(baseOverlay) + setLayer(baseOverlay, PALETTE_LAYER) + add(baseBlocker) + setLayer(baseBlocker, MODAL_LAYER) + baseBlocker.isVisible = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, bounds) + } + + @RequiresEdt + fun setModalContent(child: JComponent?) { + blocker.removeAll() + if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) + blocker.isVisible = child != null + if (child != null) blocker.requestFocusInWindow() + invalidate() + blocker.invalidate() + child?.invalidate() + if (width > 0 && height > 0) { + doLayout() + child?.let(::layoutTree) + } + blocker.revalidate() + blocker.repaint() + revalidate() + repaint() + } + + @RequiresEdt + fun setBlocked(value: Boolean) { + blocker.isVisible = value + if (value) blocker.requestFocusInWindow() + invalidate() + blocker.invalidate() + if (width > 0 && height > 0) doLayout() + revalidate() + repaint() + } + + override fun doLayout() { + components + .sortedBy { getLayer(it) } + .forEach { child -> + child.setBounds(0, 0, width, height) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 + val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 + return JBDimension(w, h) + } + + open class Overlay : BorderLayoutPanel() { + + private val items = linkedMapOf Rectangle>() + + init { + layout = null + isOpaque = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + items[child] = bounds + add(child) + } + + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true + } + return false + } + + override fun doLayout() { + items.forEach { (child, bounds) -> + child.bounds = bounds(this, child) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val pref = super.getPreferredSize() + val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) + val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) + return JBDimension(w, h) + } + } + + open class Blocker : JPanel() { + init { + layout = BorderLayout() + isFocusable = true + } + + override fun updateUI() { + super.updateUI() + background = UiStyle.Colors.bg() + isOpaque = true + } + + override fun contains(x: Int, y: Int): Boolean { + if (!isVisible) return false + return super.contains(x, y) + } + + override fun doLayout() { + super.doLayout() + components.forEach { layoutTree(it) } + } + } +} + +private fun layoutTree(comp: java.awt.Component) { + comp.doLayout() + if (comp is Container) comp.components.forEach { layoutTree(it) } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt index ae1d5dde543..3ed326d2e7b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt @@ -51,9 +51,9 @@ open class RoundedContentPanel( super.paintComponent(g) } - protected open fun contentColor(): Color = UiStyle.Colors.cardBg() + protected open fun contentColor(): Color = UiStyle.Colors.contentBackground() - protected open fun outlineColor(): Color? = UiStyle.Colors.cardBorder() + protected open fun outlineColor(): Color? = UiStyle.Colors.contentBorder() protected open fun outlineWidth(): Int = JBUI.scale(1) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt deleted file mode 100644 index f51a7d9b880..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/SvgIconColorizer.kt +++ /dev/null @@ -1,46 +0,0 @@ -package ai.kilocode.client.ui - -import com.intellij.ui.icons.CachedImageIcon -import com.intellij.ui.svg.SvgAttributePatcher -import com.intellij.util.SVGLoader -import java.awt.Color -import javax.swing.Icon - -private const val OPAQUE_ALPHA = 255 - -internal fun Icon.colorizeIfPossible( - fillColor: Color, - borderColor: Color = fillColor, - fillId: String? = null, - strokeId: String? = null, -): Icon = (this as? CachedImageIcon)?.createWithPatcher( - colorPatcher = object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { - private val digest = longArrayOf(0L, 440413911775177385) - - override fun digest(): LongArray { - digest[0] = toLong(fillColor.rgb, borderColor.rgb) - return digest - } - - override fun patchColors(attributes: MutableMap) { - val id = attributes["id"] - if (fillId == null || id == fillId) setAttribute(attributes, "fill", fillColor) - if (strokeId == null || id == strokeId) setAttribute(attributes, "stroke", borderColor) - } - - override fun attributeForPath(path: String) = this - - private fun setAttribute(attributes: MutableMap, key: String, color: Color) { - if (!attributes.containsKey(key) || attributes[key] == "none") return - attributes[key] = "rgb(${color.red},${color.green},${color.blue})" - val alpha = color.alpha - if (alpha != OPAQUE_ALPHA) { - attributes["$key-opacity"] = "${alpha / OPAQUE_ALPHA.toFloat()}" - } - } - - private fun toLong(high: Int, low: Int): Long { - return (high.toLong() shl 32) or (low.toLong() and 0xFFFFFFFFL) - } - }, -) ?: this diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 156d0760338..43494feb4fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -6,6 +6,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color +import javax.swing.AbstractButton import javax.swing.JComponent import javax.swing.UIManager @@ -33,6 +34,20 @@ object UiStyle { fun component() = com.intellij.util.ui.JBValue.UIInteger("Component.arc", 8).get() } + /** Platform balloon styling used by lightweight contextual overlays. */ + object Balloon { + fun bg(): Color = UIUtil.getPanelBackground() + + fun border(): Color = JBUI.CurrentTheme.Popup.borderColor(true) + + /** New UI parameter-info balloon insets: symmetric vertical padding with wider sides. */ + fun insets() = JBUI.insets(6, 12, 6, 12) + + fun pointer() = JBUI.size(16, 8) + + fun arc() = JBUI.scale(8) + } + /** Theme-aware colors and color math used by multiple UI surfaces. */ object Colors { fun bg(): Color = UIUtil.getPanelBackground() @@ -45,11 +60,10 @@ object UiStyle { fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } /** - * Card surface background: follows the active theme's text-field/input surface. - * Uses [UIUtil.getTextFieldBackground] as the semantic platform surface color for - * contained panels. Falls back to the panel background when unavailable. + * Contained panel background: follows the active theme's text-field/input surface. + * Falls back to the panel background when unavailable. */ - fun cardBg(): Color = JBColor.lazy { + fun contentBackground(): Color = JBColor.lazy { UIManager.getColor("TextField.background") ?: UIUtil.getPanelBackground() } @@ -64,14 +78,34 @@ object UiStyle { fun badgeBg(): Color = JBColor.lazy { UIManager.getColor("Badge.background") ?: UIManager.getColor("Label.infoBackground") - ?: blend(cardBg(), fg(), 0.16f) + ?: blend(contentBackground(), fg(), 0.16f) } /** Filled badge text color paired with [badgeBg]. */ fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground()) - /** Card border color shared across profile cards. */ - fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border()) + fun runningBadgeBg(): Color = JBColor.namedColor( + "Kilo.History.runningBadgeBackground", + JBColor(0xF5C542, 0x7A5A00), + ) + + fun runningBadgeFg(): Color = JBColor.namedColor( + "Kilo.History.runningBadgeForeground", + JBColor(Color.BLACK, Color.WHITE), + ) + + fun activityBadgeBg(): Color = JBColor.namedColor( + "Kilo.History.activityBadgeBackground", + JBUI.CurrentTheme.Link.Foreground.ENABLED, + ) + + fun activityBadgeFg(): Color = JBColor.namedColor( + "Kilo.History.activityBadgeForeground", + Color.WHITE, + ) + + /** Border color shared across contained panels. */ + fun contentBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border()) /** * Floating panel background: white in light themes, black in dark themes. @@ -91,6 +125,20 @@ object UiStyle { ?: UIUtil.getContextHelpForeground() } + fun infoOverlayBackground(): Color = JBUI.CurrentTheme.NotificationInfo.backgroundColor() + + fun infoOverlayForeground(): Color = JBUI.CurrentTheme.NotificationInfo.foregroundColor() + + fun infoOverlayBorder(): Color = JBUI.CurrentTheme.NotificationInfo.borderColor() + + fun actionHoverBackground(): Color = JBUI.CurrentTheme.ActionButton.hoverBackground() + + fun errorOverlayBackground(): Color = JBUI.CurrentTheme.NotificationError.backgroundColor() + + fun errorOverlayForeground(): Color = JBUI.CurrentTheme.NotificationError.foregroundColor() + + fun errorOverlayBorder(): Color = JBUI.CurrentTheme.NotificationError.borderColor() + internal fun contrast(base: Color, delta: Int): Color { val step = if (bright(base)) -delta else delta return Color( @@ -152,5 +200,35 @@ object UiStyle { fun transparent(vararg components: JComponent) { components.forEach { it.isOpaque = false } } + + fun actionForeground(enabled: Boolean): Color = if (enabled) { + UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground() + } else { + UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground() + } + + fun actionBackground(): Color = UIManager.getColor("Button.background") ?: UIUtil.getPanelBackground() + + fun actionBorder() = JBUI.Borders.compound( + JBUI.Borders.customLine(UIUtil.getBoundsColor()), + JBUI.Borders.empty(Gap.sm(), Gap.pad()), + ) + + fun actionLabel(component: JComponent, enabled: Boolean = component.isEnabled) { + component.foreground = actionForeground(enabled) + component.background = actionBackground() + component.border = actionBorder() + component.isOpaque = true + } + + fun actionButton(button: AbstractButton) { + button.foreground = actionForeground(button.isEnabled) + button.background = actionBackground() + button.border = actionBorder() + button.isOpaque = true + button.isBorderPainted = true + button.isContentAreaFilled = false + button.isFocusPainted = false + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt index e811fc8a7f8..e65a2b10408 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Align.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.ui.layout import java.awt.Component +import java.awt.Container import java.awt.Dimension +import java.awt.LayoutManager2 import javax.swing.JPanel enum class HAlign { TRACK, FIT, LEFT, CENTER, RIGHT } @@ -20,6 +22,11 @@ enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM } * child uses its bounded preferred size (coerced into [min, max]) and is placed at the * corresponding edge or centered. Shrinks to available space when necessary. * + * During layout, the child is first sized on TRACK/FIT axes before preferred size + * is read. This mirrors Swing layouts such as [java.awt.BorderLayout] where the + * parent axis is constrained, while preserving ordinary preferred-size behavior + * for edge/center axes. + * * Wrapper min/preferred/max sizes are computed by combining the per-axis child contribution * (zero for TRACK axes) with the panel insets. * @@ -33,61 +40,96 @@ enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM } */ class Align( child: Component, - private val h: HAlign = HAlign.FIT, - private val v: VAlign = VAlign.FIT, -) : JPanel(null) { + h: HAlign = HAlign.FIT, + v: VAlign = VAlign.FIT, +) : JPanel(Layout(h, v)) { init { isOpaque = false add(child) } - // ----------------------------------------------------------------------- - // Layout - // ----------------------------------------------------------------------- - - override fun doLayout() { - if (componentCount == 0) return - val child = getComponent(0) - val ins = insets - val availW = maxOf(0, width - ins.left - ins.right) - val availH = maxOf(0, height - ins.top - ins.bottom) - - val (w, cx) = placeAxis(h, availW, child.minimumSize.width, child.preferredSize.width, child.maximumSize.width) - val (ht, cy) = placeAxis(v, availH, child.minimumSize.height, child.preferredSize.height, child.maximumSize.height) + private class Layout( + private val h: HAlign, + private val v: VAlign, + ) : LayoutManager2 { + + override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit + override fun addLayoutComponent(name: String?, comp: Component) = Unit + override fun removeLayoutComponent(comp: Component) = Unit + + override fun layoutContainer(parent: Container) { + if (parent.componentCount == 0) return + val child = parent.getComponent(0) + val ins = parent.insets + val availW = maxOf(0, parent.width - ins.left - ins.right) + val availH = maxOf(0, parent.height - ins.top - ins.bottom) + + val min = child.minimumSize + val max = child.maximumSize + if (probes(h) || probes(v)) { + child.setSize( + if (probes(h)) probe(h, availW, min.width, max.width) else child.width, + if (probes(v)) probe(v, availH, min.height, max.height) else child.height, + ) + } + val pref = child.preferredSize + + val (w, cx) = place(h, availW, min.width, pref.width, max.width) + val (ht, cy) = place(v, availH, min.height, pref.height, max.height) + + child.setBounds(ins.left + cx, ins.top + cy, w, ht) + } - child.setBounds(ins.left + cx, ins.top + cy, w, ht) - } + override fun minimumLayoutSize(parent: Container): Dimension { + if (parent.componentCount == 0) return Dimension(0, 0) + val child = parent.getComponent(0) + val ins = parent.insets + val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width + val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height + return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) + } - // ----------------------------------------------------------------------- - // Wrapper size negotiation - // ----------------------------------------------------------------------- - - override fun getMinimumSize(): Dimension { - if (componentCount == 0) return super.getMinimumSize() - val child = getComponent(0) - val ins = insets - val cw = if (h == HAlign.TRACK) 0 else child.minimumSize.width - val ch = if (v == VAlign.TRACK) 0 else child.minimumSize.height - return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) - } + override fun preferredLayoutSize(parent: Container): Dimension { + if (parent.componentCount == 0) return Dimension(0, 0) + val child = parent.getComponent(0) + val ins = parent.insets + val min = child.minimumSize + val max = child.maximumSize + val availW = maxOf(0, parent.width - ins.left - ins.right) + val availH = maxOf(0, parent.height - ins.top - ins.bottom) + if ((availW > 0 && probes(h)) || (availH > 0 && probes(v))) { + child.setSize( + if (availW > 0 && probes(h)) probe(h, availW, min.width, max.width) else child.width, + if (availH > 0 && probes(v)) probe(v, availH, min.height, max.height) else child.height, + ) + } + val pref = child.preferredSize + val cw = if (h == HAlign.TRACK) 0 else bounded(pref.width, min.width, max.width) + val ch = if (v == VAlign.TRACK) 0 else bounded(pref.height, min.height, max.height) + return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) + } - override fun getPreferredSize(): Dimension { - if (componentCount == 0) return super.getPreferredSize() - val child = getComponent(0) - val ins = insets - val cw = if (h == HAlign.TRACK) 0 else bounded(child.preferredSize.width, child.minimumSize.width, child.maximumSize.width) - val ch = if (v == VAlign.TRACK) 0 else bounded(child.preferredSize.height, child.minimumSize.height, child.maximumSize.height) - return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom) - } + override fun maximumLayoutSize(target: Container): Dimension { + if (target.componentCount == 0) return Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + val child = target.getComponent(0) + val ins = target.insets + val cw = if (h == HAlign.TRACK) { + Int.MAX_VALUE + } else { + maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right + } + val ch = if (v == VAlign.TRACK) { + Int.MAX_VALUE + } else { + maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom + } + return Dimension(cw, ch) + } - override fun getMaximumSize(): Dimension { - if (componentCount == 0) return super.getMaximumSize() - val child = getComponent(0) - val ins = insets - val cw = if (h == HAlign.TRACK) super.getMaximumSize().width else maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right - val ch = if (v == VAlign.TRACK) super.getMaximumSize().height else maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom - return Dimension(cw, ch) + override fun getLayoutAlignmentX(target: Container) = 0.5f + override fun getLayoutAlignmentY(target: Container) = 0.5f + override fun invalidateLayout(target: Container) = Unit } } @@ -101,7 +143,7 @@ class Align( * - FIT: size = clamp(avail, min, max), offset = 0 * - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment */ -private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair { +private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair { val effMax = maxOf(min, max) return when (mode) { HAlign.TRACK, VAlign.TRACK -> avail to 0 @@ -128,6 +170,13 @@ private fun placeAxis(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pai private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max)) +private fun probes(mode: Any) = mode == HAlign.TRACK || mode == HAlign.FIT || mode == VAlign.TRACK || mode == VAlign.FIT + +private fun probe(mode: Any, avail: Int, min: Int, max: Int): Int { + if (mode == HAlign.FIT || mode == VAlign.FIT) return minOf(avail, maxOf(min, max)) + return avail +} + // --------------------------------------------------------------------------- // Factory extension // --------------------------------------------------------------------------- diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt new file mode 100644 index 00000000000..e043225c3ce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt @@ -0,0 +1,301 @@ +package ai.kilocode.client.ui.layout + +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.awt.LayoutManager2 +import javax.swing.JComponent +import javax.swing.JPanel + +enum class StackAxis { VERTICAL, HORIZONTAL } + +/** + * A transparent one-dimensional layout panel for rows and columns. + * + * Vertical stacks make every child track the available width while preserving + * each child's bounded preferred height. Horizontal stacks do the opposite. + * Children are probed with the known cross-axis size before preferred size is + * read, so wrapping components can report the preferred size for that width or + * height. + */ +open class Stack( + private val axis: StackAxis, + private val space: Int = 0, +) : JPanel(Layout(axis, space)) { + + init { + isOpaque = false + } + + fun next(child: Component): Stack { + add(child) + return this + } + + fun gap(size: Int = space): Stack { + mgr.gap(size) + revalidate() + return this + } + + fun fill(size: Int): Stack { + add(filler(axis, size)) + return this + } + + override fun removeAll() { + mgr.clear() + super.removeAll() + } + + private val mgr: Layout + get() = getLayout() as Layout + + internal fun fit(): Stack { + mgr.fit = true + revalidate() + return this + } + + private class Layout( + private val axis: StackAxis, + private val gap: Int, + ) : LayoutManager2 { + + var fit = false + + private val entries = mutableListOf() + + fun gap(size: Int) { + entries.add(Entry.Gap(size)) + } + + fun clear() { + entries.clear() + } + + override fun addLayoutComponent(comp: Component, constraints: Any?) { + entries.removeAll { it is Entry.Child && it.comp == comp } + entries.add(Entry.Child(comp)) + } + + override fun addLayoutComponent(name: String?, comp: Component) { + addLayoutComponent(comp, null) + } + + override fun removeLayoutComponent(comp: Component) { + entries.removeAll { it is Entry.Child && it.comp == comp } + } + + override fun layoutContainer(parent: Container) { + val ins = parent.insets + val w = maxOf(0, parent.width - ins.left - ins.right) + val h = maxOf(0, parent.height - ins.top - ins.bottom) + if (axis == StackAxis.HORIZONTAL && fit) { + fit(parent, ins.left, ins.top, w, h) + return + } + var x = ins.left + var y = ins.top + var seen = false + var ready = false + var pending: Int? = null + + for (entry in entries) { + when (entry) { + is Entry.Gap -> { + if (ready) pending = safe(pending ?: 0, entry.size) + } + is Entry.Child -> { + val space = pending + pending = null + ready = false + if (entry.comp.isVisible) { + if (seen) { + val gap = space ?: gap + if (axis == StackAxis.VERTICAL) y += gap else x += gap + } + seen = true + ready = true + if (axis == StackAxis.VERTICAL) { + entry.comp.setSize(w, entry.comp.height.coerceAtLeast(1)) + } else { + entry.comp.setSize(entry.comp.width.coerceAtLeast(1), h) + } + val pref = entry.comp.preferredSize + val min = entry.comp.minimumSize + val max = entry.comp.maximumSize + val cw = if (axis == StackAxis.VERTICAL) { + w + } else { + bound(pref.width, min.width, max.width) + } + val ch = if (axis == StackAxis.HORIZONTAL) { + h + } else { + bound(pref.height, min.height, max.height) + } + entry.comp.setBounds(x, y, cw, ch) + if (axis == StackAxis.VERTICAL) y += ch else x += cw + } + } + } + } + } + + private fun fit(parent: Container, left: Int, top: Int, w: Int, h: Int) { + val items = children(parent, h) + var x = left + var rest = w + items.forEach { item -> + val gap = minOf(item.gap, rest) + x += gap + rest -= gap + val width = minOf(item.width, rest) + item.comp.setBounds(x, top, width, h) + x += width + rest -= width + } + } + + private fun children(parent: Container, h: Int): List { + val items = mutableListOf() + var seen = false + var ready = false + var pending: Int? = null + for (entry in entries) { + when (entry) { + is Entry.Gap -> if (ready) pending = safe(pending ?: 0, entry.size) + is Entry.Child -> { + val space = pending + pending = null + ready = false + if (entry.comp.isVisible) { + entry.comp.setSize(entry.comp.width.coerceAtLeast(1), h) + val pref = entry.comp.preferredSize + val min = entry.comp.minimumSize + val max = entry.comp.maximumSize + items.add(Item(entry.comp, if (seen) space ?: gap else 0, bound(pref.width, min.width, max.width))) + seen = true + ready = true + } + } + } + } + return items + } + + override fun minimumLayoutSize(parent: Container) = size(parent, Size.MIN) + override fun preferredLayoutSize(parent: Container) = size(parent, Size.PREF) + override fun maximumLayoutSize(target: Container) = size(target, Size.MAX) + override fun getLayoutAlignmentX(target: Container) = 0.5f + override fun getLayoutAlignmentY(target: Container) = 0.5f + override fun invalidateLayout(target: Container) = Unit + + private fun size(parent: Container, kind: Size): Dimension { + val ins = parent.insets + var main = 0 + var cross = 0 + var seen = false + var ready = false + var pending: Int? = null + + for (entry in entries) { + when (entry) { + is Entry.Gap -> { + if (ready) pending = safe(pending ?: 0, entry.size) + } + is Entry.Child -> { + val space = pending + pending = null + ready = false + if (entry.comp.isVisible) { + if (seen) main = safe(main, space ?: gap) + seen = true + ready = true + val dim = dim(entry.comp, kind, cross(parent)) + main = safe(main, if (axis == StackAxis.VERTICAL) dim.height else dim.width) + cross = maxOf(cross, if (axis == StackAxis.VERTICAL) dim.width else dim.height) + } + } + } + } + + val w = if (axis == StackAxis.VERTICAL) cross else main + val h = if (axis == StackAxis.VERTICAL) main else cross + return Dimension(safe(w, ins.left + ins.right), safe(h, ins.top + ins.bottom)) + } + + private fun dim(comp: Component, kind: Size, cross: Int): Dimension { + if (kind == Size.MIN) return comp.minimumSize + val min = comp.minimumSize + if (kind == Size.MAX) { + val max = comp.maximumSize + return Dimension(maxOf(min.width, max.width), maxOf(min.height, max.height)) + } + if (cross > 0) { + if (axis == StackAxis.VERTICAL) { + comp.setSize(cross, comp.height.coerceAtLeast(1)) + } else { + comp.setSize(comp.width.coerceAtLeast(1), cross) + } + } + val pref = comp.preferredSize + val max = comp.maximumSize + return Dimension( + bound(pref.width, min.width, max.width), + bound(pref.height, min.height, max.height), + ) + } + + private fun cross(parent: Container): Int { + val ins = parent.insets + if (axis == StackAxis.VERTICAL) return maxOf(0, parent.width - ins.left - ins.right) + return maxOf(0, parent.height - ins.top - ins.bottom) + } + + private sealed interface Entry { + data class Child(val comp: Component) : Entry + data class Gap(val size: Int) : Entry + } + + private data class Item(val comp: Component, val gap: Int, val width: Int) + } + + private enum class Size { MIN, PREF, MAX } + + companion object { + fun vertical(gap: Int = 0) = Stack(StackAxis.VERTICAL, gap) + fun horizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap) + fun fitHorizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap).fit() + fun verticalFiller(size: Int): Component = filler(StackAxis.VERTICAL, size) + fun horizontalFiller(size: Int): Component = filler(StackAxis.HORIZONTAL, size) + } +} + +private fun filler(axis: StackAxis, size: Int) = object : JComponent() { + init { + isOpaque = false + } + + override fun getMinimumSize() = dim() + override fun getPreferredSize() = dim() + override fun getMaximumSize(): Dimension { + if (axis == StackAxis.VERTICAL) return Dimension(Int.MAX_VALUE, size) + return Dimension(size, Int.MAX_VALUE) + } + + private fun dim(): Dimension { + if (axis == StackAxis.VERTICAL) return Dimension(0, size) + return Dimension(size, 0) + } +} + +private fun bound(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max)) + +private fun safe(a: Int, b: Int): Int { + val sum = a.toLong() + b.toLong() + if (sum > Int.MAX_VALUE) return Int.MAX_VALUE + if (sum < 0) return 0 + return sum.toInt() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt new file mode 100644 index 00000000000..7230ff2eda5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -0,0 +1,128 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.ColorKey +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Color + +internal object MdCommon { + val tags = listOf( + "body", "p", "div", "span", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td", + "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "a", "tt", "code", "samp", "pre", + ) + + fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) + + fun css(text: String): String = text + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", " ") + .replace("\r", " ") + + fun rules(opts: MdStyle): String { + val rules = StringBuilder() + + val text = mutableListOf() + text.add("color: ${hex(opts.foreground)}") + text.add("font-family: '${css(opts.font.name)}', sans-serif") + text.add("font-size: ${opts.font.size}pt") + if (opts.font.isItalic) text.add("font-style: italic") + if (opts.font.isBold) text.add("font-weight: bold") + val rule = text.joinToString("; ") + for (tag in tags) rules.append("$tag { $rule } ") + + val body = mutableListOf() + if (!opts.opaque) body.add("background: transparent") + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + rules.append("h1, h2, h3, h4, h5, h6 { color: ${hex(opts.headingFg)} } ") + rules.append("strong, b { color: ${hex(opts.strongFg)} } ") + rules.append("em, i { color: ${hex(opts.emphasisFg)} } ") + rules.append("a { color: ${hex(opts.linkColor)} } ") + rules.append("ul, ol { color: ${hex(opts.listMarkerFg)} } ") + rules.append("li { color: ${hex(opts.foreground)} } ") + rules.append("tt, code, samp, pre { font-family: '${css(opts.codeFont)}', monospace } ") + rules.append("code { background: ${hex(opts.codeBg)}; color: ${hex(opts.inlineCodeFg)} } ") + rules.append("pre { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)}; border-color: ${hex(opts.codeBorder)} } ") + rules.append("pre code { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)} } ") + rules.append("blockquote { border-left-color: ${hex(opts.quoteBorder)}; color: ${hex(opts.quoteFg)} } ") + rules.append("blockquote p { color: ${hex(opts.quoteFg)} } ") + rules.append("th, td { border-color: ${hex(opts.tableBorder)} } ") + rules.append("th { color: ${hex(opts.tableHeaderFg)} } ") + rules.append("hr { border-color: ${hex(opts.hrColor)} } ") + + return rules.toString().trim() + } + + fun defaults(style: SessionEditorStyle): MdStyle { + val weak = fg(style, DefaultLanguageHighlighterColors.DOC_COMMENT) + ?: fg(style, DefaultLanguageHighlighterColors.LINE_COMMENT) + ?: UIUtil.getContextHelpForeground() + val border = color(style, EditorColors.PREVIEW_BORDER_COLOR) ?: UiStyle.Colors.contentBorder() + val blockBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorBackground + return MdStyle( + font = style.transcriptFont, + foreground = style.editorForeground, + background = style.editorBackground, + linkColor = fg(style, CodeInsightColors.HYPERLINK_ATTRIBUTES) ?: JBUI.CurrentTheme.Link.Foreground.ENABLED, + codeBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) + ?: bg(style, DefaultLanguageHighlighterColors.STRING) + ?: style.editorBackground, + preBg = blockBg, + preFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorForeground, + codeFont = style.editorFamily, + quoteBorder = border, + quoteFg = weak, + tableBorder = border, + headingFg = fg(style, CodeInsightColors.HYPERLINK_ATTRIBUTES) ?: style.editorForeground, + strongFg = fg(style, HighlighterColors.TEXT) ?: style.editorForeground, + emphasisFg = weak, + inlineCodeFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) + ?: fg(style, DefaultLanguageHighlighterColors.STRING) + ?: style.editorForeground, + listMarkerFg = weak, + hrColor = border, + tableHeaderFg = fg(style, HighlighterColors.TEXT) ?: style.editorForeground, + codeBorder = border, + opaque = true, + ) + } + + private fun fg(style: SessionEditorStyle, key: TextAttributesKey): Color? = + style.editorScheme.getAttributes(key)?.foregroundColor + + private fun bg(style: SessionEditorStyle, key: TextAttributesKey): Color? = + style.editorScheme.getAttributes(key)?.backgroundColor + + private fun color(style: SessionEditorStyle, key: ColorKey): Color? = style.editorScheme.getColor(key) +} + +internal data class MdStyle( + val font: java.awt.Font, + val foreground: Color, + val background: Color, + val linkColor: Color, + val codeBg: Color, + val preBg: Color, + val preFg: Color, + val codeFont: String, + val quoteBorder: Color, + val quoteFg: Color, + val tableBorder: Color, + val headingFg: Color, + val strongFg: Color, + val emphasisFg: Color, + val inlineCodeFg: Color, + val listMarkerFg: Color, + val hrColor: Color, + val tableHeaderFg: Color, + val codeBorder: Color, + val opaque: Boolean, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 1135022c543..2ff6770974e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,62 +1,38 @@ package ai.kilocode.client.ui.md -import ai.kilocode.client.ui.UiStyle -import ai.kilocode.log.KiloLog -import com.intellij.ui.components.JBHtmlPane -import com.intellij.ui.components.JBHtmlPaneConfiguration -import com.intellij.ui.components.JBHtmlPaneStyleConfiguration -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import org.commonmark.ext.autolink.AutolinkExtension -import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection +import com.intellij.openapi.Disposable import java.awt.Color import java.awt.Font import java.awt.Point import javax.swing.JComponent -import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.StyleSheet -/** - * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. - * - * By default, font and colors are derived from the global editor colour scheme. - * All style properties are optional overrides on top of those defaults. - * Call [resetStyles] to revert to editor defaults after overriding. - * - * Create instances via [MdView.html]. All public methods must be called on the EDT. - */ -@Suppress("UnstableApiUsage") -abstract class MdView private constructor() { - - abstract val component: JComponent - abstract fun set(text: String) - abstract fun append(delta: String) - abstract fun clear() - /** Revert all style overrides to editor-derived defaults. */ - abstract fun resetStyles() - abstract fun addLinkListener(listener: LinkListener) - abstract fun removeLinkListener(listener: LinkListener) - - abstract var font: Font - abstract var foreground: Color - abstract var background: Color - abstract var linkColor: Color - abstract var codeBg: Color - abstract var preBg: Color - abstract var preFg: Color - abstract var codeFont: String - abstract var quoteBorder: Color - abstract var quoteFg: Color - abstract var tableBorder: Color - - /** - * When `false`, the component is transparent — the parent's background shows through - * and no background is forced in the CSS body rule. - */ - abstract var opaque: Boolean +/** Markdown rendering component. All public methods must be called on the EDT. */ +interface MdView : Disposable { + val component: JComponent + + fun set(text: String) + fun append(delta: String) + fun clear() + fun applyStyle(style: SessionEditorStyle) + fun setSelection(selection: SessionSelection?) + fun resetStyles() + fun addLinkListener(listener: LinkListener) + fun removeLinkListener(listener: LinkListener) + + var font: Font + var foreground: Color + var background: Color + var linkColor: Color + var codeBg: Color + var preBg: Color + var preFg: Color + var codeFont: String + var quoteBorder: Color + var quoteFg: Color + var tableBorder: Color + var opaque: Boolean data class LinkEvent( val href: String, @@ -67,307 +43,8 @@ abstract class MdView private constructor() { fun onLink(event: LinkEvent) } - internal abstract fun markdown(): String - internal abstract fun html(): String - /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ - internal abstract fun overrideSheet(): String - internal abstract fun simulateLink(href: String) - - companion object { - fun html(): MdView = HtmlImpl() - } - - @Suppress("UnstableApiUsage") - private class HtmlImpl : MdView() { - companion object { - private val LOG = KiloLog.create(HtmlImpl::class.java) - private val TAGS = listOf( - "body", "p", "div", "span", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td", - "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "a", "tt", "code", "samp", "pre", - ) - - private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) - - private fun css(text: String): String = text - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\n", " ") - .replace("\r", " ") - } - - private val listeners = mutableListOf() - private val source = StringBuilder() - private var rendered = "" - - private val extensions = listOf( - AutolinkExtension.create(), - TablesExtension.create(), - StrikethroughExtension.create(), - ) - - private val parser: Parser = Parser.builder().extensions(extensions).build() - - private val renderer: HtmlRenderer = HtmlRenderer.builder() - .extensions(extensions) - .escapeHtml(true) - .sanitizeUrls(true) - .build() - - // nullable overrides — null means "use JBHtmlPane / editor default" - private var fontOverride: Font? = null - private var foregroundOverride: Color? = null - private var backgroundOverride: Color? = null - private var linkColorOverride: Color? = null - private var codeBgOverride: Color? = null - private var preBgOverride: Color? = null - private var preFgOverride: Color? = null - private var codeFontOverride: String? = null - private var quoteBorderOverride: Color? = null - private var quoteFgOverride: Color? = null - private var tableBorderOverride: Color? = null - private var opaqueState = true - - private val pane: JBHtmlPane = JBHtmlPane( - JBHtmlPaneStyleConfiguration { - // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme - enableInlineCodeBackground = true - enableCodeBlocksBackground = true - }, - JBHtmlPaneConfiguration { - // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService - customStyleSheetProvider { buildOverrideStyleSheet() } - } - ).apply { - isEditable = false - isOpaque = true - background = UIUtil.getPanelBackground() - - addHyperlinkListener { e -> - if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { - val href = e.description ?: return@addHyperlinkListener - val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point - val event = LinkEvent(href, pt) - for (l in listeners) l.onLink(event) - } - } - } - - override val component: JComponent get() = pane - - // -- style properties (non-null API backed by nullable overrides) ---- - - override var font: Font - get() = fontOverride ?: JBUI.Fonts.label() - set(value) { - if (fontOverride == value) return - fontOverride = value - markDirty() - } - - override var foreground: Color - get() = foregroundOverride ?: UIUtil.getLabelForeground() - set(value) { - if (foregroundOverride == value) return - foregroundOverride = value - markDirty() - } - - override var background: Color - get() = backgroundOverride ?: pane.background - set(value) { - if (backgroundOverride == value) return - backgroundOverride = value - if (opaqueState) pane.background = value - markDirty() - } - - override var linkColor: Color - get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) - set(value) { - if (linkColorOverride == value) return - linkColorOverride = value - markDirty() - } - - override var codeBg: Color - get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) - set(value) { - if (codeBgOverride == value) return - codeBgOverride = value - markDirty() - } - - override var preBg: Color - get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) - set(value) { - if (preBgOverride == value) return - preBgOverride = value - markDirty() - } - - override var preFg: Color - get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) - set(value) { - if (preFgOverride == value) return - preFgOverride = value - markDirty() - } - - override var codeFont: String - // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font - get() = codeFontOverride ?: "_EditorFontNoLigatures_" - set(value) { - if (codeFontOverride == value) return - codeFontOverride = value - markDirty() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) - set(value) { - if (quoteBorderOverride == value) return - quoteBorderOverride = value - markDirty() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) - set(value) { - if (quoteFgOverride == value) return - quoteFgOverride = value - markDirty() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) - set(value) { - if (tableBorderOverride == value) return - tableBorderOverride = value - markDirty() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (opaqueState == value) return - opaqueState = value - pane.isOpaque = value - if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() - markDirty() - } - - override fun resetStyles() { - fontOverride = null - foregroundOverride = null - backgroundOverride = null - linkColorOverride = null - codeBgOverride = null - preBgOverride = null - preFgOverride = null - codeFontOverride = null - quoteBorderOverride = null - quoteFgOverride = null - tableBorderOverride = null - opaqueState = true - pane.isOpaque = true - pane.background = UIUtil.getPanelBackground() - markDirty() - } - - // -- content API --------------------------------------------------- - - override fun set(text: String) { - if (source.toString() == text) return - source.clear() - source.append(text) - syncHtml() - } - - override fun append(delta: String) { - if (delta.isEmpty()) return - source.append(delta) - syncHtml() - } - - override fun clear() { - if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return - source.clear() - rendered = "" - pane.text = "" - } - - override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } - override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } - - override fun markdown(): String = source.toString() - override fun html(): String = rendered - override fun overrideSheet(): String = buildOverrideRulesString() - - override fun simulateLink(href: String) { - val event = LinkEvent(href) - for (l in listeners) l.onLink(event) - } - - private fun markDirty() { - pane.reloadCssStylesheets() - if (source.isNotEmpty()) syncHtml() - } - - private fun syncHtml() { - val body = renderer.render(parser.parse(source.toString())) - if (rendered == body && pane.text == "$body") return - rendered = body - pane.text = "$body" - pane.caretPosition = 0 - } - - private fun buildOverrideStyleSheet(): StyleSheet { - val sheet = StyleSheet() - val rules = buildOverrideRulesString() - if (rules.isNotEmpty()) { - try { - sheet.addRule(rules) - } catch (err: Exception) { - LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) - } - } - return sheet - } - - private fun buildOverrideRulesString(): String { - val rules = StringBuilder() - - val text = mutableListOf() - foregroundOverride?.let { text.add("color: ${hex(it)}") } - fontOverride?.let { - text.add("font-family: '${css(it.name)}', sans-serif") - text.add("font-size: ${it.size}pt") - if (it.isItalic) text.add("font-style: italic") - if (it.isBold) text.add("font-weight: bold") - } - if (text.isNotEmpty()) { - val rule = text.joinToString("; ") - for (tag in TAGS) rules.append("$tag { $rule } ") - } - - val body = mutableListOf() - if (!opaqueState) body.add("background: transparent") - if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") - - linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } - codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") } - preBgOverride?.let { - val color = hex(it) - rules.append("div.code-block { background: $color; border-color: $color; padding: ${UiStyle.Gap.xs()}px ${UiStyle.Gap.lg()}px } ") - rules.append("pre { background: $color; border-color: $color } ") - } - preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } - codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } - quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } - quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } - tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } - - return rules.toString().trim() - } - } + fun markdown(): String + fun html(): String + fun overrideSheet(): String + fun simulateLink(href: String) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt new file mode 100644 index 00000000000..bcb444d5602 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection +import javax.swing.ScrollPaneConstants + +object MdViewFactory { + fun create(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = + hybrid(style, selection) + + fun create(style: SessionEditorStyle, selection: SessionSelection?, code: MdCodeBlockFactory): MdView = + hybrid(style, selection, code) + + fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = + MdViewHybrid(style, selection) + + fun hybrid( + style: SessionEditorStyle, + selection: SessionSelection?, + code: MdCodeBlockFactory, + ): MdView = MdViewHybrid(style, selection, code) + + fun hybrid(code: MdCodeBlockFactory): MdView = hybrid(SessionEditorStyle.current(), null, code) + + fun html(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = + hybrid(style, selection) +} + +data class MdCodeBlockOptions( + val border: MdCodeBlockBorder = MdCodeBlockBorder.All, + val maxLines: Int? = null, + val verticalPolicy: Int = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, + val editorOnly: Boolean = false, +) + +enum class MdCodeBlockBorder { All, Horizontal, Bottom } + +data class MdCodeBlockFactory(val opts: MdCodeBlockOptions = MdCodeBlockOptions()) { + companion object { + fun default(opts: MdCodeBlockOptions = MdCodeBlockOptions()) = MdCodeBlockFactory(opts) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt new file mode 100644 index 00000000000..18f20bd6e9c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -0,0 +1,10 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle + +internal class MdViewHybrid( + style: SessionEditorStyle = SessionEditorStyle.current(), + selection: SessionSelection? = null, + code: MdCodeBlockFactory = MdCodeBlockFactory.default(), +) : ai.kilocode.client.ui.md.hybrid.MdViewHybrid(style, selection, code) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt new file mode 100644 index 00000000000..4acb36e8d12 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt @@ -0,0 +1,78 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType + +internal sealed class Kind { + data class Source(val file: FileType) : Kind() + data class Terminal(val stream: Stream, val mode: Mode) : Kind() +} + +internal enum class Stream { Stdout, Stderr } + +internal enum class Mode { Ansi, Shell, Command } + +internal object MdLanguage { + /** Internal terminal fence tags produced by ShellToolView shell transcript markdown. */ + private val terms = mapOf( + "ansi" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "ansi-stdout" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "terminal" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "terminal-output" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "shell-command" to Kind.Terminal(Stream.Stdout, Mode.Command), + "shell-output" to Kind.Terminal(Stream.Stdout, Mode.Shell), + "ansi-stderr" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + "terminal-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + "shell-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + ) + + // Alias layer only; canonical extensions fall through to FileTypeRegistry below. + private val files = mapOf( + "kotlin" to "kt", + "javascript" to "js", + "typescript" to "ts", + "python" to "py", + "bash" to "sh", + "shell" to "sh", + "zsh" to "sh", + "shellscript" to "sh", + "markdown" to "md", + "yml" to "yaml", + "golang" to "go", + "rust" to "rs", + "ruby" to "rb", + "docker" to "dockerfile", + "c++" to "cpp", + "h++" to "hpp", + "csharp" to "cs", + "c#" to "cs", + "fsharp" to "fs", + "f#" to "fs", + "powershell" to "ps1", + "pwsh" to "ps1", + "batch" to "bat", + "cmd" to "bat", + "make" to "makefile", + "terraform" to "tf", + ) + + fun kind(lang: String?): Kind { + val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty() + terms[key]?.let { return it } + if (key == "shell script") return Kind.Source(type("sh")) + val single = key.substringBefore(' ') + terms[single]?.let { return it } + files[key]?.let { return Kind.Source(type(it)) } + files[single]?.let { return Kind.Source(type(it)) } + type(key).takeIf { it != PlainTextFileType.INSTANCE }?.let { return Kind.Source(it) } + return Kind.Source(type(single)) + } + + private fun type(ext: String): FileType { + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt new file mode 100644 index 00000000000..e1754aaf63a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt @@ -0,0 +1,79 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.colors.TextAttributesKey + +internal data class ShellRange(val start: Int, val end: Int, val key: TextAttributesKey) + +internal data class ShellDisplay(val text: String, val ranges: List) + +internal object MdShellHighlight { + private val hash = Regex("(?m)^[0-9a-f]{7,40}(?=\\s)") + private val commit = Regex("^[0-9a-f]{7,40}(?:\\s|$)") + private val file = Regex("^\\s+.+\\s+\\|\\s+\\d+") + private val summary = Regex("^\\d+ files? changed(?:,|$)") + private val refs = Regex("\\((?:HEAD|origin|main|master|develop|release|feature|bugfix|hotfix|[^)]+/[^)]+)[^)]*\\)") + private val plus = Regex("\\+{2,}") + private val minus = Regex("-{2,}") + private val insertions = Regex("\\b\\d+ insertions?\\(\\+\\)") + private val deletions = Regex("\\b\\d+ deletions?\\(-\\)") + private val meta = Regex("(?m)^<(?:shell_metadata|/shell_metadata)>$") + private val cut = Regex("(?m)^\\.\\.\\.output truncated\\.\\.\\.$") + private val cmd = Regex("(?m)(^|[|&;]\\s*)([A-Za-z_./~][A-Za-z0-9_./~+-]*)") + private val flag = Regex("(?() + var grouped = false + var stat = false + + for (line in text.lines()) { + val header = commit.containsMatchIn(line) + if (header && grouped && stat && out.lastOrNull()?.isNotEmpty() == true) out.add("") + out.add(line) + if (header) grouped = true + stat = line.isNotBlank() && (file.containsMatchIn(line) || summary.containsMatchIn(line)) + } + + val display = out.joinToString("\n") + return ShellDisplay(display, ranges(display)) + } + + fun command(text: String) = ShellDisplay(text, commandRanges(text)) + + fun ranges(text: String): List = buildList { + fun add(regex: Regex, key: TextAttributesKey) { + regex.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, key)) + } + } + + add(hash, DefaultLanguageHighlighterColors.NUMBER) + add(refs, DefaultLanguageHighlighterColors.KEYWORD) + add(insertions, DefaultLanguageHighlighterColors.STRING) + add(deletions, DefaultLanguageHighlighterColors.LINE_COMMENT) + add(plus, DefaultLanguageHighlighterColors.STRING) + add(minus, DefaultLanguageHighlighterColors.LINE_COMMENT) + add(meta, DefaultLanguageHighlighterColors.DOC_COMMENT) + add(cut, DefaultLanguageHighlighterColors.KEYWORD) + } + + private fun commandRanges(text: String): List = buildList { + cmd.findAll(text).forEach { match -> + val group = match.groups[2] ?: return@forEach + add(ShellRange(group.range.first, group.range.last + 1, DefaultLanguageHighlighterColors.FUNCTION_CALL)) + } + flag.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, DefaultLanguageHighlighterColors.KEYWORD)) + } + string.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, DefaultLanguageHighlighterColors.STRING)) + } + env.findAll(text).forEach { match -> + val group = match.groups[2] ?: return@forEach + add(ShellRange(group.range.first, group.range.last + 1, DefaultLanguageHighlighterColors.STATIC_FIELD)) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt new file mode 100644 index 00000000000..2422c9b9810 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt @@ -0,0 +1,101 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.execution.process.AnsiEscapeDecoder +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.util.Key + +internal data class Range(val start: Int, val end: Int, val key: Key<*>) + +internal data class Term(val text: String, val ranges: List) + +internal object MdTerminal { + private val ansi = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]") + + fun decode(text: String, stream: Stream): Term { + val out = StringBuilder() + val ranges = mutableListOf() + val key = when (stream) { + Stream.Stdout -> ProcessOutputTypes.STDOUT + Stream.Stderr -> ProcessOutputTypes.STDERR + } + // AnsiEscapeDecoder handles SGR coloring; full terminal emulation is too heavy for inline transcripts. + AnsiEscapeDecoder().escapeText(reduce(text, keepSgr = true), key) { chunk, attrs -> + val start = out.length + out.append(chunk) + val end = out.length + if (start != end) ranges.add(Range(start, end, attrs)) + } + return Term(out.toString().trimEnd('\n'), ranges) + } + + fun split(text: String, delim: Char): List { + val list = mutableListOf() + var start = 0 + while (true) { + val index = text.indexOf(delim, start) + if (index < 0) { + list.add(text.substring(start)) + return list + } + list.add(text.substring(start, index)) + start = index + 1 + } + } + + fun backspace(text: String): String { + val out = StringBuilder() + var idx = 0 + while (idx < text.length) { + val ch = text[idx++] + if (ch == '\b') { + if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + continue + } + out.append(ch) + } + return out.toString() + } + + fun reduce(text: String, keepSgr: Boolean): String = split(text.replace("\r\n", "\n"), '\n') + .joinToString("\n") { controls(it, keepSgr) } + + fun strip(text: String): String = ansi.replace(text, "") + + fun hasAnsi(text: String): Boolean = ansi.containsMatchIn(text) + + private fun controls(text: String, keepSgr: Boolean): String { + val out = StringBuilder() + val src = text + var idx = 0 + fun esc(): String? { + if (src[idx] != '\u001B') return null + if (idx + 1 >= src.length || src[idx + 1] != '[') { + idx++ + return "" + } + var end = idx + 2 + while (end < src.length && src[end] !in '@'..'~') end++ + if (end >= src.length) { + idx = src.length + return "" + } + val seq = src.substring(idx, end + 1) + idx = end + 1 + return if (keepSgr && seq.endsWith('m')) seq else "" + } + while (idx < src.length) { + val seq = esc() + if (seq != null) { + out.append(seq) + continue + } + when (val ch = src[idx++]) { + '\r' -> out.clear() + '\b' -> if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + '\t' -> out.append(ch) + else -> if (!ch.isISOControl()) out.append(ch) + } + } + return out.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt new file mode 100644 index 00000000000..cfb9c4b0bcf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -0,0 +1,1106 @@ +package ai.kilocode.client.ui.md.hybrid + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.selection.SessionCopyTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCommon +import ai.kilocode.client.ui.md.MdStyle +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.log.KiloLog +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Block +import org.commonmark.node.Document +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Node +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Dimension +import java.awt.Font +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +@Suppress("UnstableApiUsage") +internal open class MdViewHybrid( + style: SessionEditorStyle = SessionEditorStyle.current(), + private var selection: SessionSelection? = null, + private val code: MdCodeBlockFactory = MdCodeBlockFactory.default(), +) : MdView { + companion object { + private val LOG = KiloLog.create(MdViewHybrid::class.java) + } + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var style = style + private var rendered = "" + private var disposed = false + private val blocks = mutableListOf() + private var openFence: Fence? = null + private var stale = false + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val root = RootPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = true + background = opts().background + } + + override val component: JComponent get() = root + + override var font: Font + get() = fontOverride ?: opts().font + set(value) { + if (disposed) return + if (fontOverride == value) return + fontOverride = value + syncStyle() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (disposed) return + if (foregroundOverride == value) return + foregroundOverride = value + syncStyle() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (disposed) return + if (backgroundOverride == value) return + backgroundOverride = value + syncStyle() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (disposed) return + if (linkColorOverride == value) return + linkColorOverride = value + syncStyle() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (disposed) return + if (codeBgOverride == value) return + codeBgOverride = value + syncStyle() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (disposed) return + if (preBgOverride == value) return + preBgOverride = value + syncStyle() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (disposed) return + if (preFgOverride == value) return + preFgOverride = value + syncStyle() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (disposed) return + if (codeFontOverride == value) return + codeFontOverride = value + syncStyle() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (disposed) return + if (quoteBorderOverride == value) return + quoteBorderOverride = value + syncStyle() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (disposed) return + if (quoteFgOverride == value) return + quoteFgOverride = value + syncStyle() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (disposed) return + if (tableBorderOverride == value) return + tableBorderOverride = value + syncStyle() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (disposed) return + if (opaqueState == value) return + opaqueState = value + syncStyle() + } + + override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return + this.style = style + selection?.applyStyle(style) + syncStyle() + } + + override fun setSelection(selection: SessionSelection?) { + if (disposed) return + if (this.selection === selection) return + this.selection = selection + clearBlocks() + syncBlocks() + } + + override fun resetStyles() { + if (disposed) return + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + syncStyle() + } + + override fun set(text: String) { + if (disposed) return + if (source.toString() == text) return + source.clear() + source.append(text) + syncBlocks() + } + + override fun append(delta: String) { + if (disposed) return + if (delta.isEmpty()) return + val fence = openFence + val view = blocks.lastOrNull() + if (fence != null && view != null && clean(fence.char, delta)) { + source.append(delta) + view.grow(delta) + stale = true + root.revalidate() + root.repaint() + return + } + source.append(delta) + syncBlocks() + } + + override fun clear() { + if (disposed) return + if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return + source.clear() + rendered = "" + openFence = null + stale = false + clearBlocks() + root.revalidate() + root.repaint() + } + + override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String { + if (!stale) return rendered + val out = project(source.toString()) + rendered = out.html + openFence = out.open + stale = false + return rendered + } + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + if (disposed) return + dispatch(MdView.LinkEvent(href)) + } + + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + openFence = null + stale = false + clearBlocks() + } + + private fun syncStyle() { + if (disposed) return + val opts = opts() + root.isOpaque = opts.opaque + if (opts.opaque) root.background = opts.background + for (view in blocks) view.style(opts) + root.revalidate() + root.repaint() + } + + private fun syncBlocks() { + if (disposed) return + val text = source.toString() + val out = project(text) + rendered = out.html + openFence = out.open + stale = false + val next = out.blocks + if (text.isEmpty()) { + openFence = null + clearBlocks() + root.revalidate() + root.repaint() + return + } + sync(next) + root.revalidate() + root.repaint() + } + + private fun clearBlocks() { + blocks.forEach { Disposer.dispose(it.disposable) } + blocks.clear() + root.removeAll() + } + + private fun sync(next: List) { + var at = 0 + while (at < blocks.size && at < next.size) { + val view = blocks[at] + val desc = next[at] + if (!view.compatible(desc)) break + view.update(desc) + at++ + } + removeBlocks(at) + for (desc in next.drop(at)) addBlock(view(desc)) + } + + private fun clean(char: Char, delta: String): Boolean { + if (delta.contains(char)) return false + val start = source.lastIndexOf("\n") + 1 + for (idx in start until source.length) { + if (source[idx] == char) return false + } + return true + } + + private fun removeBlocks(start: Int) { + if (start >= blocks.size) return + val idx = if (start == 0) 0 else start * 2 - 1 + while (root.componentCount > idx) root.remove(root.componentCount - 1) + val stale = blocks.drop(start) + repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) } + stale.forEach { Disposer.dispose(it.disposable) } + } + + private fun addGap() { + if (root.componentCount == 0) return + root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) + } + + private fun addBlock(view: View) { + addGap() + view.component.alignmentX = JComponent.LEFT_ALIGNMENT + blocks.add(view) + root.add(view.component) + } + + private fun view(desc: Desc): View { + val disposable = Disposer.newDisposable("Markdown block") + return when (desc) { + is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) + is Desc.Code -> when (val kind = desc.kind) { + is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable) + is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable) + } + } + } + + private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { + val opts = opts() + return object : JBHtmlPane( + JBHtmlPaneStyleConfiguration { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { sheet() } + }, + ), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { document.getText(0, document.length).trim() } + } + }.apply { + isEditable = false + isOpaque = opts.opaque + background = opts.background + text = "$body" + selection?.register(this, disposable) + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt)) + } + } + } + + private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { + val opts = opts() + val value = text.trimEnd('\n') + fun editor(type: FileType) = CodeField(type, opts, text, false).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + val field = runCatching { + editor(file) + }.getOrElse { err -> + LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) + if (code.opts.editorOnly) runCatching { + editor(PlainTextFileType.INSTANCE) + }.getOrElse { fallback -> + LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) + throw fallback + } else { + textArea(text, opts, disposable) + } + } + sizeCodeField(field, value) + val pane = object : JBScrollPane(field), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = when (field) { + is CodeField -> field.text + is JBTextArea -> field.text + else -> "" + } + + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + val view = viewport.view ?: return + val size = viewport.extentSize + if (size.height <= 0 || view.height == size.height) return + view.setSize(view.width.coerceAtLeast(size.width), size.height) + } + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + return pane + } + + private fun terminalBlock(text: String, kind: Kind.Terminal, disposable: Disposable): JBScrollPane { + val opts = opts() + val term = MdTerminal.decode(text, kind.stream) + val value = shellDisplay(term, kind.mode) + val field = CodeField(PlainTextFileType.INSTANCE, opts, value.text, false).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + sizeCodeField(field, value.text) + val pane = object : JBScrollPane(field), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = field.text + + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + val view = viewport.view ?: return + val size = viewport.extentSize + if (size.height <= 0 || view.height == size.height) return + view.setSize(view.width.coerceAtLeast(size.width), size.height) + } + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + applyTerm(field, term, kind.mode, value) + return pane + } + + private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { + pane.apply { + val width = SessionUiStyle.View.Code.BORDER_WIDTH + border = when (code.opts.border) { + MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) + MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) + MdCodeBlockBorder.Bottom -> JBUI.Borders.customLine(opts.codeBorder, 0, 0, width, 0) + } + viewportBorder = JBUI.Borders.empty( + SessionUiStyle.View.Code.topPadding(), + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) + isOpaque = true + background = opts.preBg + viewport.isOpaque = true + viewport.background = opts.preBg + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = code.opts.verticalPolicy + isWheelScrollingEnabled = true + setOverlappingScrollBar(false) + horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) + horizontalScrollBar.isOpaque = true + if (code.opts.verticalPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) { + verticalScrollBar.preferredSize = JBUI.emptySize() + } + } + } + + private fun sizeCodeField(component: JComponent, text: String) { + val height = codeHeight(component, text) + val width = codeWidth(component, text) + component.preferredSize = Dimension(width, height) + component.minimumSize = Dimension(0, height) + component.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { + val pad = pane.viewportBorder.getBorderInsets(pane) + val text = when (component) { + is CodeField -> component.text + is JBTextArea -> component.text + else -> "" + } + val content = visibleCodeHeight(component, text) + val height = content + pane.insets.top + pane.insets.bottom + + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height + pane.preferredSize = Dimension(0, height) + pane.minimumSize = Dimension(0, height) + pane.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + private fun codeWidth(component: JComponent, text: String): Int { + val metrics = component.getFontMetrics(component.font) + val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0 + return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun codeHeight(component: JComponent, text: String): Int { + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + val field = component as? CodeField + if (field != null) { + field.ensureWillComputePreferredSize() + val ed = field.getEditor(false) + val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height + return maxOf(field.preferredSize.height, line * rows) + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + + private fun visibleCodeHeight(component: JComponent, text: String): Int { + val max = code.opts.maxLines ?: return component.preferredSize.height + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max) + val field = component as? CodeField + if (field != null) { + field.ensureWillComputePreferredSize() + val ed = field.getEditor(false) + val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height + return line * rows + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + + private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = object : JBTextArea(text.trimEnd('\n')), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = this.text + }.apply { + isEditable = false + lineWrap = false + styleTextArea(this, opts) + border = JBUI.Borders.empty( + SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) + selection?.register(this, disposable) + } + + private fun styleTextArea(area: JBTextArea, opts: MdStyle) { + area.isOpaque = true + area.background = opts.preBg + area.foreground = opts.preFg + area.font = style.editorFont + } + + private inner class CodeField(file: FileType, opts: MdStyle, value: String, val soft: Boolean) : + com.intellij.ui.EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + file, + true, + false, + ), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = text + + init { + setFontInheritedFromLAF(false) + font = style.editorFont + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = soft + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } + } + } + + private inner class RootPanel : JPanel(), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { markdown() } + } + } + + private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { + if (mode == Mode.Shell) return MdShellHighlight.project(term.text) + if (mode == Mode.Command) return MdShellHighlight.command(term.text) + return ShellDisplay(term.text, emptyList()) + } + + private fun applyTerm(field: CodeField, term: Term, mode: Mode, display: ShellDisplay = shellDisplay(term, mode)) { + val editor = field.getEditor(true) ?: return + editor.markupModel.removeAllHighlighters() + if (mode == Mode.Shell || mode == Mode.Command) { + applyShell(field, display) + return + } + val size = editor.document.textLength + for (range in term.ranges) { + val start = range.start.coerceAtMost(size) + val end = range.end.coerceAtMost(size) + if (start >= end) continue + val type = ConsoleViewContentType.getConsoleViewType(range.key) + val key = type.attributesKey + if (key != null) { + editor.markupModel.addRangeHighlighter( + key, + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } else { + editor.markupModel.addRangeHighlighter( + start, + end, + HighlighterLayer.SYNTAX + 1, + type.attributes, + HighlighterTargetArea.EXACT_RANGE, + ) + } + } + } + + private fun applyShell(field: CodeField, display: ShellDisplay) { + val editor = field.getEditor(false) ?: return + val size = editor.document.textLength + for (range in display.ranges) { + val start = range.start.coerceAtMost(size) + val end = range.end.coerceAtMost(size) + if (start >= end) continue + editor.markupModel.addRangeHighlighter( + range.key, + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } + } + + private fun dispatch(event: MdView.LinkEvent) { + for (l in listeners) l.onLink(event) + } + + private fun sheet(): StyleSheet { + val sheet = StyleSheet() + val rules = overrideSheet() + if (rules.isEmpty()) return sheet + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } + return sheet + } + + private fun opts(): MdStyle { + val base = MdCommon.defaults(style) + return base.copy( + font = fontOverride ?: base.font, + foreground = foregroundOverride ?: base.foreground, + background = backgroundOverride ?: base.background, + linkColor = linkColorOverride ?: base.linkColor, + codeBg = codeBgOverride ?: base.codeBg, + preBg = preBgOverride ?: base.preBg, + preFg = preFgOverride ?: base.preFg, + codeFont = codeFontOverride ?: base.codeFont, + quoteBorder = quoteBorderOverride ?: base.quoteBorder, + quoteFg = quoteFgOverride ?: base.quoteFg, + tableBorder = tableBorderOverride ?: base.tableBorder, + opaque = opaqueState, + ) + } + + private fun collect(doc: Node): List { + val visitor = Visitor() + doc.accept(visitor) + return visitor.blocks + } + + private fun project(text: String): Projection { + val blocks = mutableListOf() + val html = StringBuilder() + val md = StringBuilder() + val lines = lines(text) + var trailing: Fence? = null + var idx = 0 + + fun flush() { + if (md.isEmpty()) return + val doc = parser.parse(md.toString()) + val descs = collect(doc) + blocks.addAll(descs) + for (desc in descs) { + when (desc) { + is Desc.Html -> html.append(desc.body) + is Desc.Code -> html.append(codeHtml(desc.text)) + } + } + md.clear() + } + + while (idx < lines.size) { + val line = lines[idx] + val open = opener(line.text) + if (open == null) { + val pending = idx == lines.lastIndex && pendingOpener(line.text) + if (pending) { + flush() + blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))) + html.append(codeHtml("")) + } else { + md.append(line.text).append(line.end) + } + idx++ + continue + } + + flush() + idx++ + val code = StringBuilder() + var closed = false + var trimmed = false + while (idx < lines.size) { + val item = lines[idx] + val close = closer(item.text, open) + if (close) { + closed = true + idx++ + break + } + val partial = idx == lines.lastIndex && partialCloser(item.text, open) + if (partial) trimmed = true + if (!partial) code.append(item.text).append(item.end) + idx++ + } + val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info)) + blocks.add(desc) + html.append(codeHtml(desc.text)) + trailing = if (!closed && !trimmed) open else null + } + + flush() + return Projection(html.toString(), blocks, trailing) + } + + private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + val lines = mutableListOf() + var start = 0 + while (start < text.length) { + val end = text.indexOf('\n', start) + if (end == -1) { + lines.add(Line(text.substring(start), "")) + break + } + lines.add(Line(text.substring(start, end), "\n")) + start = end + 1 + } + return lines + } + + private fun opener(text: String): Fence? { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return null + val char = trimmed.firstOrNull() ?: return null + if (char != '`' && char != '~') return null + val size = trimmed.takeWhile { it == char }.length + if (size < 3) return null + val info = trimmed.drop(size).trim() + if (char == '`' && info.contains('`')) return null + return Fence(char, size, info) + } + + private fun closer(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size < fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun pendingOpener(text: String): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val char = trimmed.firstOrNull() ?: return false + if (char != '`' && char != '~') return false + val size = trimmed.takeWhile { it == char }.length + if (size !in 1..2) return false + return trimmed.drop(size).isBlank() + } + + private fun partialCloser(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size !in 1 until fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun codeHtml(text: String): String = "
    ${escape(text)}
    \n" + + private fun escape(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private sealed class Desc { + data class Html(val body: String) : Desc() + data class Code(val text: String, val kind: Kind) : Desc() + } + + private data class Projection(val html: String, val blocks: List, val open: Fence?) + + private data class Line(val text: String, val end: String) + + private data class Fence(val char: Char, val size: Int, val info: String) + + private abstract inner class View( + var desc: Desc, + val component: JComponent, + val disposable: Disposable, + ) { + abstract fun compatible(desc: Desc): Boolean + abstract fun update(desc: Desc) + abstract fun style(opts: MdStyle) + open fun grow(delta: String) = Unit + } + + private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Html + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + pane.text = "${(desc as Desc.Html).body}" + } + + override fun style(opts: MdStyle) { + pane.isOpaque = opts.opaque + pane.background = opts.background + pane.reloadCssStylesheets() + val item = desc as Desc.Html + pane.text = "${item.body}" + } + } + + private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val value = (desc as Desc.Code).text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + override fun grow(delta: String) { + val item = desc as Desc.Code + val next = item.copy(text = item.text + delta) + desc = next + val value = next.text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view + when (view) { + is CodeField -> { + view.font = style.editorFont + view.background = opts.preBg + view.getEditor(false)?.let { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = view.soft + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + is JBTextArea -> styleTextArea(view, opts) + } + if (view is JComponent) { + val text = when (view) { + is CodeField -> view.text + is JBTextArea -> view.text + else -> "" + } + sizeCodeField(view, text) + sizeCodePane(pane, view) + } + } + } + + private inner class TermView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val item = desc as Desc.Code + val kind = item.kind as Kind.Terminal + val term = MdTerminal.decode(item.text, kind.stream) + val value = shellDisplay(term, kind.mode) + val view = pane.viewport.view as? CodeField ?: return + view.text = value.text + sizeCodeField(view, value.text) + sizeCodePane(pane, view) + applyTerm(view, term, kind.mode, value) + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view as? CodeField ?: return + val item = desc as Desc.Code + val kind = item.kind as Kind.Terminal + view.font = style.editorFont + view.background = opts.preBg + view.getEditor(false)?.let { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = view.soft + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + val term = MdTerminal.decode(item.text, kind.stream) + val value = shellDisplay(term, kind.mode) + if (view.text != value.text) view.text = value.text + sizeCodeField(view, value.text) + sizeCodePane(pane, view) + applyTerm(view, term, kind.mode, value) + } + + override fun grow(delta: String) { + val item = desc as Desc.Code + update(item.copy(text = item.text + delta)) + } + } + + private inner class Visitor : AbstractVisitor() { + val blocks = mutableListOf() + private val run = StringBuilder() + + override fun visit(document: Document) { + visitChildren(document) + flush() + } + + override fun visit(code: FencedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info))) + } + + override fun visit(code: IndentedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(null))) + } + + private fun flush() { + if (run.isEmpty()) return + blocks.add(Desc.Html(run.toString())) + run.clear() + } + + public override fun visitChildren(parent: Node) { + var child = parent.firstChild + while (child != null) { + val next = child.next + if (child is ThematicBreak) { + child = next + continue + } + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) + child = next + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/util/UiTimers.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/util/UiTimers.kt new file mode 100644 index 00000000000..88e4b0074e8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/util/UiTimers.kt @@ -0,0 +1,41 @@ +package ai.kilocode.client.util + +import javax.swing.Timer + +interface UiTimer { + fun start() + fun stop() + fun restart() + fun isRunning(): Boolean +} + +interface UiTimerSource { + fun now(): Long + fun timer(ms: Int, repeats: Boolean = true, action: () -> Unit): UiTimer +} + +object UiTimers : UiTimerSource { + override fun now(): Long = System.currentTimeMillis() + + override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer { + val timer = Timer(ms.coerceAtLeast(0)) { action() } + timer.isRepeats = repeats + return SwingUiTimer(timer) + } + + private class SwingUiTimer(private val timer: Timer) : UiTimer { + override fun start() { + timer.start() + } + + override fun stop() { + timer.stop() + } + + override fun restart() { + timer.restart() + } + + override fun isRunning(): Boolean = timer.isRunning + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt new file mode 100644 index 00000000000..e3276f68529 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt @@ -0,0 +1,13 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +interface KiloEditorKind : KiloVirtualFileKind { + @RequiresEdt + fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent + + fun preferredFocus(component: JComponent): JComponent? = null +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt new file mode 100644 index 00000000000..236019d7f35 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.APP) +class KiloEditorKindRegistry { + private val kinds = ConcurrentHashMap() + + fun register(kind: KiloEditorKind) { + kinds[kind.id] = kind + service().register(kind) + } + + fun unregister(id: String) { + kinds.remove(id) + service().unregister(id) + } + + fun clear() { + kinds.keys.forEach { id -> unregister(id) } + } + + fun get(id: String): KiloEditorKind? = kinds[id] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt new file mode 100644 index 00000000000..1b0dc0f7605 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt @@ -0,0 +1,28 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +class KiloFileEditor( + private val project: Project, + private val file: VirtualFile, + private val kilo: KiloVirtualFile, + private val kind: KiloEditorKind, +) : KiloFileEditorBase() { + private val ui: JComponent by lazy { kind.createContent(project, kilo, this) } + + @RequiresEdt + override fun getComponent(): JComponent = ui + + override fun getPreferredFocusedComponent(): JComponent? = kind.preferredFocus(ui) + override fun getName(): String = kind.title(kilo.path.params) + override fun getFile(): VirtualFile = file + override fun isValid(): Boolean = super.isValid() && kilo.isValid + + override fun dispose() { + KiloVirtualFileSystem.getInstance().release(kilo.path) + super.dispose() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt new file mode 100644 index 00000000000..4fc5b296c66 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.fileEditor.FileEditorStateLevel +import com.intellij.openapi.util.CheckedDisposable +import com.intellij.openapi.util.UserDataHolderBase +import java.beans.PropertyChangeListener +import java.beans.PropertyChangeSupport + +abstract class KiloFileEditorBase : UserDataHolderBase(), FileEditor, CheckedDisposable { + private var disposed = false + private val support = PropertyChangeSupport(this) + + override fun isDisposed(): Boolean = disposed + + override fun dispose() { + disposed = true + } + + override fun isValid(): Boolean = !disposed + + override fun addPropertyChangeListener(listener: PropertyChangeListener) { + support.addPropertyChangeListener(listener) + } + + override fun removePropertyChangeListener(listener: PropertyChangeListener) { + support.removePropertyChangeListener(listener) + } + + override fun getState(level: FileEditorStateLevel): FileEditorState = FileEditorState.INSTANCE + override fun setState(state: FileEditorState) {} + override fun isModified(): Boolean = false +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt new file mode 100644 index 00000000000..1bcbbbcb27e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt @@ -0,0 +1,46 @@ +package ai.kilocode.client.vfs + +import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile + +class KiloFileEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean { + ensureAttachmentEditorKind() + val path = path(file) ?: return false + return service().get(path.kind) != null + } + + override fun acceptRequiresReadAction(): Boolean = false + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + ensureAttachmentEditorKind() + val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}") + val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path) + val kind = service().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}") + return KiloFileEditor(project, file, kilo, kind) + } + + override fun disposeEditor(editor: FileEditor) { + Disposer.dispose(editor) + } + + override fun getEditorTypeId(): String = EDITOR_TYPE_ID + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_OTHER_EDITORS + + companion object { + const val EDITOR_TYPE_ID = "KiloVfsEditor" + + private fun path(file: VirtualFile): KiloPath? { + if (file is KiloVirtualFile) return file.path + if (file.fileSystem.protocol != KiloVirtualFileSystem.PROTOCOL && !file.url.startsWith("${KiloVirtualFileSystem.PROTOCOL}://")) return null + return KiloVirtualFileSystem.decode(file.path) ?: KiloVirtualFileSystem.decode(file.url) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt new file mode 100644 index 00000000000..a2773afa1b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.vfs + +import kotlinx.serialization.Serializable + +@Serializable +data class KiloPath( + val kind: String, + val params: Map = emptyMap(), +) { + fun canonical(): KiloPath = copy(params = canonicalParams(params)) +} + +internal fun canonicalParams(params: Map): Map { + if (params.size < 2) return params + return params.toSortedMap() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt new file mode 100644 index 00000000000..b23d0f69a05 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt @@ -0,0 +1,40 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresEdt + +@Service(Service.Level.PROJECT) +class KiloVfsManager(private val project: Project) { + @RequiresEdt + fun open(kind: String, params: Map = emptyMap(), focus: Boolean = true): Boolean { + val file = file(kind, params) ?: return false + if (ApplicationManager.getApplication().isUnitTestMode) { + file.putUserData(FileEditorProvider.KEY, KiloFileEditorProvider()) + } + FileEditorManager.getInstance(project).openFile(file, focus) + return true + } + + @RequiresEdt + fun close(kind: String, params: Map = emptyMap()) { + val file = file(kind, params) ?: return + FileEditorManager.getInstance(project).closeFile(file) + KiloVirtualFileSystem.getInstance().release(file.path) + } + + @RequiresEdt + fun updatePresentation(kind: String, params: Map = emptyMap()) { + val file = file(kind, params) ?: return + FileEditorManager.getInstance(project).updateFilePresentation(file) + } + + private fun file(kind: String, params: Map): KiloVirtualFile? { + val path = KiloPath(kind, params) + val fs = KiloVirtualFileSystem.getInstance() + return fs.refreshAndFindFileByPath(fs.getPath(path)) as? KiloVirtualFile + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt new file mode 100644 index 00000000000..01b14336dd7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt @@ -0,0 +1,58 @@ +@file:Suppress("LeakingThis") + +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManagerKeys +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypes +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFilePathWrapper +import com.intellij.openapi.vfs.VirtualFileWithoutContent +import java.io.InputStream +import java.io.OutputStream + +class KiloVirtualFile( + val path: KiloPath, +) : VirtualFile(), + VirtualFileWithoutContent, + VirtualFilePathWrapper { + init { + putUserData(FileEditorManagerKeys.FORBID_TAB_SPLIT, true) + } + + override fun getFileSystem(): KiloVirtualFileSystem = KiloVirtualFileSystem.getInstance() + override fun getFileType(): FileType = FileTypes.UNKNOWN + override fun getPath(): String = fileSystem.getPath(path) + override fun getUrl(): String = "${fileSystem.protocol}://$path" + override fun getName(): String = kind()?.title(path.params) ?: path.kind + override fun getPresentableName(): String = name + override fun getPresentablePath(): String = kind()?.presentablePath(path.params) ?: name + override fun enforcePresentableName(): Boolean = true + override fun isValid(): Boolean = kind()?.isValid(path.params) == true + override fun isWritable(): Boolean = false + override fun isDirectory(): Boolean = false + override fun getParent(): VirtualFile? = null + override fun getChildren(): Array = emptyArray() + override fun getLength(): Long = 0 + override fun getTimeStamp(): Long = 0 + override fun getModificationStamp(): Long = 0 + override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) { + postRunnable?.run() + } + + override fun contentsToByteArray(): ByteArray = throw UnsupportedOperationException() + override fun getInputStream(): InputStream = throw UnsupportedOperationException() + override fun getOutputStream(requestor: Any?, newModificationStamp: Long, newTimeStamp: Long): OutputStream = + throw UnsupportedOperationException() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is KiloVirtualFile) return false + return path == other.path + } + + override fun hashCode(): Int = path.hashCode() + + private fun kind(): KiloVirtualFileKind? = service().get(path.kind) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt new file mode 100644 index 00000000000..0dc059356b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt @@ -0,0 +1,15 @@ +package ai.kilocode.client.vfs + +import javax.swing.Icon + +interface KiloVirtualFileKind { + val id: String + + fun title(params: Map): String + + fun icon(params: Map): Icon? = null + + fun presentablePath(params: Map): String = title(params) + + fun isValid(params: Map): Boolean = true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt new file mode 100644 index 00000000000..08ad685a9ed --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.Service +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.APP) +class KiloVirtualFileKindRegistry { + private val kinds = ConcurrentHashMap() + + fun register(kind: KiloVirtualFileKind) { + kinds[kind.id] = kind + } + + fun unregister(id: String) { + kinds.remove(id) + } + + fun clear() { + kinds.clear() + } + + fun get(id: String): KiloVirtualFileKind? = kinds[id] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt new file mode 100644 index 00000000000..cac74dd041f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.vfs.NonPhysicalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileListener +import com.intellij.openapi.vfs.VirtualFilePathWrapper +import com.intellij.openapi.vfs.VirtualFileSystem +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap +import kotlinx.serialization.json.Json + +class KiloVirtualFileSystem : VirtualFileSystem(), NonPhysicalFileSystem { + private val files = ConcurrentHashMap() + + fun getPath(path: KiloPath): String = json.encodeToString(KiloPath.serializer(), path.canonical()) + + fun findOrCreateFile(path: KiloPath): VirtualFile? { + service().get(path.kind) ?: return null + return files.computeIfAbsent(path.canonical()) { KiloVirtualFile(it) } + } + + fun release(path: KiloPath) { + files.remove(path.canonical()) + } + + fun clear() { + files.clear() + } + + override fun findFileByPath(path: String): VirtualFile? { + val parsed = decode(path) ?: return null + return findOrCreateFile(parsed) + } + + override fun refreshAndFindFileByPath(path: String): VirtualFile? = findFileByPath(path) + + override fun extractPresentableUrl(path: String): String { + return (refreshAndFindFileByPath(path) as? VirtualFilePathWrapper)?.presentablePath ?: path + } + + override fun refresh(asynchronous: Boolean) {} + + override fun getProtocol(): String = PROTOCOL + + override fun addVirtualFileListener(listener: VirtualFileListener) {} + + override fun removeVirtualFileListener(listener: VirtualFileListener) {} + override fun isReadOnly(): Boolean = true + override fun deleteFile(requestor: Any?, file: VirtualFile) = unsupported() + override fun moveFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile) = unsupported() + override fun renameFile(requestor: Any?, file: VirtualFile, newName: String) = unsupported() + override fun createChildFile(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported() + override fun createChildDirectory(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported() + override fun copyFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile, copyName: String): VirtualFile = unsupported() + + private fun unsupported(): Nothing = throw UnsupportedOperationException("Kilo virtual files are read-only") + + companion object { + const val PROTOCOL = "kilo" + + private val json = Json + private val log = logger() + private val local = KiloVirtualFileSystem() + + fun getInstance(): KiloVirtualFileSystem = local + + fun decode(path: String): KiloPath? { + return try { + val raw = raw(path) ?: return null + json.decodeFromString(KiloPath.serializer(), raw).canonical() + } catch (err: Exception) { + log.warn("Cannot deserialize $path", err) + null + } + } + + private fun raw(path: String): String? { + if (path.startsWith("{")) return path + if (!path.startsWith("$PROTOCOL://")) return null + val raw = path.substringAfter("://") + if (raw.startsWith("{")) return raw + if (!raw.startsWith("%7B", ignoreCase = true)) return null + return URLDecoder.decode(raw, StandardCharsets.UTF_8) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/book-open-check.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/book-open-check.svg new file mode 100644 index 00000000000..4ca1b365dda --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/book-open-check.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/book-open-check_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/book-open-check_dark.svg new file mode 100644 index 00000000000..47d03ec6b11 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/book-open-check_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg new file mode 100644 index 00000000000..6b1bcab5732 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg new file mode 100644 index 00000000000..db15066d329 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg new file mode 100644 index 00000000000..3c44cd2f564 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg new file mode 100644 index 00000000000..1b0773f2641 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg new file mode 100644 index 00000000000..437e5035017 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg new file mode 100644 index 00000000000..e604cb68874 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg new file mode 100644 index 00000000000..fe390112568 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg new file mode 100644 index 00000000000..a00b482d888 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg index d1dd8bd8cd7..dad71aa60b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg index a9ede4c7320..12fe7ad9f85 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-question.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-question.svg new file mode 100644 index 00000000000..5230d4344ff --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-question.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-question_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-question_dark.svg new file mode 100644 index 00000000000..f4733a8aed4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-question_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield-filled.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield-filled.svg new file mode 100644 index 00000000000..a9a908cc760 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield-filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield-filled_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield-filled_dark.svg new file mode 100644 index 00000000000..e6be1f92151 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield-filled_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg new file mode 100644 index 00000000000..eb8185f256b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg index a8f3398dfe1..5d59433bf6a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/shield_dark.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg new file mode 100644 index 00000000000..304a93ea9c5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg new file mode 100644 index 00000000000..894515662bf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg new file mode 100644 index 00000000000..b76605a472d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg new file mode 100644 index 00000000000..95762858b59 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg new file mode 100644 index 00000000000..c15ecbd0e45 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg new file mode 100644 index 00000000000..bcce4c17aec --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg new file mode 100644 index 00000000000..11b4cac00b4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg new file mode 100644 index 00000000000..3997b5ec2ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg new file mode 100644 index 00000000000..b916dec2a8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg new file mode 100644 index 00000000000..9d727eb37c2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg new file mode 100644 index 00000000000..57c07f8de87 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg new file mode 100644 index 00000000000..0db8b8f5c86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg new file mode 100644 index 00000000000..c4f66e5533a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg new file mode 100644 index 00000000000..929005b5aaf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg new file mode 100644 index 00000000000..456560f6327 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg new file mode 100644 index 00000000000..947b66ac25c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg new file mode 100644 index 00000000000..554b8656b03 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg new file mode 100644 index 00000000000..ddfe6242464 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg new file mode 100644 index 00000000000..5a27e30208e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg new file mode 100644 index 00000000000..44f84beaa6d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg new file mode 100644 index 00000000000..45d7231e5e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg new file mode 100644 index 00000000000..14983a084f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg new file mode 100644 index 00000000000..1f891fb11d4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg new file mode 100644 index 00000000000..769102d69ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg new file mode 100644 index 00000000000..3ab3bf728cd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg new file mode 100644 index 00000000000..a1eff436992 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg new file mode 100644 index 00000000000..cee92d0c0b2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg new file mode 100644 index 00000000000..4eb477f0e37 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg new file mode 100644 index 00000000000..17a37afba86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg new file mode 100644 index 00000000000..6eaae56d332 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg new file mode 100644 index 00000000000..802ac7d8554 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg new file mode 100644 index 00000000000..d9f9e992c55 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg new file mode 100644 index 00000000000..b26bac3f715 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg new file mode 100644 index 00000000000..80ac013dd3a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/wand-sparkles.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/wand-sparkles.svg new file mode 100644 index 00000000000..2db552dbd1c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/wand-sparkles.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/wand-sparkles_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/wand-sparkles_dark.svg new file mode 100644 index 00000000000..556b125c896 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/wand-sparkles_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 6557629278c..dec2fd4b2ee 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 @@ -7,11 +7,22 @@ messages.KiloBundle + + + + + + + + + + + + + + + + + @@ -45,11 +83,33 @@ - + + + + + + + + + + + + + + + + + + + + @@ -60,14 +120,10 @@ - - - @@ -77,6 +133,10 @@ description="Send the current Kilo prompt"> + +
    + + + + 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 763099b2a0a..0cf258b2850 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -5,13 +5,25 @@ session.connection.error.unknown=Unknown error session.connection.retry=Try again session.connection.warning.config=Configuration warnings +notification.group.kilo=Kilo Code + session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. session.account.balance=Balance: {0} session.account.switcher=Switch account session.empty.loading=Loading... session.empty.recent=RECENT session.showHistory=Show History +feedback.button=Feedback and Support +feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing. +feedback.dialog.github=Report an issue on GitHub +feedback.dialog.discord=Join our Discord community +feedback.dialog.support=Customer Support session.scroll.bottom=Scroll to bottom +session.scroll.question=Scroll to question +session.copy.hover=Copy +session.copy.copied=Copied +session.drop.files.title=Drop files here +session.drop.files.subtitle=to add them to the prompt session.tab.new=New Session session.tab.untitled=Untitled Session @@ -71,6 +83,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.offline=Connection offline session.part.reasoning=Reasoning session.part.compaction=context compacted @@ -78,9 +92,20 @@ session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.pending=Pending session.part.tool.read=Read +session.part.tool.glob=Glob +session.part.tool.search=Search session.part.tool.running=Running session.part.tool.shell=Shell +session.part.tool.shell.command=Command +session.part.tool.shell.error=Error +session.part.tool.shell.output=Output session.part.tool.truncated=Output truncated in preview. Full output remains in session data. +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden session.error.prompt=Prompt failed session.error.compact=Session compact failed @@ -101,21 +126,63 @@ session.header.compact=Compact session session.header.compact.description=Summarize the session to reduce context size session.header.expand=Show session metrics session.header.collapse=Hide session metrics +session.header.cost.tooltip={0} spent in this session session.header.todos.progress={0}/{1} todos complete session.header.todos.done=All {0} todos complete +session.header.todos.toggle=Toggle to-dos session.header.context.tooltip.percent={0} tokens ({1}% of context) session.header.context.tooltip.tokens={0} tokens session.header.context.used={0} / {1} tokens used session.header.context.reserved={0} reserved for output session.header.context.available={0} available -prompt.placeholder=Type a message... -prompt.placeholder.with.shortcuts=Type a message... ({0} to send, {1} for new line) -prompt.placeholder.with.send=Type a message... ({0} to send) -prompt.placeholder.with.newline=Type a message... ({0} for new line) +prompt.placeholder=Type here; use / or @, drop/paste files +prompt.placeholder.with.shortcuts=Type here; use / or @, drop/paste files; {0} send, {1} newline +prompt.placeholder.with.send=Type here; use / or @, drop/paste files; {0} send +prompt.placeholder.with.newline=Type here; use / or @, drop/paste files; {0} newline prompt.button.send=Send prompt.button.stop=Stop prompt.button.send.tooltip.stop=To stop, press {0} +prompt.attachment.remove=Remove {0} +prompt.attachment.open=Open {0} +prompt.attachment.tooltip=Name: {0}\nType: {1}\nLocation: {2} +prompt.attachment.embedded=Embedded content +prompt.attachment.unsupported.model=The selected model does not support image or PDF attachments. +prompt.attachment.missing=Attachment no longer exists: {0} +prompt.attachment.send.failed=Failed to send attachment: {0} +session.attachment.title=Attachment +session.attachment.path=Kilo / Attachments / {0} / {1} +session.attachment.loading=Loading attachment... +session.attachment.missing=Attachment not found +session.attachment.unsupported=Cannot preview {0} +session.attachment.mime=Type: {0} +session.attachment.size=Size: {0} bytes +session.attachment.error=Failed to load attachment: {0} +prompt.action.enhance=Enhance prompt +prompt.action.enhance.loading=Enhancing prompt... +prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works. +prompt.action.enhance.failed=Failed to enhance prompt +prompt.action.enhance.failed.description=The configured small model could not enhance this prompt. +prompt.completion.action=Kilo Prompt Completion +prompt.completion.noMatches=No matches +prompt.mention.indexing=Indexing project files. File mentions will be available soon. +prompt.mention.gitChanges=Attach current git changes +prompt.mention.goto=Go to Mentioned File +prompt.mention.unresolved=Cannot resolve file "{0}" in the workspace +prompt.slash.new=Start a new session +prompt.slash.sessions=Open session history +prompt.slash.models=Choose a model +prompt.slash.agents=Choose an agent +prompt.slash.variant=Choose reasoning variant +prompt.slash.compact=Compact this session +prompt.slash.settings=Open Kilo settings +prompt.slash.help=Open Kilo documentation +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +session.editor.undo=Kilo Session Undo +session.editor.redo=Kilo Session Redo mode.picker.tooltip=Select mode mode.picker.deprecated=deprecated model.picker.tooltip=Select model @@ -126,6 +193,8 @@ model.picker.no.matches=No matching models model.picker.favorite.add=Add to favorites model.picker.favorite.remove=Remove from favorites model.picker.free=Free +model.picker.dataCollected=Data may be used for training +model.picker.dataCollected.current=The current selected model may be used for training model.picker.reset=Reset model to default reasoning.picker.tooltip=Select reasoning effort @@ -144,6 +213,10 @@ history.rename.title=Rename Session history.rename.prompt=New session name: history.cloud.load.more=Load more history.cloud.repo.only=Only this repository +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question history.group.today=Today history.group.yesterday=Yesterday history.group.week=This Week @@ -172,6 +245,71 @@ action.Kilo.ShowProfile.description=Open Kilo user profile settings 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.models.displayName=Models +settings.providers.displayName=Providers +settings.providers.loading=Loading providers... +settings.providers.connected=Connected providers +settings.providers.available=Available providers +settings.providers.disabled=Disabled providers +settings.providers.popular=Popular providers +settings.providers.all=All providers +settings.providers.search=Filter providers +settings.providers.noMatches=No matching providers +settings.providers.addCustom=Add custom provider +settings.providers.addCustom.description=Add a custom OpenAI-compatible provider +settings.providers.refresh=Refresh +settings.providers.refresh.description=Refresh provider settings +settings.providers.connect=Connect +settings.providers.oauth=OAuth +settings.providers.oauth.starting=Starting OAuth for {0}... +settings.providers.oauth.waitingTimed=Waiting for authorization... ({0}) +settings.providers.oauth.cancel=Cancel +settings.providers.disconnect=Disconnect +settings.providers.enable=Enable +settings.providers.note.kilo=Access 500+ AI models +settings.providers.note.opencode=Curated models including Claude, GPT, Gemini and more +settings.providers.note.anthropic=Direct access to Claude models, including Pro and Max +settings.providers.note.deepseek=DeepSeek models for reasoning and coding tasks +settings.providers.note.copilot=Claude models for coding assistance +settings.providers.note.openai=GPT and Codex models with API key or ChatGPT login +settings.providers.note.google=Gemini models for fast, structured responses +settings.providers.note.openrouter=Access all supported models from one provider +settings.providers.note.vercel=Unified access to AI models with smart routing +settings.providers.apiKey=API key +settings.providers.apiKeyRequired=API key is required. +settings.providers.customTitle=Custom OpenAI-Compatible Provider +settings.providers.customId=Provider ID +settings.providers.customName=Display name +settings.providers.customUrl=Base URL +settings.providers.customEnv=API key environment variable +settings.providers.customModels=Model IDs (comma-separated) +settings.providers.customIdRequired=Provider ID is required. +settings.providers.customUrlRequired=Base URL is required. +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=The lightweight model used for prompt enhancement, title generation, and other quick tasks. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset settings.profile.displayName=User Profile profile.group.account=Account profile.group.organization=Organization @@ -215,9 +353,22 @@ action.Kilo.StopSession.text=Stop Session action.Kilo.StopSession.description=Stop the current Kilo session action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings -action.Kilo.Restart.text=Restart Kilo +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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.Restart.text=Restart action.Kilo.Restart.description=Kill and restart the CLI process -action.Kilo.Reinstall.text=Reinstall Kilo +action.Kilo.Reinstall.text=Reinstall action.Kilo.Reinstall.description=Re-extract the CLI binary and restart action.Kilo.Session.Open.text=Open action.Kilo.Session.Open.description=Open the selected session @@ -226,3 +377,23 @@ action.Kilo.Session.Rename.description=Rename the selected session action.Kilo.Session.Delete.text=Delete action.Kilo.Session.Delete.description=Delete the selected session(s) action.Kilo.History.ContextMenu.text=History Actions +action.Kilo.Session.ContextMenu.text=Session Actions + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 9cb9ee73187..0890f64c2cf 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الا session.empty.loading=جاري التحميل… session.empty.recent=الحديثة session.showHistory=عرض السجل +feedback.button=التغذية الراجعة والدعم +feedback.dialog.message=يسعدنا سماع تعليقاتك أو مساعدتك في حل أي مشكلات تواجهها. +feedback.dialog.github=الإبلاغ عن مشكلة على GitHub +feedback.dialog.discord=الانضمام إلى مجتمع Discord +feedback.dialog.support=دعم العملاء session.scroll.bottom=التمرير إلى الأسفل +session.copy.hover=نسخ +session.copy.copied=تم النسخ session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان @@ -17,7 +24,7 @@ session.permission.title=طلب إذن session.permission.meta=الأداة: {0} • الأنماط: {1} session.permission.allow=سماح session.permission.deny=رفض -session.question.dismiss=رفض +session.question.dismiss=إغلاق session.status.considering=جار التفكير في الخطوات التالية… session.status.thinking=جار التفكير… @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} رمز مستخدم session.header.context.reserved={0} محجوز للإخراج session.header.context.available={0} متاح -prompt.placeholder=اكتب رسالة… -prompt.placeholder.with.shortcuts=اكتب رسالة… ({0} للإرسال، {1} لسطر جديد) -prompt.placeholder.with.send=اكتب رسالة… ({0} للإرسال) -prompt.placeholder.with.newline=اكتب رسالة… ({0} لسطر جديد) +prompt.placeholder=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات +prompt.placeholder.with.shortcuts=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات؛ {0} إرسال، {1} سطر جديد +prompt.placeholder.with.send=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات؛ {0} إرسال +prompt.placeholder.with.newline=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات؛ {0} سطر جديد prompt.button.send=إرسال prompt.button.stop=إيقاف prompt.button.send.tooltip.stop=للإيقاف، اضغط {0} @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=إعادة تسمية الجلسة الم action.Kilo.Session.Delete.text=حذف action.Kilo.Session.Delete.description=حذف الجلسة (الجلسات) المحددة action.Kilo.History.ContextMenu.text=إجراءات السجل +action.Kilo.Session.ContextMenu.text=إجراءات الجلسة + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=تحسين الطلب +prompt.action.enhance.loading=جارٍ تحسين الطلب... +prompt.action.enhance.description=يساعد زر 'تحسين الطلب' في تحسين طلبك بإضافة سياق أو توضيحات أو إعادة صياغة. جرّب كتابة طلب هنا، ثم انقر على الزر مرة أخرى لترى كيف يعمل. +prompt.action.enhance.failed=فشل تحسين الطلب +prompt.action.enhance.failed.description=تعذّر على النموذج الصغير الذي تم إعداده تحسين هذا الطلب. +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=قد تُستخدم البيانات للتدريب +model.picker.dataCollected.current=قد يُستخدم النموذج المحدد حاليًا للتدريب + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=النموذج الخفيف المستخدم لتحسين الطلبات وإنشاء العناوين وغيرها من المهام السريعة. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 a310da70e8e..f26bf14c4f9 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega session.empty.loading=Učitavanje… session.empty.recent=NEDAVNO session.showHistory=Prikaži historiju +feedback.button=Povratne informacije i podrška +feedback.dialog.message=Voljeli bismo čuti vaše povratne informacije ili pomoći s problemima koje doživljavate. +feedback.dialog.github=Prijavite problem na GitHubu +feedback.dialog.discord=Pridružite se našoj Discord zajednici +feedback.dialog.support=Korisnička podrška session.scroll.bottom=Skrolaj na dno +session.copy.hover=Kopiraj +session.copy.copied=Kopirano session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} tokena korišteno session.header.context.reserved={0} rezervisano za izlaz session.header.context.available={0} dostupno -prompt.placeholder=Upišite poruku… -prompt.placeholder.with.shortcuts=Upišite poruku… ({0} za slanje, {1} za novi red) -prompt.placeholder.with.send=Upišite poruku… ({0} za slanje) -prompt.placeholder.with.newline=Upišite poruku… ({0} za novi red) +prompt.placeholder=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke +prompt.placeholder.with.shortcuts=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke; {0} pošalji, {1} novi red +prompt.placeholder.with.send=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke; {0} pošalji +prompt.placeholder.with.newline=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke; {0} novi red prompt.button.send=Pošalji prompt.button.stop=Zaustavi prompt.button.send.tooltip.stop=Da zaustavite, pritisnite {0} @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Preimenujte odabranu sesiju action.Kilo.Session.Delete.text=Obriši action.Kilo.Session.Delete.description=Obrišite odabrane sesije action.Kilo.History.ContextMenu.text=Akcije historije +action.Kilo.Session.ContextMenu.text=Radnje sesije + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=Poboljšaj upit +prompt.action.enhance.loading=Poboljšavanje upita... +prompt.action.enhance.description=Dugme 'Poboljšaj upit' pomaže vam da poboljšate upit dodavanjem dodatnog konteksta, pojašnjenja ili preformulacije. Unesite upit ovdje i ponovo kliknite na dugme da vidite kako funkcioniše. +prompt.action.enhance.failed=Poboljšavanje upita nije uspjelo +prompt.action.enhance.failed.description=Konfigurisani mali model nije uspio poboljšati ovaj upit. +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Podaci se mogu koristiti za obuku +model.picker.dataCollected.current=Trenutno odabrani model može se koristiti za obuku + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Lagani model koji se koristi za poboljšavanje upita, generisanje naslova i druge brze zadatke. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 706e2faf0b6..001b10bad85 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge session.empty.loading=Indlæser… session.empty.recent=SENESTE session.showHistory=Vis historik +feedback.button=Feedback & support +feedback.dialog.message=Vi vil gerne høre din feedback eller hjælpe med eventuelle problemer, du oplever. +feedback.dialog.github=Rapportér et problem på GitHub +feedback.dialog.discord=Deltag i vores Discord-fællesskab +feedback.dialog.support=Kundesupport session.scroll.bottom=Rul til bunden +session.copy.hover=Kopiér +session.copy.copied=Kopieret session.tab.new=Ny session session.tab.untitled=Unavngivet session @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} tokens brugt session.header.context.reserved={0} reserveret til output session.header.context.available={0} tilgængelige -prompt.placeholder=Skriv en besked… -prompt.placeholder.with.shortcuts=Skriv en besked… ({0} for at sende, {1} for ny linje) -prompt.placeholder.with.send=Skriv en besked… ({0} for at sende) -prompt.placeholder.with.newline=Skriv en besked… ({0} for ny linje) +prompt.placeholder=Skriv her; brug / eller @, slip/indsæt filer +prompt.placeholder.with.shortcuts=Skriv her; brug / eller @, slip/indsæt filer; {0} send, {1} ny linje +prompt.placeholder.with.send=Skriv her; brug / eller @, slip/indsæt filer; {0} send +prompt.placeholder.with.newline=Skriv her; brug / eller @, slip/indsæt filer; {0} ny linje prompt.button.send=Send prompt.button.stop=Stop prompt.button.send.tooltip.stop=Tryk på {0} for at stoppe @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Omdøb den valgte session action.Kilo.Session.Delete.text=Slet action.Kilo.Session.Delete.description=Slet den/de valgte session(er) action.Kilo.History.ContextMenu.text=Historikhandlinger +action.Kilo.Session.ContextMenu.text=Sessionshandlinger + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=Forbedr prompt +prompt.action.enhance.loading=Forbedrer prompt... +prompt.action.enhance.description=Knappen 'Forbedr prompt' hjælper med at forbedre din prompt ved at tilføje ekstra kontekst, præciseringer eller omformuleringer. Prøv at skrive en prompt her og klikke på knappen igen for at se, hvordan det fungerer. +prompt.action.enhance.failed=Kunne ikke forbedre prompten +prompt.action.enhance.failed.description=Den konfigurerede lille model kunne ikke forbedre denne prompt. +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Data kan bruges til træning +model.picker.dataCollected.current=Den aktuelt valgte model kan bruges til træning + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Den lette model, der bruges til at forbedre prompts, generere titler og udføre andre hurtige opgaver. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 f604c18c850..2cab50026f4 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Fun session.empty.loading=Wird geladen… session.empty.recent=ZULETZT session.showHistory=Verlauf anzeigen +feedback.button=Feedback & Support +feedback.dialog.message=Wir würden uns freuen, Ihr Feedback zu hören oder Ihnen bei Problemen zu helfen. +feedback.dialog.github=Ein Problem auf GitHub melden +feedback.dialog.discord=Unserer Discord-Community beitreten +feedback.dialog.support=Kundensupport session.scroll.bottom=Zum Ende scrollen +session.copy.hover=Kopieren +session.copy.copied=Kopiert session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} Token verwendet session.header.context.reserved={0} für Ausgabe reserviert session.header.context.available={0} verfügbar -prompt.placeholder=Nachricht eingeben… -prompt.placeholder.with.shortcuts=Nachricht eingeben… ({0} zum Senden, {1} für neue Zeile) -prompt.placeholder.with.send=Nachricht eingeben… ({0} zum Senden) -prompt.placeholder.with.newline=Nachricht eingeben… ({0} für neue Zeile) +prompt.placeholder=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen +prompt.placeholder.with.shortcuts=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen; {0} senden, {1} neue Zeile +prompt.placeholder.with.send=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen; {0} senden +prompt.placeholder.with.newline=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen; {0} neue Zeile prompt.button.send=Senden prompt.button.stop=Stopp prompt.button.send.tooltip.stop=Zum Stoppen drücken Sie {0} @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Ausgewählte Sitzung umbenennen action.Kilo.Session.Delete.text=Löschen action.Kilo.Session.Delete.description=Ausgewählte Sitzung(en) löschen action.Kilo.History.ContextMenu.text=Verlaufsaktionen +action.Kilo.Session.ContextMenu.text=Sitzungsaktionen + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +prompt.action.enhance=Prompt verbessern +prompt.action.enhance.loading=Prompt wird verbessert... +prompt.action.enhance.description=Die Schaltfläche „Prompt verbessern“ hilft Ihnen, Ihren Prompt durch zusätzlichen Kontext, Klarstellungen oder Umformulierungen zu verbessern. Geben Sie hier einen Prompt ein und klicken Sie erneut auf die Schaltfläche, um zu sehen, wie sie funktioniert. +prompt.action.enhance.failed=Prompt konnte nicht verbessert werden +prompt.action.enhance.failed.description=Das konfigurierte kleine Modell konnte diesen Prompt nicht verbessern. +model.picker.dataCollected=Daten können für das Training verwendet werden +model.picker.dataCollected.current=Das aktuell ausgewählte Modell kann für das Training verwendet werden + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Das leichtgewichtige Modell, das zur Verbesserung von Prompts, zur Titelgenerierung und für andere schnelle Aufgaben verwendet wird. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 92fbad3508f..2eb7dbe49df 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida qu session.empty.loading=Cargando… session.empty.recent=RECIENTE session.showHistory=Mostrar historial +feedback.button=Comentarios y soporte +feedback.dialog.message=Nos encantaría escuchar tus comentarios o ayudarte con cualquier problema que estés experimentando. +feedback.dialog.github=Reportar un problema en GitHub +feedback.dialog.discord=Unirse a nuestra comunidad de Discord +feedback.dialog.support=Atención al cliente session.scroll.bottom=Desplazarse al final +session.copy.hover=Copiar +session.copy.copied=Copiado session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} tokens utilizados session.header.context.reserved={0} reservados para la salida session.header.context.available={0} disponibles -prompt.placeholder=Escribe un mensaje… -prompt.placeholder.with.shortcuts=Escribe un mensaje… ({0} para enviar, {1} para nueva línea) -prompt.placeholder.with.send=Escribe un mensaje… ({0} para enviar) -prompt.placeholder.with.newline=Escribe un mensaje… ({0} para nueva línea) +prompt.placeholder=Escribe aquí; usa / o @, suelta/pega archivos +prompt.placeholder.with.shortcuts=Escribe aquí; usa / o @, suelta/pega archivos; {0} enviar, {1} nueva línea +prompt.placeholder.with.send=Escribe aquí; usa / o @, suelta/pega archivos; {0} enviar +prompt.placeholder.with.newline=Escribe aquí; usa / o @, suelta/pega archivos; {0} nueva línea prompt.button.send=Enviar prompt.button.stop=Detener prompt.button.send.tooltip.stop=Para detener, presiona {0} @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Renombrar la sesión seleccionada action.Kilo.Session.Delete.text=Eliminar action.Kilo.Session.Delete.description=Eliminar la(s) sesión(es) seleccionada(s) action.Kilo.History.ContextMenu.text=Acciones del historial +action.Kilo.Session.ContextMenu.text=Acciones de sesión + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +prompt.action.enhance=Mejorar mensaje +prompt.action.enhance.loading=Mejorando mensaje... +prompt.action.enhance.description=El botón «Mejorar mensaje» ayuda a mejorar el mensaje aportando contexto adicional, aclaraciones o una reformulación. Escribe un mensaje aquí y vuelve a hacer clic en el botón para ver cómo funciona. +prompt.action.enhance.failed=No se pudo mejorar el mensaje +prompt.action.enhance.failed.description=El modelo pequeño configurado no pudo mejorar este mensaje. +model.picker.dataCollected=Los datos pueden usarse para entrenamiento +model.picker.dataCollected.current=El modelo seleccionado actualmente puede usarse para entrenamiento + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=El modelo ligero que se utiliza para mejorar mensajes, generar títulos y realizar otras tareas rápidas. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 bf51dd4e426..2f437d5df60 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de c session.empty.loading=Chargement… session.empty.recent=RÉCENT session.showHistory=Afficher l'historique +feedback.button=Commentaires & support +feedback.dialog.message=Nous aimerions recueillir vos commentaires ou vous aider avec les problèmes que vous rencontrez. +feedback.dialog.github=Signaler un problème sur GitHub +feedback.dialog.discord=Rejoindre notre communauté Discord +feedback.dialog.support=Service client session.scroll.bottom=Faire défiler vers le bas +session.copy.hover=Copier +session.copy.copied=Copié session.tab.new=Nouvelle session session.tab.untitled=Session sans titre @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} tokens utilisés session.header.context.reserved={0} réservés pour la sortie session.header.context.available={0} disponibles -prompt.placeholder=Saisir un message… -prompt.placeholder.with.shortcuts=Saisir un message… ({0} pour envoyer, {1} pour nouvelle ligne) -prompt.placeholder.with.send=Saisir un message… ({0} pour envoyer) -prompt.placeholder.with.newline=Saisir un message… ({0} pour nouvelle ligne) +prompt.placeholder=Tapez ici ; utilisez / ou @, déposez/collez des fichiers +prompt.placeholder.with.shortcuts=Tapez ici ; utilisez / ou @, déposez/collez des fichiers ; {0} envoyer, {1} nouvelle ligne +prompt.placeholder.with.send=Tapez ici ; utilisez / ou @, déposez/collez des fichiers ; {0} envoyer +prompt.placeholder.with.newline=Tapez ici ; utilisez / ou @, déposez/collez des fichiers ; {0} nouvelle ligne prompt.button.send=Envoyer prompt.button.stop=Arrêter prompt.button.send.tooltip.stop=Pour arrêter, appuyez sur {0} @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Renommer la session sélectionnée action.Kilo.Session.Delete.text=Supprimer action.Kilo.Session.Delete.description=Supprimer la ou les sessions sélectionnées action.Kilo.History.ContextMenu.text=Actions d'historique +action.Kilo.Session.ContextMenu.text=Actions de session + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +prompt.action.enhance=Améliorer l'invite +prompt.action.enhance.loading=Amélioration de l'invite... +prompt.action.enhance.description=Le bouton « Améliorer l'invite » permet d'améliorer votre invite en ajoutant du contexte, des précisions ou en la reformulant. Saisissez une invite ici, puis cliquez à nouveau sur le bouton pour voir comment il fonctionne. +prompt.action.enhance.failed=Impossible d'améliorer l'invite +prompt.action.enhance.failed.description=Le petit modèle configuré n'a pas pu améliorer cette invite. +model.picker.dataCollected=Les données peuvent être utilisées pour l’entraînement +model.picker.dataCollected.current=Le modèle actuellement sélectionné peut être utilisé pour l’entraînement + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Le modèle léger utilisé pour améliorer les invites, générer des titres et effectuer d'autres tâches rapides. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 c32cc633e9f..552523ec0f3 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo CodeはAIコーディングアシスタントです session.empty.loading=読み込み中… session.empty.recent=最近 session.showHistory=履歴を表示 +feedback.button=フィードバック & サポート +feedback.dialog.message=フィードバックをお聞かせいただくか、問題がある場合はお気軽にご相談ください。 +feedback.dialog.github=GitHubで問題を報告する +feedback.dialog.discord=Discordコミュニティに参加する +feedback.dialog.support=カスタマーサポート session.scroll.bottom=一番下にスクロール +session.copy.hover=コピー +session.copy.copied=コピーしました session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション @@ -62,10 +69,10 @@ session.header.context.used={0} / {1}トークン使用中 session.header.context.reserved={0}は出力用に予約済み session.header.context.available={0}利用可能 -prompt.placeholder=メッセージを入力… -prompt.placeholder.with.shortcuts=メッセージを入力…({0}で送信、{1}で改行) -prompt.placeholder.with.send=メッセージを入力…({0}で送信) -prompt.placeholder.with.newline=メッセージを入力…({0}で改行) +prompt.placeholder=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け +prompt.placeholder.with.shortcuts=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け;{0}で送信、{1}で改行 +prompt.placeholder.with.send=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け;{0}で送信 +prompt.placeholder.with.newline=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け;{0}で改行 prompt.button.send=送信 prompt.button.stop=停止 prompt.button.send.tooltip.stop=停止するには{0}を押してください @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=選択したセッションを変名 action.Kilo.Session.Delete.text=削除 action.Kilo.Session.Delete.description=選択したセッションを削除 action.Kilo.History.ContextMenu.text=履歴アクション +action.Kilo.Session.ContextMenu.text=セッション操作 + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=プロンプトを改善 +prompt.action.enhance.loading=プロンプトを改善中… +prompt.action.enhance.description='プロンプトを改善'ボタンを使うと、コンテキストの追加、内容の明確化、言い換えによってプロンプトを改善できます。ここにプロンプトを入力し、もう一度ボタンをクリックして動作を確認してみてください。 +prompt.action.enhance.failed=プロンプトの改善に失敗しました +prompt.action.enhance.failed.description=設定された小型モデルでは、このプロンプトを改善できませんでした。 +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=データがトレーニングに使用される場合があります +model.picker.dataCollected.current=現在選択されているモデルはトレーニングに使用される場合があります + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=プロンプトの改善やタイトル生成など、短時間で処理できるタスクに使用する軽量モデルです。 +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 893abc95b96..4c22c1e52b8 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 session.empty.loading=로딩 중… session.empty.recent=최근 session.showHistory=기록 보기 +feedback.button=피드백 & 지원 +feedback.dialog.message=피드백을 들려주시거나 겪고 계신 문제에 대해 도움을 드리고 싶습니다. +feedback.dialog.github=GitHub에 이슈 보고하기 +feedback.dialog.discord=Discord 커뮤니티 참여하기 +feedback.dialog.support=고객 지원 session.scroll.bottom=맨 아래로 스크롤 +session.copy.hover=복사 +session.copy.copied=복사됨 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} 토큰 사용 중 session.header.context.reserved={0} 출력용 예약됨 session.header.context.available={0} 사용 가능 -prompt.placeholder=메시지를 입력하세요… -prompt.placeholder.with.shortcuts=메시지를 입력하세요… ({0} 전송, {1} 새 줄) -prompt.placeholder.with.send=메시지를 입력하세요… ({0} 전송) -prompt.placeholder.with.newline=메시지를 입력하세요… ({0} 새 줄) +prompt.placeholder=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기 +prompt.placeholder.with.shortcuts=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기; {0} 전송, {1} 새 줄 +prompt.placeholder.with.send=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기; {0} 전송 +prompt.placeholder.with.newline=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기; {0} 새 줄 prompt.button.send=전송 prompt.button.stop=정지 prompt.button.send.tooltip.stop=정지하려면 {0}을 누르세요 @@ -82,7 +89,7 @@ model.picker.free=무료 model.picker.reset=모델을 기본값으로 재설정 reasoning.picker.tooltip=추론 노력 선택 -history.tab.local=로컈 +history.tab.local=로컬 history.tab.cloud=클라우드 history.back=뒤로 history.search.placeholder=세션 검색 @@ -91,8 +98,8 @@ history.loading=로딩 중… history.untitled=제목 없음 history.delete.text=삭제 history.delete.confirm.title=세션을 삭제하시겠습니까? -history.delete.confirm.message=로컈 기록에서 "{0}"을 삭제하시겠습니까? -history.delete.confirm.message.multiple=로컈 기록에서 {0}개의 세션을 삭제하시겠습니까? +history.delete.confirm.message=로컬 기록에서 "{0}"을 삭제하시겠습니까? +history.delete.confirm.message.multiple=로컬 기록에서 {0}개의 세션을 삭제하시겠습니까? history.rename.title=세션 이름 바꾸기 history.rename.prompt=새 세션 이름: history.cloud.load.more=더 불러오기 @@ -108,7 +115,7 @@ history.time.hours={0}시간 전 history.time.days={0}일 전 history.time.months={0}개월 전 history.time.years={0}년 전 -history.error.local=로컈 기록을 로드하지 못했습니다 +history.error.local=로컬 기록을 로드하지 못했습니다 history.error.cloud=클라우드 기록을 로드하지 못했습니다 history.error.local.delete=세션을 삭제하지 못했습니다 history.error.local.rename=세션의 이름을 바꾸지 못했습니다 @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=선택한 세션 이름 바꾸기 action.Kilo.Session.Delete.text=삭제 action.Kilo.Session.Delete.description=선택한 세션 삭제 action.Kilo.History.ContextMenu.text=기록 작업 +action.Kilo.Session.ContextMenu.text=세션 작업 + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=프롬프트 개선 +prompt.action.enhance.loading=프롬프트 개선 중… +prompt.action.enhance.description='프롬프트 개선' 버튼을 사용하면 컨텍스트를 추가하거나 내용을 명확히 하고 표현을 다듬어 프롬프트를 개선할 수 있습니다. 여기에 프롬프트를 입력한 다음 버튼을 다시 클릭해 작동 방식을 확인해 보세요. +prompt.action.enhance.failed=프롬프트 개선 실패 +prompt.action.enhance.failed.description=설정된 소형 모델이 이 프롬프트를 개선하지 못했습니다. +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=데이터가 학습에 사용될 수 있습니다 +model.picker.dataCollected.current=현재 선택된 모델은 학습에 사용될 수 있습니다 + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=프롬프트 개선, 제목 생성 및 빠르게 처리할 수 있는 기타 작업에 사용되는 경량 모델입니다. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 e88d7960997..e130d8e5d48 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies session.empty.loading=Laden… session.empty.recent=RECENT session.showHistory=Geschiedenis weergeven +feedback.button=Feedback & Ondersteuning +feedback.dialog.message=We horen graag uw feedback of helpen met eventuele problemen die u ervaart. +feedback.dialog.github=Meld een probleem op GitHub +feedback.dialog.discord=Word lid van onze Discord community +feedback.dialog.support=Klantenservice session.scroll.bottom=Naar beneden scrollen +session.copy.hover=Kopiëren +session.copy.copied=Gekopieerd session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} tokens gebruikt session.header.context.reserved={0} gereserveerd voor uitvoer session.header.context.available={0} beschikbaar -prompt.placeholder=Typ een bericht… -prompt.placeholder.with.shortcuts=Typ een bericht… ({0} om te verzenden, {1} voor nieuwe regel) -prompt.placeholder.with.send=Typ een bericht… ({0} om te verzenden) -prompt.placeholder.with.newline=Typ een bericht… ({0} voor nieuwe regel) +prompt.placeholder=Typ hier; gebruik / of @, sleep/plak bestanden +prompt.placeholder.with.shortcuts=Typ hier; gebruik / of @, sleep/plak bestanden; {0} verzenden, {1} nieuwe regel +prompt.placeholder.with.send=Typ hier; gebruik / of @, sleep/plak bestanden; {0} verzenden +prompt.placeholder.with.newline=Typ hier; gebruik / of @, sleep/plak bestanden; {0} nieuwe regel prompt.button.send=Verzenden prompt.button.stop=Stoppen prompt.button.send.tooltip.stop=Druk op {0} om te stoppen @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Geselecteerde sessie hernoemen action.Kilo.Session.Delete.text=Verwijderen action.Kilo.Session.Delete.description=Geselecteerde sessie(s) verwijderen action.Kilo.History.ContextMenu.text=Geschiedenisacties +action.Kilo.Session.ContextMenu.text=Sessieacties + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=Prompt verbeteren +prompt.action.enhance.loading=Prompt wordt verbeterd… +prompt.action.enhance.description=Met de knop 'Prompt verbeteren' kunt u uw prompt verbeteren door extra context toe te voegen, de inhoud te verduidelijken of de tekst anders te formuleren. Typ hier een prompt en klik nogmaals op de knop om te zien hoe het werkt. +prompt.action.enhance.failed=Prompt verbeteren is mislukt +prompt.action.enhance.failed.description=Het geconfigureerde kleine model kon deze prompt niet verbeteren. +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Gegevens kunnen worden gebruikt voor training +model.picker.dataCollected.current=Het momenteel geselecteerde model kan worden gebruikt voor training + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Het lichtgewichtmodel dat wordt gebruikt om prompts te verbeteren, titels te genereren en andere snelle taken uit te voeren. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 e7631a21d2c..e2185e84542 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge fu session.empty.loading=Laster… session.empty.recent=NYLIGE session.showHistory=Vis historikk +feedback.button=Tilbakemelding & støtte +feedback.dialog.message=Vi vil gjerne høre tilbakemeldingene dine eller hjelpe med problemer du opplever. +feedback.dialog.github=Rapporter et problem på GitHub +feedback.dialog.discord=Bli med i Discord-fellesskapet vårt +feedback.dialog.support=Kundestøtte session.scroll.bottom=Rull til bunnen +session.copy.hover=Kopier +session.copy.copied=Kopiert session.tab.new=Ny økt session.tab.untitled=Uten tittel @@ -62,13 +69,18 @@ session.header.context.used={0} / {1} tokens brukt session.header.context.reserved={0} reservert for utdata session.header.context.available={0} tilgjengelig -prompt.placeholder=Skriv en melding… -prompt.placeholder.with.shortcuts=Skriv en melding… ({0} for å sende, {1} for ny linje) -prompt.placeholder.with.send=Skriv en melding… ({0} for å sende) -prompt.placeholder.with.newline=Skriv en melding… ({0} for ny linje) +prompt.placeholder=Skriv her; bruk / eller @, slipp/lim inn filer +prompt.placeholder.with.shortcuts=Skriv her; bruk / eller @, slipp/lim inn filer; {0} send, {1} ny linje +prompt.placeholder.with.send=Skriv her; bruk / eller @, slipp/lim inn filer; {0} send +prompt.placeholder.with.newline=Skriv her; bruk / eller @, slipp/lim inn filer; {0} ny linje prompt.button.send=Send prompt.button.stop=Stopp prompt.button.send.tooltip.stop=Trykk {0} for å stoppe +prompt.action.enhance=Forbedre forespørsel +prompt.action.enhance.loading=Forbedrer forespørselen... +prompt.action.enhance.description=Knappen 'Forbedre forespørsel' hjelper deg med å forbedre forespørselen ved å legge til mer kontekst, presiseringer eller omformuleringer. Skriv inn en forespørsel her, og klikk på knappen igjen for å se hvordan det fungerer. +prompt.action.enhance.failed=Kunne ikke forbedre forespørselen +prompt.action.enhance.failed.description=Den konfigurerte lille modellen kunne ikke forbedre denne forespørselen. mode.picker.tooltip=Velg modus mode.picker.deprecated=utdatert model.picker.tooltip=Velg modell @@ -137,3 +149,181 @@ action.Kilo.Session.Rename.description=Gi valgt økt nytt navn action.Kilo.Session.Delete.text=Slett action.Kilo.Session.Delete.description=Slett valgte økt(er) action.Kilo.History.ContextMenu.text=Historikkhandlinger +action.Kilo.Session.ContextMenu.text=Økthandlinger + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Data kan brukes til trening +model.picker.dataCollected.current=Den valgte modellen kan brukes til trening + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Lettvektsmodellen som brukes til å forbedre forespørsler, generere titler og utføre andre raske oppgaver. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 53ddeb68b15..eff0dfc7e4c 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie session.empty.loading=Ładowanie… session.empty.recent=OSTATNIE session.showHistory=Pokaż historię +feedback.button=Opinie i wsparcie +feedback.dialog.message=Chętnie poznamy Twoją opinię lub pomożemy w przypadku problemów. +feedback.dialog.github=Zgłoś problem na GitHubie +feedback.dialog.discord=Dołącz do naszej społeczności Discord +feedback.dialog.support=Wsparcie klienta session.scroll.bottom=Przewiń na dół +session.copy.hover=Kopiuj +session.copy.copied=Skopiowano session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu @@ -62,13 +69,18 @@ session.header.context.used={0} / {1} tokenów użito session.header.context.reserved={0} zarezerwowane dla wyjścia session.header.context.available={0} dostępne -prompt.placeholder=Wpisz wiadomość… -prompt.placeholder.with.shortcuts=Wpisz wiadomość… ({0} aby wyslać, {1} nowa linia) -prompt.placeholder.with.send=Wpisz wiadomość… ({0} aby wyslać) -prompt.placeholder.with.newline=Wpisz wiadomość… ({0} nowa linia) +prompt.placeholder=Pisz tutaj; użyj / lub @, upuść/wklej pliki +prompt.placeholder.with.shortcuts=Pisz tutaj; użyj / lub @, upuść/wklej pliki; {0} wyślij, {1} nowa linia +prompt.placeholder.with.send=Pisz tutaj; użyj / lub @, upuść/wklej pliki; {0} wyślij +prompt.placeholder.with.newline=Pisz tutaj; użyj / lub @, upuść/wklej pliki; {0} nowa linia prompt.button.send=Wyślij prompt.button.stop=Zatrzymaj prompt.button.send.tooltip.stop=Aby zatrzymać, naciśnij {0} +prompt.action.enhance=Ulepsz prompt +prompt.action.enhance.loading=Ulepszanie promptu... +prompt.action.enhance.description=Przycisk 'Ulepsz prompt' pomaga dopracować prompt, dodając kontekst, wyjaśnienia lub zmieniając jego sformułowanie. Spróbuj wpisać tutaj prompt i ponownie kliknąć przycisk, aby zobaczyć, jak to działa. +prompt.action.enhance.failed=Nie udało się ulepszyć promptu +prompt.action.enhance.failed.description=Skonfigurowany mały model nie mógł ulepszyć tego promptu. mode.picker.tooltip=Wybierz tryb mode.picker.deprecated=przestarzały model.picker.tooltip=Wybierz model @@ -137,3 +149,181 @@ action.Kilo.Session.Rename.description=Zmień nazwę wybranej sesji action.Kilo.Session.Delete.text=Usuń action.Kilo.Session.Delete.description=Usuń wybraną sesję (wybrane sesje) action.Kilo.History.ContextMenu.text=Akcje historii +action.Kilo.Session.ContextMenu.text=Akcje sesji + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Dane mogą być używane do trenowania +model.picker.dataCollected.current=Aktualnie wybrany model może być używany do trenowania + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Lekki model używany do ulepszania promptów, generowania tytułów i wykonywania innych szybkich zadań. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 27c7eb48436..efdff77ef96 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça session.empty.loading=Carregando… session.empty.recent=RECENTE session.showHistory=Mostrar histórico +feedback.button=Feedback e suporte +feedback.dialog.message=Adoraríamos ouvir seu feedback ou ajudar com quaisquer problemas que você esteja enfrentando. +feedback.dialog.github=Reportar um problema no GitHub +feedback.dialog.discord=Entrar na nossa comunidade Discord +feedback.dialog.support=Suporte ao cliente session.scroll.bottom=Rolar para o fim +session.copy.hover=Copiar +session.copy.copied=Copiado session.tab.new=Nova sessão session.tab.untitled=Sessão sem título @@ -62,13 +69,18 @@ session.header.context.used={0} / {1} tokens utilizados session.header.context.reserved={0} reservados para saída session.header.context.available={0} disponíveis -prompt.placeholder=Digite uma mensagem… -prompt.placeholder.with.shortcuts=Digite uma mensagem… ({0} para enviar, {1} para nova linha) -prompt.placeholder.with.send=Digite uma mensagem… ({0} para enviar) -prompt.placeholder.with.newline=Digite uma mensagem… ({0} para nova linha) +prompt.placeholder=Digite aqui; use / ou @, solte/cole arquivos +prompt.placeholder.with.shortcuts=Digite aqui; use / ou @, solte/cole arquivos; {0} enviar, {1} nova linha +prompt.placeholder.with.send=Digite aqui; use / ou @, solte/cole arquivos; {0} enviar +prompt.placeholder.with.newline=Digite aqui; use / ou @, solte/cole arquivos; {0} nova linha prompt.button.send=Enviar prompt.button.stop=Parar prompt.button.send.tooltip.stop=Para parar, pressione {0} +prompt.action.enhance=Melhorar prompt +prompt.action.enhance.loading=Melhorando o prompt... +prompt.action.enhance.description=O botão 'Melhorar prompt' ajuda a aprimorar seu prompt, fornecendo mais contexto, esclarecimentos ou reformulações. Digite um prompt aqui e clique novamente no botão para ver como funciona. +prompt.action.enhance.failed=Falha ao melhorar o prompt +prompt.action.enhance.failed.description=O modelo leve configurado não conseguiu melhorar este prompt. mode.picker.tooltip=Selecionar modo mode.picker.deprecated=obsoleto model.picker.tooltip=Selecionar modelo @@ -137,3 +149,181 @@ action.Kilo.Session.Rename.description=Renomear a sessão selecionada action.Kilo.Session.Delete.text=Excluir action.Kilo.Session.Delete.description=Excluir a(s) sessão(es) selecionada(s) action.Kilo.History.ContextMenu.text=Ações do histórico +action.Kilo.Session.ContextMenu.text=Ações da sessão + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Os dados podem ser usados para treinamento +model.picker.dataCollected.current=O modelo selecionado atualmente pode ser usado para treinamento + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=O modelo leve usado para aprimorar prompts, gerar títulos e realizar outras tarefas rápidas. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 e6bdb4d1c62..0eeee04d8c9 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code — это AI-ассистент по прогр session.empty.loading=Загрузка… session.empty.recent=НЕДАВНИЕ session.showHistory=Показать историю +feedback.button=Отзывы и поддержка +feedback.dialog.message=Мы будем рады услышать ваши отзывы или помочь с любыми возникающими проблемами. +feedback.dialog.github=Сообщить о проблеме на GitHub +feedback.dialog.discord=Присоединиться к нашему Discord +feedback.dialog.support=Служба поддержки session.scroll.bottom=Прокрутить вниз +session.copy.hover=Копировать +session.copy.copied=Скопировано session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия @@ -62,13 +69,18 @@ session.header.context.used={0} / {1} токенов использовано session.header.context.reserved={0} зарезервировано для вывода session.header.context.available={0} доступно -prompt.placeholder=Введите сообщение… -prompt.placeholder.with.shortcuts=Введите сообщение… ({0} — отправить, {1} — новая строка) -prompt.placeholder.with.send=Введите сообщение… ({0} — отправить) -prompt.placeholder.with.newline=Введите сообщение… ({0} — новая строка) +prompt.placeholder=Пишите здесь; используйте / или @, перетащите/вставьте файлы +prompt.placeholder.with.shortcuts=Пишите здесь; используйте / или @, перетащите/вставьте файлы; {0} — отправить, {1} — новая строка +prompt.placeholder.with.send=Пишите здесь; используйте / или @, перетащите/вставьте файлы; {0} — отправить +prompt.placeholder.with.newline=Пишите здесь; используйте / или @, перетащите/вставьте файлы; {0} — новая строка prompt.button.send=Отправить prompt.button.stop=Остановить prompt.button.send.tooltip.stop=Для остановки нажмите {0} +prompt.action.enhance=Улучшить промпт +prompt.action.enhance.loading=Улучшаем промпт… +prompt.action.enhance.description=Кнопка 'Улучшить промпт' помогает улучшить ваш промпт, добавляя контекст, уточнения или переформулировки. Введите промпт и снова нажмите кнопку, чтобы увидеть, как это работает. +prompt.action.enhance.failed=Не удалось улучшить промпт +prompt.action.enhance.failed.description=Настроенная малая модель не смогла улучшить этот промпт. mode.picker.tooltip=Выбрать режим mode.picker.deprecated=устаревший model.picker.tooltip=Выбрать модель @@ -137,3 +149,181 @@ action.Kilo.Session.Rename.description=Переименовать выбранн action.Kilo.Session.Delete.text=Удалить action.Kilo.Session.Delete.description=Удалить выбранную(-ые) сессию(-ии) action.Kilo.History.ContextMenu.text=Действия с историей +action.Kilo.Session.ContextMenu.text=Действия сеанса + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Данные могут использоваться для обучения +model.picker.dataCollected.current=Текущая выбранная модель может использоваться для обучения + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Лёгкая модель, используемая для улучшения промптов, создания заголовков и других быстрых задач. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 049b1e1d1ab..a5184f76a91 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโ session.empty.loading=กำลังโหลด… session.empty.recent=ล่าสุด session.showHistory=แสดงประวัติ +feedback.button=ข้อเสนอแนะและการสนับสนุน +feedback.dialog.message=เรายินดีรับฟังข้อเสนอแนะของคุณหรือช่วยแก้ไขปัญหาที่คุณพบ +feedback.dialog.github=รายงานปัญหาบน GitHub +feedback.dialog.discord=เข้าร่วมชุมชน Discord ของเรา +feedback.dialog.support=ฝ่ายสนับสนุนลูกค้า session.scroll.bottom=เลื่อนไปด้านล่าง +session.copy.hover=คัดลอก +session.copy.copied=คัดลอกแล้ว session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ @@ -62,13 +69,18 @@ session.header.context.used=ใช้แล้ว {0} / {1} โทเคน session.header.context.reserved={0} สำรองไว้สำหรับเอาต์พุต session.header.context.available={0} พร้อมใช้งาน -prompt.placeholder=พิมพ์ข้อความ… -prompt.placeholder.with.shortcuts=พิมพ์ข้อความ… ({0} เพื่อส่ง {1} สำหรับบรรทัดใหม่) -prompt.placeholder.with.send=พิมพ์ข้อความ… ({0} เพื่อส่ง) -prompt.placeholder.with.newline=พิมพ์ข้อความ… ({0} สำหรับบรรทัดใหม่) +prompt.placeholder=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์ +prompt.placeholder.with.shortcuts=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์; {0} ส่ง, {1} บรรทัดใหม่ +prompt.placeholder.with.send=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์; {0} ส่ง +prompt.placeholder.with.newline=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์; {0} บรรทัดใหม่ prompt.button.send=ส่ง prompt.button.stop=หยุด prompt.button.send.tooltip.stop=กด {0} เพื่อหยุด +prompt.action.enhance=ปรับปรุงพรอมต์ +prompt.action.enhance.loading=กำลังปรับปรุงพรอมต์… +prompt.action.enhance.description=ปุ่ม 'ปรับปรุงพรอมต์' ช่วยปรับปรุงพรอมต์ของคุณโดยเพิ่มบริบท ทำให้ชัดเจนขึ้น หรือเรียบเรียงใหม่ ลองพิมพ์พรอมต์ที่นี่แล้วคลิกปุ่มอีกครั้งเพื่อดูว่าทำงานอย่างไร +prompt.action.enhance.failed=ปรับปรุงพรอมต์ไม่สำเร็จ +prompt.action.enhance.failed.description=โมเดลขนาดเล็กที่กำหนดค่าไว้ไม่สามารถปรับปรุงพรอมต์นี้ได้ mode.picker.tooltip=เลือกโหมด mode.picker.deprecated=ล้าสมัย model.picker.tooltip=เลือกโมเดล @@ -137,3 +149,181 @@ action.Kilo.Session.Rename.description=เปลี่ยนชื่อเซ action.Kilo.Session.Delete.text=ลบ action.Kilo.Session.Delete.description=ลบเซสชันที่เลือก action.Kilo.History.ContextMenu.text=การดำเนินการประวัติ +action.Kilo.Session.ContextMenu.text=การดำเนินการเซสชัน + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=ข้อมูลอาจถูกใช้สำหรับการฝึก +model.picker.dataCollected.current=โมเดลที่เลือกอยู่ในขณะนี้อาจถูกใช้สำหรับการฝึก + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=โมเดลน้ำหนักเบาที่ใช้สำหรับปรับปรุงพรอมต์ สร้างชื่อเรื่อง และทำงานด่วนอื่นๆ +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 096fcb1ee94..f9efbf5b189 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik session.empty.loading=Yükleniyor… session.empty.recent=SON session.showHistory=Geçmişi göster -session.scroll.bottom=En alta kaýr +feedback.button=Geri Bildirim ve Destek +feedback.dialog.message=Geri bildiriminizi almaktan veya yaşadığınız sorunlarda yardımcı olmaktan mutluluk duyarız. +feedback.dialog.github=GitHub'da sorun bildirin +feedback.dialog.discord=Discord topluluğumuza katılın +feedback.dialog.support=Müşteri Desteği +session.scroll.bottom=En alta kaydır +session.copy.hover=Kopyala +session.copy.copied=Kopyalandı session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum @@ -62,13 +69,18 @@ session.header.context.used={0} / {1} jeton kullanıldı session.header.context.reserved={0} çıkış için ayrıldı session.header.context.available={0} kullanılabilir -prompt.placeholder=Bir mesaj yazın… -prompt.placeholder.with.shortcuts=Bir mesaj yazın… ({0} göndermek için, {1} yeni satır için) -prompt.placeholder.with.send=Bir mesaj yazın… ({0} göndermek için) -prompt.placeholder.with.newline=Bir mesaj yazın… ({0} yeni satır için) +prompt.placeholder=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın +prompt.placeholder.with.shortcuts=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın; {0} gönder, {1} yeni satır +prompt.placeholder.with.send=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın; {0} gönder +prompt.placeholder.with.newline=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın; {0} yeni satır prompt.button.send=Gönder prompt.button.stop=Durdur prompt.button.send.tooltip.stop=Durdurmak için {0} basın +prompt.action.enhance=İstemi iyileştir +prompt.action.enhance.loading=İstem iyileştiriliyor… +prompt.action.enhance.description='İstemi İyileştir' düğmesi, ek bağlam, açıklama veya farklı bir ifade sunarak isteminizi iyileştirmenize yardımcı olur. Nasıl çalıştığını görmek için buraya bir istem yazıp düğmeye tekrar tıklayın. +prompt.action.enhance.failed=İstem iyileştirilemedi +prompt.action.enhance.failed.description=Yapılandırılan küçük model bu istemi iyileştiremedi. mode.picker.tooltip=Mod seç mode.picker.deprecated=geçersiz model.picker.tooltip=Model seç @@ -137,3 +149,181 @@ action.Kilo.Session.Rename.description=Seçilen oturumu yeniden adlandır action.Kilo.Session.Delete.text=Sil action.Kilo.Session.Delete.description=Seçilen oturumları sil action.Kilo.History.ContextMenu.text=Geçmiş eylemleri +action.Kilo.Session.ContextMenu.text=Oturum eylemleri + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Veriler eğitim için kullanılabilir +model.picker.dataCollected.current=Geçerli seçili model eğitim için kullanılabilir + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=İstem iyileştirme, başlık oluşturma ve diğer hızlı görevler için kullanılan hafif model. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 73d770b3c05..6d3efd7480c 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code — це AI-асистент для програ session.empty.loading=Завантаження… session.empty.recent=НЕДАВНІ session.showHistory=Показати історію +feedback.button=Зворотний зв'язок і підтримка +feedback.dialog.message=Ми раді отримати ваш відгук або допомогти з будь-якими проблемами, які у вас виникли. +feedback.dialog.github=Повідомити про проблему на GitHub +feedback.dialog.discord=Приєднатися до нашої спільноти Discord +feedback.dialog.support=Служба підтримки клієнтів session.scroll.bottom=Прокрутити донизу +session.copy.hover=Копіювати +session.copy.copied=Скопійовано session.tab.new=Нова сесія session.tab.untitled=Сесія без назви @@ -45,7 +52,7 @@ session.error.compact=Помилка стиснення сесії session.error.unknown=Невідома помилка session.header.tokens=Токени -session.header.tokens.description=Токени, використані останній відповід䍎ю асистента: вхід, вихід, запис у кеш і читання з кешу. +session.header.tokens.description=Токени, використані останньою відповіддю асистента: вхід, вихід, запис у кеш і читання з кешу. session.header.input=вхід {0} session.header.output=вихід {0} session.header.cache.write=запис у кеш {0} @@ -62,10 +69,10 @@ session.header.context.used={0} / {1} токенів використано session.header.context.reserved={0} зарезервовано для виходу session.header.context.available={0} доступно -prompt.placeholder=Введіть повідомлення… -prompt.placeholder.with.shortcuts=Введіть повідомлення… ({0} — надіслати, {1} — новий рядок) -prompt.placeholder.with.send=Введіть повідомлення… ({0} — надіслати) -prompt.placeholder.with.newline=Введіть повідомлення… ({0} — новий рядок) +prompt.placeholder=Пишіть тут; використовуйте / або @, перетягніть/вставте файли +prompt.placeholder.with.shortcuts=Пишіть тут; використовуйте / або @, перетягніть/вставте файли; {0} — надіслати, {1} — новий рядок +prompt.placeholder.with.send=Пишіть тут; використовуйте / або @, перетягніть/вставте файли; {0} — надіслати +prompt.placeholder.with.newline=Пишіть тут; використовуйте / або @, перетягніть/вставте файли; {0} — новий рядок prompt.button.send=Надіслати prompt.button.stop=Зупинити prompt.button.send.tooltip.stop=Щоб зупинити, натисніть {0} @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=Перейменувати вибрану action.Kilo.Session.Delete.text=Видалити action.Kilo.Session.Delete.description=Видалити вибрану(-і) сесію(-ї) action.Kilo.History.ContextMenu.text=Дії з історією +action.Kilo.Session.ContextMenu.text=Дії сеансу + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=Покращити запит +prompt.action.enhance.loading=Покращення запиту... +prompt.action.enhance.description=Кнопка 'Покращити запит' допомагає вдосконалити ваш запит, надаючи додатковий контекст, уточнення або перефразування. Введіть запит тут і натисніть кнопку ще раз, щоб побачити, як це працює. +prompt.action.enhance.failed=Не вдалося покращити запит +prompt.action.enhance.failed.description=Налаштована мала модель не змогла покращити цей запит. +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=Дані можуть використовуватися для навчання +model.picker.dataCollected.current=Поточна вибрана модель може використовуватися для навчання + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=Легка модель, що використовується для покращення запитів, генерування заголовків та інших швидких завдань. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 2288060553f..eea4f6d35a0 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 @@ -9,7 +9,14 @@ session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功 session.empty.loading=加载中… session.empty.recent=最近 session.showHistory=显示历史 +feedback.button=反馈与支持 +feedback.dialog.message=我们很乐意听取您的反馈,或帮助解决您遇到的任何问题。 +feedback.dialog.github=在 GitHub 上报告问题 +feedback.dialog.discord=加入我们的 Discord 社区 +feedback.dialog.support=客户支持 session.scroll.bottom=滚动到底部 +session.copy.hover=复制 +session.copy.copied=已复制 session.tab.new=新建会话 session.tab.untitled=无标题会话 @@ -62,10 +69,10 @@ session.header.context.used=已使用 {0} / {1} 令牌 session.header.context.reserved={0} 为输出预留 session.header.context.available={0} 可用 -prompt.placeholder=输入消息… -prompt.placeholder.with.shortcuts=输入消息…({0} 发送,{1} 换行) -prompt.placeholder.with.send=输入消息…({0} 发送) -prompt.placeholder.with.newline=输入消息…({0} 换行) +prompt.placeholder=在此输入;使用 / 或 @,拖放/粘贴文件 +prompt.placeholder.with.shortcuts=在此输入;使用 / 或 @,拖放/粘贴文件;{0} 发送,{1} 换行 +prompt.placeholder.with.send=在此输入;使用 / 或 @,拖放/粘贴文件;{0} 发送 +prompt.placeholder.with.newline=在此输入;使用 / 或 @,拖放/粘贴文件;{0} 换行 prompt.button.send=发送 prompt.button.stop=停止 prompt.button.send.tooltip.stop=按 {0} 停止 @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=重命名所选会话 action.Kilo.Session.Delete.text=删除 action.Kilo.Session.Delete.description=删除所选会话 action.Kilo.History.ContextMenu.text=历史操作 +action.Kilo.Session.ContextMenu.text=会话操作 + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=优化提示词 +prompt.action.enhance.loading=正在优化提示词... +prompt.action.enhance.description=“优化提示词”按钮可通过补充上下文、澄清内容或重新措辞来改进您的提示词。请在此输入提示词,然后再次点击该按钮,即可查看效果。 +prompt.action.enhance.failed=提示词优化失败 +prompt.action.enhance.failed.description=已配置的小模型无法优化此提示词。 +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=数据可能用于训练 +model.picker.dataCollected.current=当前选择的模型可能用于训练 + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=用于优化提示词、生成标题和执行其他快速任务的轻量级模型。 +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue 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 40fb78179b3..4c771f8861f 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 @@ -1,7 +1,7 @@ session.connection.connecting=載入中… session.connection.error.app=連線失敗 session.connection.error.workspace=工作區載入失敗 -session.connection.error.unknown=未知預設 +session.connection.error.unknown=未知錯誤 session.connection.retry=重試 session.connection.warning.config=設定警告 @@ -9,12 +9,19 @@ session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、 session.empty.loading=載入中… session.empty.recent=最近 session.showHistory=顯示歷史 +feedback.button=意見回饋與支援 +feedback.dialog.message=我們很樂意聆聽您的意見回饋,或協助解決您遇到的任何問題。 +feedback.dialog.github=在 GitHub 上回報問題 +feedback.dialog.discord=加入我們的 Discord 社群 +feedback.dialog.support=客戶支援 session.scroll.bottom=滾動到底部 +session.copy.hover=複製 +session.copy.copied=已複製 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 session.permission.title=權限請求 -session.permission.meta=工具:{0} • 樣式:{1} +session.permission.meta=工具:{0} • 模式:{1} session.permission.allow=允許 session.permission.deny=拒絕 session.question.dismiss=關閉 @@ -62,10 +69,10 @@ session.header.context.used=已使用 {0} / {1} 記號 session.header.context.reserved={0} 為輸出預留 session.header.context.available={0} 可用 -prompt.placeholder=輸入訊息… -prompt.placeholder.with.shortcuts=輸入訊息…({0} 發送,{1} 換行) -prompt.placeholder.with.send=輸入訊息…({0} 發送) -prompt.placeholder.with.newline=輸入訊息…({0} 換行) +prompt.placeholder=在此輸入;使用 / 或 @,拖放/貼上檔案 +prompt.placeholder.with.shortcuts=在此輸入;使用 / 或 @,拖放/貼上檔案;{0} 發送,{1} 換行 +prompt.placeholder.with.send=在此輸入;使用 / 或 @,拖放/貼上檔案;{0} 發送 +prompt.placeholder.with.newline=在此輸入;使用 / 或 @,拖放/貼上檔案;{0} 換行 prompt.button.send=發送 prompt.button.stop=停止 prompt.button.send.tooltip.stop=按 {0} 停止 @@ -137,3 +144,186 @@ action.Kilo.Session.Rename.description=重命名所選工作階段 action.Kilo.Session.Delete.text=刪除 action.Kilo.Session.Delete.description=刪除所選工作階段 action.Kilo.History.ContextMenu.text=歷史操作 +action.Kilo.Session.ContextMenu.text=工作階段操作 + + +notification.group.kilo=Kilo Code + +session.account.balance=Balance: {0} +session.account.switcher=Switch account +session.scroll.question=Scroll to question + +session.permission.title.subagent=Permission required (subagent) +session.permission.run=Run +session.permission.command=Command +session.permission.patterns={0}: +session.permission.diff=Changes +session.permission.diff.summary=+{0} -{1} +session.permission.no.details={0} requires permission. +session.permission.responding=Sending response... +session.permission.error=Failed to send permission response +session.permission.tool.read=Read +session.permission.tool.edit=Edit +session.permission.tool.write=Write +session.permission.tool.patch=Patch +session.permission.tool.multiedit=Edit +session.permission.tool.glob=Glob Search +session.permission.tool.grep=Grep Search +session.permission.tool.list=List +session.permission.tool.bash=Shell +session.permission.tool.external_directory=External Directory +session.permission.tool.webfetch=Web Fetch +session.permission.tool.websearch=Web Search +session.permission.tool.codesearch=Code Search +session.permission.tool.todoread=Read Todo List +session.permission.tool.todowrite=Update Todo List +session.permission.tool.task=Task +session.permission.tool.skill=Skill +session.permission.tool.lsp=Language Server +session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers +session.question.review=Review +session.question.review.title=Review your answers +session.question.review.notAnswered=(not answered) +session.question.result.title=Questions +session.question.result.answered={0} answered +session.question.custom.label=Add your own response +session.question.custom.placeholder=Type your response... + +session.status.retry=Retrying connection... +session.status.offline=Connection offline + +session.part.plan.ready=Plan is ready +session.part.todo.title=To-dos +session.part.todo.hidden.earlier.one={0} earlier to-do hidden +session.part.todo.hidden.earlier.many={0} earlier to-dos hidden +session.part.todo.hidden.later.one={0} later to-do hidden +session.part.todo.hidden.later.many={0} later to-dos hidden + + +session.login.required.title=You need to sign in to use this model +session.login.required.description=Go to User Profile settings to sign in, then continue this session. +session.login.required.button=Open User Profile +session.login.required.dismiss=Dismiss + +session.header.todos.toggle=Toggle to-dos + +prompt.action.enhance=改善提示詞 +prompt.action.enhance.loading=正在改善提示詞... +prompt.action.enhance.description=「改善提示詞」按鈕可透過補充上下文、釐清內容或改寫措辭,協助改善您的提示詞。請在此輸入提示詞,然後再次按一下該按鈕,即可查看效果。 +prompt.action.enhance.failed=無法改善提示詞 +prompt.action.enhance.failed.description=已設定的小模型無法改善此提示詞。 +prompt.action.autoApprove.enable=Enable auto-approve +prompt.action.autoApprove.disable=Disable auto-approve +prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically. +prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically. +model.picker.dataCollected=資料可能會用於訓練 +model.picker.dataCollected.current=目前選取的模型可能會用於訓練 + +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question + +action.Kilo.ShowProfile.text=Profile +action.Kilo.ShowProfile.description=Open Kilo user profile settings +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.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=用於改善提示詞、產生標題及執行其他快速任務的輕量模型。 +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset +settings.profile.displayName=User Profile +profile.group.account=Account +profile.group.organization=Organization +profile.label.account=Active account: +profile.notLoggedIn=Not logged in +profile.status.connecting=Connecting to Kilo... +profile.status.error=Connection error +profile.balance.title=BALANCE +profile.personalAccount=Personal Account +profile.switchingAccount=Switching account... +profile.action.login=Login with Kilo Code +profile.action.logout=Log Out +profile.action.dashboard=Dashboard +profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... +profile.login.signingIn=Signing in to Kilo Code +profile.login.urlLabel=Open this URL: +profile.login.codeLabel=Enter this code: +profile.login.waiting=Waiting for authorization... +profile.login.cancel=Cancel +profile.login.starting=Starting login... +profile.login.title=Sign in to Kilo Code +profile.login.step.one=Step 1: +profile.login.step.url=Open this URL +profile.login.copyUrl=Copy URL +profile.login.openBrowser=Open Browser +profile.login.qr=QR Code +profile.login.qr.description=Scan to open the sign-in URL +profile.login.step.two=Step 2: +profile.login.step.code=Enter this code +profile.login.clickToCopy=Click to copy +profile.login.waitingTimed=Waiting for authorization... ({0}) +profile.login.failed=Login failed +profile.login.tryAgain=Try Again +profile.login.urlCopied=URL copied to clipboard +profile.login.codeCopied=Code copied to clipboard +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +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 + +# Migration wizard +migration.migrate.title=Migrate Your Settings +migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over. +migration.empty=Nothing to migrate was found in the legacy settings. +migration.keep_legacy_settings=Keep legacy settings file + +migration.row.providers=Provider API Keys +migration.row.mcp=MCP Servers +migration.row.modes=Custom Modes / Agents +migration.row.sessions=Chat Sessions & History +migration.row.model=Default Model +migration.row.settings=Auto-Approval, Language & Autocomplete + +migration.button.skip=Skip +migration.button.migrate=Migrate Settings +migration.button.migrating=Migrating... +migration.button.done=Done +migration.button.continue=Continue diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index cfd34206eae..f86d9c65ff7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -414,9 +414,11 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("id=\"Kilo.Session.Rename\"")) assertTrue(xml.contains("id=\"Kilo.Session.Delete\"")) assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\"")) + assertTrue(xml.contains("id=\"Kilo.Session.ContextMenu\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Open\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Rename\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Delete\"")) + assertTrue(xml.contains("ref=\"${'$'}Copy\"")) } // ------ Helpers ------ 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 new file mode 100644 index 00000000000..5ab38d4ed47 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -0,0 +1,185 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.replaceService +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow + +@Suppress("UnstableApiUsage") +class KiloRecoveryActionsTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeWorkspaceRpcApi() + ApplicationManager.getApplication().replaceService( + KiloWorkspaceService::class.java, + KiloWorkspaceService(scope, rpc), + testRootDisposable, + ) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test restart action stays enabled for all app states`() { + val action = RestartKiloAction() + val event = event(action) + + update(action, event) + + assertTrue("Restart should force-enable recovery action", event.presentation.isEnabled) + } + + fun `test reinstall action stays enabled for all app states`() { + val action = ReinstallKiloAction() + val event = event(action) + + update(action, event) + + assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled) + } + + fun `test cli group has visible menu text`() { + 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("")) + assertFalse(xml.contains("")) + } + + fun `test local config action says open when target exists`() { + rpc.localConfigPath = "/test/.kilo/kilo.jsonc" + rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" + rpc.localConfigExists = true + service().localConfig["/test"] = ConfigTargetDto("/test/.kilo/kilo.jsonc", "~/.kilo/kilo.jsonc", true) + val action = OpenLocalConfigAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabled) + assertEquals("Open: local ~/.kilo/kilo.jsonc", event.presentation.text) + assertEquals(0, rpc.localConfigPathCalls) + } + + fun `test local config action says create when target is missing`() { + rpc.localConfigPath = "/test/.kilo/kilo.jsonc" + rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" + rpc.localConfigExists = false + service().localConfig["/test"] = ConfigTargetDto("/test/.kilo/kilo.jsonc", "~/.kilo/kilo.jsonc", false) + val action = OpenLocalConfigAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabled) + assertEquals("Create: local ~/.kilo/kilo.jsonc", event.presentation.text) + assertEquals(0, rpc.localConfigPathCalls) + } + + fun `test global config action says open when target exists`() { + rpc.globalConfigPath = "/config/kilo.jsonc" + rpc.globalConfigDisplayPath = "~/.config/kilo/kilo.jsonc" + rpc.globalConfigExists = true + cacheGlobal(ConfigTargetDto("/config/kilo.jsonc", "~/.config/kilo/kilo.jsonc", true)) + val action = OpenGlobalConfigAction() + val event = event(action) + + update(action, event) + + assertEquals("Open: global ~/.config/kilo/kilo.jsonc", event.presentation.text) + assertEquals(0, rpc.globalConfigPathCalls) + } + + fun `test global config action says create when target is missing`() { + rpc.globalConfigPath = "/config/kilo.jsonc" + rpc.globalConfigDisplayPath = "~/.config/kilo/kilo.jsonc" + rpc.globalConfigExists = false + cacheGlobal(ConfigTargetDto("/config/kilo.jsonc", "~/.config/kilo/kilo.jsonc", false)) + val action = OpenGlobalConfigAction() + val event = event(action) + + update(action, event) + + assertEquals("Create: global ~/.config/kilo/kilo.jsonc", event.presentation.text) + assertEquals(0, rpc.globalConfigPathCalls) + } + + fun `test local config action disables without directory`() { + val action = OpenLocalConfigAction() + val event = event(action) + + update(action, event) + + assertFalse(event.presentation.isEnabled) + assertEquals(0, rpc.localConfigPathCalls) + } + + private fun event(action: AnAction, workspace: Workspace? = null): AnActionEvent { + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + presentation.isEnabled = false + return AnActionEvent.createFromDataContext("", presentation, context(workspace)) + } + + private fun update(action: AnAction, event: AnActionEvent) { + ApplicationManager.getApplication().executeOnPooledThread { + ActionUtil.updateAction(action, event) + }.get() + } + + private fun service(): KiloWorkspaceService = ApplicationManager.getApplication().getService(KiloWorkspaceService::class.java) + + private fun cacheGlobal(target: ConfigTargetDto) { + val field = KiloWorkspaceService::class.java.getDeclaredField("globalConfig") + field.isAccessible = true + field.set(service(), target) + } + + private fun context(workspace: Workspace?): DataContext { + return DataContext { id -> + when (id) { + SessionManager.WORKSPACE_KEY.name -> workspace + CommonDataKeys.PROJECT.name -> project.takeIf { workspace != null } + else -> null + } + } + } + + private fun workspace(dir: String): Workspace { + return Workspace( + dir, + MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)), + reload = {}, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt index d3a670bf208..f4e92f12db7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/SendPromptActionTest.kt @@ -56,6 +56,19 @@ class SendPromptActionTest : BasePlatformTestCase() { assertTrue(action.promote(listOf(action), absent).isEmpty()) } + fun `test frontend descriptor registers send prompt shortcuts`() { + val xml = javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml") + ?.bufferedReader() + ?.use { it.readText() } + ?: error("missing frontend descriptor") + + assertTrue(xml.contains("id=\"Kilo.SendPrompt\"")) + assertTrue(xml.contains("first-keystroke=\"ENTER\"")) + assertTrue(xml.contains("keymap=\"Mac OS X 10.5+\"")) + assertTrue(xml.contains("first-keystroke=\"meta ENTER\"")) + assertTrue(xml.contains("first-keystroke=\"control ENTER\"")) + } + private fun event(action: SendPromptAction, ctx: SendPromptContext?): AnActionEvent { val presentation = Presentation().apply { copyFrom(action.templatePresentation) } return AnActionEvent.createFromDataContext("", presentation, context(ctx)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt index 3a6ae7a3d99..b8115a662e4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -89,6 +89,15 @@ class KiloSessionServiceTest : BasePlatformTestCase() { assertTrue(service.sessions.value.any { it.id == "ses_2" }) } + fun `test enhance prompt delegates directory and text`() = runBlocking(Dispatchers.Default) { + rpc.enhanced = "Use a focused implementation plan" + + val result = service.enhancePrompt("/workspace", "make a plan") + + assertEquals("Use a focused implementation plan", result) + assertEquals(listOf("/workspace" to "make a plan"), rpc.enhancements) + } + private fun session(id: String, title: String) = SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt new file mode 100644 index 00000000000..5300e74ae8b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Suppress("UnstableApiUsage") +class KiloWorkspaceServiceTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var service: KiloWorkspaceService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeWorkspaceRpcApi() + service = KiloWorkspaceService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test openPath opens first file match`() = runBlocking { + rpc.fileMatches = listOf( + WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"), + WorkspaceFileDto("/other/.kilo/plans/a.md", "a.md"), + ) + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/a.md") + } + + assertTrue(ok) + assertEquals(listOf("/test" to ".kilo/plans/a.md"), rpc.fileCalls) + assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) + } + + fun `test openPath returns false when no match exists`() = runBlocking { + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/missing.md") + } + + assertFalse(ok) + assertEquals(listOf("/test" to ".kilo/plans/missing.md"), rpc.fileCalls) + assertTrue(rpc.opened.isEmpty()) + } + + fun `test openPath returns false when backend open fails`() = runBlocking { + rpc.fileMatches = listOf(WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md")) + rpc.openResult = false + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/a.md") + } + + assertFalse(ok) + assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) + } + + fun `test searchFiles rethrows cancellation`() = runBlocking { + val err = CancellationException("stale completion") + rpc.search = { throw err } + + val seen = try { + withContext(Dispatchers.Default) { + service.searchFiles("/test", "dep") + } + fail("expected cancellation") + null + } catch (e: CancellationException) { + e + } + + assertEquals(err.message, seen?.message) + assertEquals(listOf("dep"), rpc.searchQueries) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/FakeMigrationUiController.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/FakeMigrationUiController.kt new file mode 100644 index 00000000000..da3eddb9280 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/FakeMigrationUiController.kt @@ -0,0 +1,37 @@ +package ai.kilocode.client.migration + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Fake [MigrationUiController] for UI tests. + * + * Push state changes by setting [_state]. + * Track calls via [checks], [starts], [skips], [finishes]. + */ +class FakeMigrationUiController : MigrationUiController { + + val _state = MutableStateFlow(MigrationUiState.Hidden) + override val state: StateFlow = _state + + val checks = mutableListOf() + val starts = mutableListOf() + val skips = mutableListOf() + val finishes = mutableListOf() + + override fun check() { + checks.add(Unit) + } + + override fun start(selections: MigrationUiSelections) { + starts.add(selections) + } + + override fun skip() { + skips.add(Unit) + } + + override fun finish() { + finishes.add(Unit) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt new file mode 100644 index 00000000000..46f19ee0ee4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/KiloMigrationServiceTest.kt @@ -0,0 +1,251 @@ +package ai.kilocode.client.migration + +import ai.kilocode.client.testing.FakeMigrationRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.LegacyAutocompleteSettingsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import ai.kilocode.rpc.dto.LegacySettingsDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationProviderInfoDto +import ai.kilocode.rpc.dto.MigrationSessionInfoDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +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.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking + +@Suppress("UnstableApiUsage") +class KiloMigrationServiceTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeMigrationRpcApi + private lateinit var service: KiloMigrationService + private lateinit var app: MutableStateFlow + private val autocomplete = mutableListOf() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeMigrationRpcApi() + app = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)) + autocomplete.clear() + service = KiloMigrationService(scope, rpc, app) { autocomplete.add(it) } + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + private fun settle() = runBlocking { + repeat(3) { + delay(50) + UIUtil.dispatchAllInvocationEvents() + } + } + + fun `test migration required app state shows needed without polling`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + settle() + assertEquals(0, rpc.statusCalls.size) + assertEquals(0, rpc.detectCalls.size) + assertTrue("state should be Needed", service.state.value is MigrationUiState.Needed) + } + + fun `test ready app state hides migration`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + settle() + app.value = KiloAppStateDto(KiloAppStatusDto.READY) + settle() + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test duplicate migration required does not reset running migration`() { + val detection = sampleDetection() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = detection) + settle() + service.start(MigrationUiSelections(providers = listOf("profile1"))) + settle() + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = detection) + settle() + val state = service.state.value as MigrationUiState.Needed + assertEquals(MigrationUiPhase.migrating, state.phase) + } + + fun `test skip marks status and hides`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + settle() + service.skip() + settle() + assertEquals(1, rpc.skipCalls.size) + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test finish calls finalize and hides`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + settle() + service.finish() + settle() + assertEquals(1, rpc.finalizeCalls.size) + assertEquals(LegacyMigrationStatusDto.completed, rpc.finalizeCalls[0]) + assertEquals(0, rpc.cleanupCalls.size) + assertEquals(MigrationUiState.Hidden, service.state.value) + } + + fun `test finish after unchecked keep file cleans up legacy settings file`() { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + settle() + service.start(MigrationUiSelections(providers = listOf("profile1"), keepLegacySettingsFile = false)) + settle() + + service.finish() + settle() + + assertEquals(1, rpc.finalizeCalls.size) + assertEquals(1, rpc.cleanupCalls.size) + val targets = rpc.cleanupCalls[0] + assertTrue(targets.providerProfiles) + assertTrue(targets.mcpSettings) + assertTrue(targets.customModes) + assertTrue(targets.globalState) + assertTrue(targets.taskHistory) + assertTrue(targets.legacySettingsFile) + } + + fun `test start emits migrating state and initial pending progress`() = runBlocking { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val selections = MigrationUiSelections(providers = listOf("profile1")) + service.start(selections) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value + assertTrue("should be Needed after start", state is MigrationUiState.Needed) + val needed = state as MigrationUiState.Needed + assertEquals(MigrationUiPhase.migrating, needed.phase) + assertTrue(needed.running) + assertTrue("should have initial progress entries", needed.progress.isNotEmpty()) + } + + fun `test complete event without errors sets done phase`() = runBlocking { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val selections = MigrationUiSelections(providers = listOf("profile1")) + service.start(selections) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + val items = listOf(LegacyMigrationResultItemDto("profile1", MigrationItemCategoryDto.provider, MigrationItemStatusDto.success)) + rpc.events.emit(LegacyMigrationEventDto.Complete(items)) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value as? MigrationUiState.Needed + assertNotNull(state) + assertEquals(MigrationUiPhase.done, state!!.phase) + assertFalse(state.running) + } + + fun `test complete event with errors sets error phase`() = runBlocking { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection()) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val selections = MigrationUiSelections(providers = listOf("profile1")) + service.start(selections) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + val items = listOf(LegacyMigrationResultItemDto("profile1", MigrationItemCategoryDto.provider, MigrationItemStatusDto.error, "bad key")) + rpc.events.emit(LegacyMigrationEventDto.Complete(items)) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value as? MigrationUiState.Needed + assertNotNull(state) + assertEquals(MigrationUiPhase.error, state!!.phase) + } + + fun `test complete event without items finalizes pending session progress`() = runBlocking { + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = sampleDetection().copy( + sessions = listOf(MigrationSessionInfoDto("ses_1", "Session", "/tmp", 1L)), + )) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + service.start(MigrationUiSelections(sessions = listOf("ses_1"))) + delay(50) + UIUtil.dispatchAllInvocationEvents() + + rpc.events.emit(LegacyMigrationEventDto.Complete(emptyList())) + delay(100) + UIUtil.dispatchAllInvocationEvents() + + val state = service.state.value as MigrationUiState.Needed + assertEquals(MigrationUiPhase.done, state.phase) + assertFalse(state.running) + assertEquals(MigrationItemProgressStatusDto.success, state.progress.single { it.item == "ses_1" }.status) + } + + fun `test start persists selected legacy autocomplete settings`() { + val detection = sampleDetection().copy( + settings = LegacySettingsDto( + autoApprovalEnabled = null, + allowedCommands = null, + deniedCommands = null, + alwaysAllowReadOnly = null, + alwaysAllowReadOnlyOutsideWorkspace = null, + alwaysAllowWrite = null, + alwaysAllowExecute = null, + alwaysAllowMcp = null, + alwaysAllowModeSwitch = null, + alwaysAllowSubtasks = null, + language = null, + autocomplete = LegacyAutocompleteSettingsDto( + enableAutoTrigger = true, + enableSmartInlineTaskKeybinding = false, + enableChatAutocomplete = true, + ), + ) + ) + app.value = KiloAppStateDto(KiloAppStatusDto.MIGRATION_REQUIRED, migration = detection) + settle() + + service.start(MigrationUiSelections(settings = MigrationSettingsUiSelections(autocomplete = true))) + settle() + + assertEquals(1, autocomplete.size) + assertEquals(true, autocomplete[0].enableAutoTrigger) + assertEquals(false, autocomplete[0].enableSmartInlineTaskKeybinding) + assertEquals(true, autocomplete[0].enableChatAutocomplete) + } + + private fun sampleDetection() = LegacyMigrationDetectionDto( + providers = listOf( + MigrationProviderInfoDto("profile1", "anthropic", "claude-3", true, true, "anthropic"), + ), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = null, + settings = null, + hasData = true, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt new file mode 100644 index 00000000000..1c81f0dc4da --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/migration/SessionUiMigrationTest.kt @@ -0,0 +1,276 @@ +package ai.kilocode.client.migration + +import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.migration.ui.MigrationItemRow +import ai.kilocode.client.migration.ui.MigrationOverlayPanel +import ai.kilocode.client.migration.ui.MigrationWizardPanel +import ai.kilocode.client.ui.layout.Align +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto +import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto +import ai.kilocode.rpc.dto.MigrationItemCategoryDto +import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto +import ai.kilocode.rpc.dto.MigrationItemStatusDto +import ai.kilocode.rpc.dto.MigrationProviderInfoDto +import ai.kilocode.rpc.dto.MigrationSessionInfoDto +import ai.kilocode.rpc.dto.MigrationSessionPhaseDto +import java.awt.Container +import java.awt.Rectangle +import javax.swing.AbstractButton +import javax.swing.JLabel + +@Suppress("UnstableApiUsage") +class SessionUiMigrationTest : SessionUiTestBase() { + + private lateinit var fakeMigration: FakeMigrationUiController + + override fun setUp() { + super.setUp() + // Replace the default UI with one using our observable fake migration controller. + fakeMigration = FakeMigrationUiController() + ui = newUi(migration = fakeMigration) + layout() + } + + fun `test hidden migration state keeps blocker hidden`() { + val root = find(ui) + fakeMigration._state.value = MigrationUiState.Hidden + settle() + assertFalse(root.blocker.isVisible) + } + + fun `test visible migration state shows root blocker`() { + val root = find(ui) + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + layout() + assertTrue("blocker should be visible", root.blocker.isVisible) + assertTrue("blocker should be opaque", root.blocker.isOpaque) + assertEquals(Rectangle(0, 0, root.width, root.height), root.blocker.bounds) + assertEquals(1, root.blocker.componentCount) + } + + fun `test visible migration state lays out content before resize`() { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + + val row = find(ui) + assertTrue("migration row should be visible", row.isVisible) + assertTrue("migration row width should be laid out before resize: ${row.bounds}", row.width > 0) + assertTrue("migration row height should be laid out before resize: ${row.bounds}", row.height > 0) + } + + fun `test migration opens on selection screen with keep file checked`() { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + + val wizard = find(ui) + assertTrue(wizard.keepLegacySettingsFileSelectedForTest()) + } + + fun `test migration wizard is centered in overlay`() { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + layout() + + val overlay = find(ui) + overlay.doLayout() + val align = find(overlay) + align.doLayout() + val wizard = find(overlay) + + assertTrue("align wrapper should fill most overlay width", align.width > overlay.width / 2) + assertTrue("align wrapper should fill most overlay height", align.height > overlay.height / 2) + assertTrue("wizard should be horizontally centered: ${wizard.bounds} in ${align.bounds}", kotlin.math.abs(wizard.x - (align.width - wizard.width) / 2) <= 1) + assertTrue("wizard should be vertically centered: ${wizard.bounds} in ${align.bounds}", kotlin.math.abs(wizard.y - (align.height - wizard.height) / 2) <= 1) + } + + fun `test hidden state after visible hides blocker`() { + val root = find(ui) + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + assertTrue(root.blocker.isVisible) + + fakeMigration._state.value = MigrationUiState.Hidden + settle() + assertFalse(root.blocker.isVisible) + assertEquals(0, root.blocker.componentCount) + } + + fun `test two session UIs sharing one controller both react to state change`() { + val ui2 = newUi(migration = fakeMigration) + ui2.setSize(800, 600) + try { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + + val root1 = find(ui) + val root2 = find(ui2) + assertTrue("ui1 blocker should be visible", root1.blocker.isVisible) + assertTrue("ui2 blocker should be visible", root2.blocker.isVisible) + } finally { + com.intellij.openapi.util.Disposer.dispose(ui2) + } + } + + fun `test default focused component is migration overlay when blocked`() { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + val root = find(ui) + assertTrue("blocker should be visible for defaultFocused test", root.blocker.isVisible) + val overlay = find(ui) + assertSame(overlay.preferredFocusComponent(), ui.defaultFocusedComponent) + assertNotSame(find(ui).defaultFocusedComponent, ui.defaultFocusedComponent) + } + + fun `test migration modal covers prompt with opaque background`() { + fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection()) + settle() + layout() + val root = find(ui) + + assertTrue(root.blocker.isVisible) + assertTrue(root.blocker.isOpaque) + assertEquals(Rectangle(0, 0, root.width, root.height), root.blocker.bounds) + } + + fun `test migration row keeps preferred height and identity while migrating`() { + val det = sampleDetection() + fakeMigration._state.value = MigrationUiState.Needed(detection = det) + settle() + + val wizard = find(ui) + val row = find(wizard) + val count = row.componentCount + val height = row.preferredSize.height + + fakeMigration._state.value = MigrationUiState.Needed( + detection = det, + phase = MigrationUiPhase.migrating, + running = true, + progress = listOf( + MigrationItemUiProgress( + item = "profile1", + category = MigrationItemCategoryDto.provider, + status = MigrationItemProgressStatusDto.migrating, + ), + ), + ) + settle() + + assertSame(wizard, find(ui)) + assertSame(row, find(wizard)) + assertEquals(count, row.componentCount) + assertEquals(height, row.preferredSize.height) + + fakeMigration._state.value = MigrationUiState.Needed( + detection = det, + phase = MigrationUiPhase.done, + progress = listOf( + MigrationItemUiProgress( + item = "profile1", + category = MigrationItemCategoryDto.provider, + status = MigrationItemProgressStatusDto.success, + ), + ), + ) + settle() + + assertSame(wizard, find(ui)) + assertSame(row, find(wizard)) + assertEquals(count, row.componentCount) + assertEquals(height, row.preferredSize.height) + } + + fun `test session migration progress does not show separate counter`() { + val det = sampleDetection().copy( + sessions = listOf(MigrationSessionInfoDto("ses_1", "Session", "/tmp", 1L)), + ) + fakeMigration._state.value = MigrationUiState.Needed( + detection = det, + phase = MigrationUiPhase.migrating, + running = true, + progress = listOf( + MigrationItemUiProgress( + item = "ses_1", + category = MigrationItemCategoryDto.session, + status = MigrationItemProgressStatusDto.migrating, + ), + ), + sessionProgress = LegacyMigrationSessionProgressDto( + session = det.sessions.single(), + index = 0, + total = 1, + phase = MigrationSessionPhaseDto.preparing, + ), + ) + settle() + + val wizard = find(ui) + assertFalse(hasText(wizard, "Migrating 1 of 1")) + } + + fun `test session migration summary does not show separate report UI`() { + val det = sampleDetection().copy( + sessions = listOf(MigrationSessionInfoDto("ses_1", "Session", "/tmp", 1L)), + ) + fakeMigration._state.value = MigrationUiState.Needed( + detection = det, + phase = MigrationUiPhase.done, + progress = listOf( + MigrationItemUiProgress( + item = "ses_1", + category = MigrationItemCategoryDto.session, + status = MigrationItemProgressStatusDto.success, + ), + ), + sessionProgress = LegacyMigrationSessionProgressDto( + session = null, + index = 1, + total = 1, + phase = MigrationSessionPhaseDto.summary, + ), + sessionSummary = SessionMigrationSummary( + imported = listOf( + LegacyMigrationResultItemDto( + item = "ses_1", + category = MigrationItemCategoryDto.session, + status = MigrationItemStatusDto.success, + ), + ), + ), + ) + settle() + + val wizard = find(ui) + assertFalse(hasText(wizard, "1 imported")) + assertFalse(hasText(wizard, "0 errored")) + assertFalse(hasText(wizard, "Copy Report")) + } + + private fun sampleDetection() = LegacyMigrationDetectionDto( + providers = listOf( + MigrationProviderInfoDto("profile1", "anthropic", "claude-3", true, true, "anthropic"), + ), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = null, + settings = null, + hasData = true, + ) + + private fun hasText(root: Container, text: String): Boolean { + if (root is JLabel && root.text == text) return true + if (root is AbstractButton && root.text == text) return true + for (child in root.components) { + if (child is JLabel && child.text == text) return true + if (child is AbstractButton && child.text == text) return true + if (child is Container && hasText(child, text)) return true + } + return false + } + +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index fb1dac9898d..48db71adf44 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -1,15 +1,34 @@ package ai.kilocode.client.session import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.ToolRefDto +import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBRadioButton import com.intellij.util.ui.JBUI +import java.awt.Container +import java.awt.Point +import javax.swing.AbstractButton +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.Scrollable +import javax.swing.SwingConstants +import javax.swing.JTextArea +import javax.swing.SwingUtilities import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") @@ -35,7 +54,7 @@ class SessionScrollTest : SessionUiTestBase() { if (bottom(bar) <= threshold) { fillTranscript(24, start = 24) } - setValue(bar, bottom(bar) - threshold + 1) + setValuePassive(bar, bottom(bar) - threshold + 1) emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) drainScroll() @@ -43,6 +62,49 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(bar) } + fun `test viewport driven scroll can move away from stale saved position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + val target = (value + JBUI.scale(96)).coerceAtMost(bottom(bar) - 1) + assertTrue("value=$value target=$target bottom=${bottom(bar)}", target > value) + + (scrollComponent() as JBScrollPane).viewport.viewPosition = Point(0, target) + drainScroll() + + assertEquals(target, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test user scroll upward near bottom disables tail follow`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val threshold = JBUI.scale(32) + assertTrue("bottom=${bottom(bar)} threshold=$threshold", bottom(bar) > threshold * 2) + val id = "near_bottom_user_tail" + val pid = "near_bottom_user_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setBottom(bar) + setValue(bar, bottom(bar) - threshold + 1) + val value = bar.value + assertFalse(ui.scroll.following()) + + repeat(240) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n"), flush = false) + } + forceFlush() + drainScroll() + + assertTrue("value=$value actual=${bar.value}", bar.value >= value) + assertFalse(ui.scroll.following()) + } + fun `test session update preserves position outside bottom threshold`() { showMessages() fillTranscript(24) @@ -137,6 +199,38 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(bar) } + fun `test no-op wheel at bottom does not cancel following`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + wheelNoop() + + emit(ChatEventDto.MessageUpdated("ses_test", message("noop_wheel_tail")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("noop_wheel_part", "noop_wheel_tail", "text", "tail line\n".repeat(120))), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test physical mouse wheel uses accelerated transcript unit distance`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + setValue(bar, 0) + drainScroll() + val amount = 3 + val expected = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT * amount) + assertTrue("bottom=${bottom(bar)} expected=$expected", bottom(bar) >= expected * 2) + + val view = scrollView() as Scrollable + val unit = view.getScrollableUnitIncrement(scrollComponent().visibleRect, SwingConstants.VERTICAL, 1) + + assertEquals(expected, unit * amount) + } + fun `test part delta follows bottom after height growth`() { showMessages() fillTranscript(24) @@ -157,6 +251,55 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test user scrolling to bottom during massive stream resumes following`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val id = "stream_massive_resume" + val pid = "stream_massive_resume_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + assertFalse(ui.scroll.following()) + assertTrue(jumpButton().isVisible) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + layout() + setBottom(bar) + drainScroll() + setBottom(bar) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + } + fun `test part delta preserves middle scroll position`() { showMessages() fillTranscript(24) @@ -177,6 +320,176 @@ class SessionScrollTest : SessionUiTestBase() { assertEquals(value, bar.value) } + fun `test expanding tool at bottom preserves clicked header position`() { + val mid = "tool_expand_bottom" + val pid = "tool_expand_bottom_part" + rpc.history.addAll(history(23) + toolHistory(mid, pid) + historyRange(1, start = 23)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + setBottom(bar) + drainScroll() + val view = toolView(mid, pid) + assertFalse(bodyVisible(view)) + val y = visibleY(view) + val value = bar.value + + toggle(view) + drainScroll() + + assertTrue(bodyVisible(view)) + assertEquals(y, visibleY(view)) + assertEquals(value, bar.value) + } + + fun `test expanding tool in middle preserves clicked header position`() { + val mid = "tool_expand_middle" + val pid = "tool_expand_middle_part" + rpc.history.addAll(history(12) + toolHistory(mid, pid) + historyRange(12, start = 12)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + val view = toolView(mid, pid) + val top = SwingUtilities.convertPoint(view, Point(0, 0), scrollView()).y + setValue(bar, top - 80) + drainScroll() + val y = visibleY(view) + + toggle(view) + drainScroll() + + assertTrue(bodyVisible(view)) + assertEquals(y, visibleY(view)) + assertTrue(jumpButton().isVisible) + } + + fun `test long prompt message follows when transcript is at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + val id = "long_prompt_bottom" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("long_prompt_part", id, "text", "prompt line\n".repeat(120))), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test long prompt message preserves middle scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + val id = "long_prompt_middle" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("long_prompt_part", id, "text", "prompt line\n".repeat(120))), flush = false) + forceFlush() + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test sending long prompt follows after prompt editor shrinks`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + findAll(ui).first().text = "prompt line\n".repeat(80) + drainScroll() + assertBottom(bar) + + find(ui).send() + settleShort(100) + val text = rpc.prompts.last().third.parts.single().text + val id = "long_prompt_send" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("long_prompt_send_part", id, "text", text)), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test long prompt followed by instant reasoning stays at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + findAll(ui).first().text = "prompt line\n".repeat(80) + drainScroll() + + find(ui).send() + settleShort(100) + val text = rpc.prompts.last().third.parts.single().text + emit(ChatEventDto.MessageUpdated("ses_test", message("prompt_reasoning_user")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prompt_reasoning_text", "prompt_reasoning_user", "text", text)), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", message("prompt_reasoning_assistant").copy(role = "assistant")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prompt_reasoning_part", "prompt_reasoning_assistant", "reasoning", "thinking")), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test prompt editor growth preserves middle scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + findAll(ui).first().text = "prompt line\n".repeat(80) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test prompt editor growth in middle does not resume following`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + findAll(ui).first().text = "prompt line\n".repeat(80) + drainScroll() + val value = bar.value + + emit(ChatEventDto.MessageUpdated("ses_test", message("prompt_growth_middle")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prompt_growth_part", "prompt_growth_middle", "text", "tail line\n".repeat(80))), flush = false) + forceFlush() + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test large question after reasoning stays at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + val mid = "question_reasoning_assistant" + emit(ChatEventDto.MessageUpdated("ses_test", message(mid).copy(role = "assistant")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("question_reasoning_part", mid, "reasoning", "thinking")), flush = false) + emit(ChatEventDto.QuestionAsked("ses_test", largeQuestion("q_large_after_reasoning")), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + fun `test batched update samples scroll once before model changes`() { showMessages() fillTranscript(24) @@ -240,6 +553,56 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(button.isVisible) } + fun `test scroll button resumes following during massive stream`() { + showMessages() + fillTranscript(48) + val button = jumpButton() + val bar = scrollBar() + val id = "stream_massive_button" + val pid = "stream_massive_button_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertTrue(button.isVisible) + assertFalse(ui.scroll.following()) + + click(button) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + } + fun `test scroll button remains hidden outside transcript body`() { val button = jumpButton() @@ -327,11 +690,12 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(scrollBar()) } - fun `test scroll owns the session viewport`() { + fun `test scroll owns the session viewport without overlapping content`() { settle() assertSame(scrollComponent(), scrollView()?.parent?.parent) assertFalse(scrollView() is SessionMessageListPanel) + assertFalse((scrollComponent() as JBScrollPane).isOverlappingScrollBar) } // ------ question/login-required autoscroll ------ @@ -363,6 +727,309 @@ class SessionScrollTest : SessionUiTestBase() { assertTrue(jumpButton().isVisible) } + fun `test question overlay replaces scroll icon and still jumps to bottom`() { + showMessages() + fillTranscript(24) + val button = jumpButton() + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + drainScroll() + val icon = button.icon + + emit(ChatEventDto.QuestionAsked("ses_test", question("q_overlay"))) + drainScroll() + + assertTrue(button.isVisible) + assertNotSame(icon, button.icon) + assertEquals(KiloBundle.message("session.scroll.question"), button.toolTipText) + + click(button) + drainScroll() + + assertBottom(bar) + assertFalse(button.isVisible) + } + + fun `test question overlay returns to scroll icon when question resolves`() { + showMessages() + fillTranscript(24) + val button = jumpButton() + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + drainScroll() + emit(ChatEventDto.QuestionAsked("ses_test", question("q_resolve"))) + drainScroll() + val icon = button.icon + val value = bar.value + + emit(ChatEventDto.QuestionReplied("ses_test", "q_resolve")) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(button.isVisible) + assertNotSame(icon, button.icon) + assertEquals(KiloBundle.message("session.scroll.bottom"), button.toolTipText) + } + + fun `test plan followup question keeps scroll icon`() { + showMessages() + fillTranscript(24) + val button = jumpButton() + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + drainScroll() + val icon = button.icon + + emit(ChatEventDto.QuestionAsked("ses_test", question("q_plan", plan = true))) + drainScroll() + + assertTrue(button.isVisible) + assertSame(icon, button.icon) + } + + fun `test question carousel navigation follows when transcript is at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_nav_bottom"))) + drainScroll() + setBottom(bar) + + option("Minimal").doClick() + button("Next").doClick() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + + option("Unit").doClick() + button("Review").doClick() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + + button("Back").doClick() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question carousel navigation follows even when transcript is in middle`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_nav_middle"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + + option("Minimal").doClick() + button("Next").doClick() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question top forward icon follows immediately from middle`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_icon_next_middle"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + + option("Minimal").doClick() + icon(KiloBundle.message("session.question.next")).doClick() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question review navigation follows immediately from middle`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_review_middle"))) + drainScroll() + + option("Minimal").doClick() + button("Next").doClick() + drainScroll() + option("Unit").doClick() + setValue(bar, bottom(bar) / 2) + button("Review").doClick() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question review back footer follows immediately from middle`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_review_back_middle"))) + drainScroll() + + option("Minimal").doClick() + button("Next").doClick() + drainScroll() + option("Unit").doClick() + button("Review").doClick() + drainScroll() + setValue(bar, bottom(bar) / 2) + button("Back").doClick() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test forced question navigation resumes following subsequent updates`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_nav_resume_follow"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + + option("Minimal").doClick() + icon(KiloBundle.message("session.question.next")).doClick() + emit(ChatEventDto.MessageUpdated("ses_test", message("q_nav_resume_tail")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("q_nav_resume_part", "q_nav_resume_tail", "text", "tail line\n".repeat(80))), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question carousel back to large question follows immediately`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", largeQuestion("q_nav_large"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + + option("Go").doClick() + button("Next").doClick() + drainScroll() + setValue(bar, bottom(bar) / 2) + icon(KiloBundle.message("session.question.back")).doClick() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question reply follows after card hides when transcript is at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", question("q_reply_bottom"))) + drainScroll() + setBottom(bar) + + option("A").doClick() + button("Submit").doClick() + settleShort(100) + drainScroll() + + assertEquals("q_reply_bottom", rpc.questionReplies.single().first) + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question reply preserves middle scroll position after card hides`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", question("q_reply_middle"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + option("A").doClick() + button("Submit").doClick() + settleShort(100) + drainScroll() + + assertEquals("q_reply_middle", rpc.questionReplies.single().first) + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test custom question answer growth follows when transcript is at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", customQuestion("q_custom_bottom"))) + drainScroll() + setBottom(bar) + + option("").doClick() + drainScroll() + findAll(ui).last().text = "custom line\n".repeat(80) + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test custom question answer growth preserves middle scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", customQuestion("q_custom_middle"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + option("").doClick() + drainScroll() + findAll(ui).last().text = "custom line\n".repeat(80) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test question text caret visibility cannot move middle scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", largeQuestion("q_text_caret_middle"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + findAll(ui).first { !it.isEditable }.scrollRectToVisible(java.awt.Rectangle(0, 10_000, 1, 1)) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test question option selection in middle does not resume following`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + emit(ChatEventDto.QuestionAsked("ses_test", question("q_select_middle"))) + drainScroll() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + option("A").doClick() + drainScroll() + emit(ChatEventDto.MessageUpdated("ses_test", message("q_select_tail")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("q_select_part", "q_select_tail", "text", "tail line\n".repeat(80))), flush = false) + forceFlush() + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + fun `test login required appearing at bottom keeps scroll at bottom`() { showMessages() fillTranscript(24) @@ -394,7 +1061,55 @@ class SessionScrollTest : SessionUiTestBase() { // ------ helpers ------ - private fun question(id: String) = QuestionRequestDto( + private fun button(text: String): JButton = findAll(ui).first { it.text == text } + + private fun icon(text: String): JButton = findAll(ui).first { it.toolTipText == text } + + private inline fun option(label: String): T where T : AbstractButton = + findAll(ui).first { it.actionCommand == label } + + private fun toolView(mid: String, pid: String): JComponent { + val messages = find(ui) + val view = messages.findMessage(mid)?.part(pid) + return when (view) { + is ShellToolView -> view + is ToolView -> view + else -> null + } + ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") + } + + private fun bodyVisible(view: JComponent): Boolean = when (view) { + is ShellToolView -> view.bodyVisible() + is ToolView -> view.bodyVisible() + else -> false + } + + private fun toggle(view: JComponent) = when (view) { + is ShellToolView -> view.toggle() + is ToolView -> view.toggle() + else -> Unit + } + + private fun visibleY(component: JComponent): Int = + SwingUtilities.convertPoint(component, Point(0, 0), scrollComponent()).y + + private inline fun findAll(root: Container = ui): List = findAll(root, T::class.java) + + private fun findAll(root: Container, cls: Class): List { + val out = mutableListOf() + if (cls.isInstance(root)) out.add(cls.cast(root)) + for (child in root.components) { + if (child is Container && child !is AbstractButton) { + out.addAll(findAll(child, cls)) + } else if (cls.isInstance(child)) { + out.add(cls.cast(child)) + } + } + return out + } + + private fun question(id: String, plan: Boolean = false) = QuestionRequestDto( id = id, sessionID = "ses_test", questions = listOf( @@ -404,8 +1119,98 @@ class SessionScrollTest : SessionUiTestBase() { options = listOf(QuestionOptionDto("A", "Option A")), multiple = false, custom = true, + questionKey = if (plan) "plan.followup.question" else null, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) + + private fun multiQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Choose approach", + header = "Approach", + options = listOf( + QuestionOptionDto("Minimal", "Smallest safe change"), + QuestionOptionDto("Balanced", "Focused implementation"), + ), + multiple = false, + custom = false, + ), + QuestionInfoDto( + question = "Choose test level", + header = "Test Level", + options = listOf( + QuestionOptionDto("Unit", "Unit tests"), + QuestionOptionDto("Integration", "Integration tests"), + ), + multiple = false, + custom = false, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) + + private fun largeQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Which backend programming language do you prefer for your project?", + header = "Backend Language", + options = listOf( + QuestionOptionDto("TypeScript", "Offers excellent ecosystem with Node.js and npm, strong typing for maintainability, good performance via V8, but may have higher memory usage than compiled languages; learning curve is moderate if you know JavaScript."), + QuestionOptionDto("Go", "Provides high performance with compiled binaries, simple concurrency model, growing ecosystem, and fast compile times; learning curve is gentle due to minimalistic language design."), + QuestionOptionDto("Rust", "Delivers top-tier performance and memory safety without garbage collector, steep learning curve due to ownership concepts, but expanding ecosystem and excellent for system-level services."), + QuestionOptionDto("Python", "Boasts vast ecosystem, ease of use and rapid development, but interpreted performance is lower than compiled languages; learning curve is very gentle, ideal for prototyping."), + ), + multiple = false, + custom = true, ), + QuestionInfoDto("Choose database", "Database", listOf(QuestionOptionDto("Postgres", "Reliable relational default")), false, false), + QuestionInfoDto("Choose deployment target", "Deploy", listOf(QuestionOptionDto("Cloud", "Managed environment")), false, false), + QuestionInfoDto("Choose testing style", "Testing", listOf(QuestionOptionDto("Integration", "Exercise real implementation")), false, false), ), tool = ToolRefDto("msg1", "call1"), ) + + private fun customQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Describe approach", + header = "Approach", + options = emptyList(), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) + + private fun toolPart(id: String, mid: String) = PartDto( + id = id, + sessionID = "ses_test", + messageID = mid, + type = "tool", + tool = "bash", + callID = "call_$id", + state = "completed", + title = "print output", + output = "output line\n".repeat(160), + ) + + private fun toolHistory(mid: String, pid: String) = MessageWithPartsDto( + message(mid).copy(role = "assistant"), + listOf(toolPart(pid, mid)), + ) + + private fun historyRange(count: Int, start: Int) = List(count) { offset -> + val i = start + offset + val id = "hist_range_$i" + MessageWithPartsDto(message(id), listOf(part("hist_range_part_$i", id, "text", text(i)))) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 74272ac98ca..73579e264bb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -4,23 +4,42 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.history.HistoryController +import ai.kilocode.client.session.history.HistoryDataKeys import ai.kilocode.client.session.history.HistoryPanel +import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.util.registry.RegistryKeyDescriptor import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow import javax.swing.JLabel import javax.swing.JComponent import javax.swing.JPanel @@ -33,6 +52,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private lateinit var workspace: Workspace private lateinit var sessions: KiloSessionService private lateinit var app: KiloAppService + private lateinit var timers: TestUiTimers private val managers = mutableListOf() private val created = mutableListOf>() private val refs = mutableListOf() @@ -40,6 +60,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + timers = TestUiTimers() scope = CoroutineScope(SupervisorJob()) rpc = FakeSessionRpcApi() sessions = KiloSessionService(project, scope, rpc) @@ -120,6 +141,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same existing session reuses component`() { + useLongInactiveDisposeTimeout() val manager = manager() val session = session("ses_1") @@ -130,10 +152,11 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { val second = active(manager) assertSame(first, second) - assertEquals(listOf("/test" to "ses_1", "/test" to null), created) + assertTrue(created.containsAll(listOf("/test" to "ses_1", "/test" to null))) } fun `test prompted blank session is reused from recents`() { + useLongInactiveDisposeTimeout() val manager = manager() manager.newSession() val first = active(manager) @@ -180,6 +203,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test inactive sessions keep queued style updates`() { + useLongInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) as SessionUi @@ -193,6 +217,138 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertSame(style, first.currentStyle()) } + fun `test hidden session is disposed after timeout`() { + useShortInactiveDisposeTimeout() + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + manager.openSession(session("ses_2")) + expire() + + assertFalse(ui.contains(first)) + assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created) + } + + fun `test reopening after hidden timeout recreates session ui`() { + useShortInactiveDisposeTimeout() + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + manager.openSession(session("ses_2")) + expire() + manager.openSession(session("ses_1")) + + assertNotSame(first, active(manager)) + assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2", "/test" to "ses_1"), created) + } + + fun `test queued hidden transcript events are discarded after timeout disposal`() { + useShortInactiveDisposeTimeout() + val flow = MutableSharedFlow(extraBufferCapacity = 16) + rpc.eventFlow = { _, _ -> flow } + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + settle() + manager.openSession(session("ses_2")) + kotlinx.coroutines.runBlocking { + flow.emit(ChatEventDto.MessageUpdated("ses_1", msg("msg_hidden", "ses_1", "assistant"))) + flow.emit(ChatEventDto.PartDelta("ses_1", "msg_hidden", "txt_hidden", "text", "stale")) + } + expire() + manager.openSession(session("ses_1")) + val second = active(manager) + settle() + + assertNotSame(first, second) + assertTrue(empty(second)) + } + + fun `test pending session is retained for history overlays before timeout`() { + useLongInactiveDisposeTimeout() + val history = JLabel("History") + val manager = manager(history = { _, _, _ -> history }) + + manager.openSession(session("ses_1")) + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false))) + } + manager.showHistory() + + assertSame(history, manager.component.getComponent(0)) + assertTrue(ui.contains(first)) + } + + fun `test history overlays update before hidden timeout`() { + useLongInactiveDisposeTimeout() + lateinit var history: HistoryPanel + val manager = manager(history = { parent, _, _ -> + val controller = HistoryController(sessions, workspace, scope) + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + HistoryPanel(parent, controller, manager = parent as SessionManager).also { history = it } + }) + + manager.openSession(session("ses_1", "/test", "Stored")) + val first = active(manager) + settle() + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false))) + } + manager.showHistory() + settle() + val controller = history.getData(HistoryDataKeys.CONTROLLER.name) as HistoryController + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + assertEquals(1, history.itemCount()) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setSession(session("ses_1", "/test", "Live")) + } + settle() + history.syncActivity() + + assertTrue(ui.contains(first)) + assertEquals("Live", history.titleText(0)) + assertEquals(KiloBundle.message("history.badge.question"), history.badgeText(0)) + } + + fun `test history overlays update from hidden session stream metadata`() { + useLongInactiveDisposeTimeout() + lateinit var history: HistoryPanel + val manager = manager(history = { parent, _, _ -> + val controller = HistoryController(sessions, workspace, scope) + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + HistoryPanel(parent, controller, manager = parent as SessionManager).also { history = it } + }) + + manager.openSession(session("ses_1", "/test", "Stored")) + settle() + manager.showHistory() + settle() + val controller = history.getData(HistoryDataKeys.CONTROLLER.name) as HistoryController + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + assertEquals(1, history.itemCount()) + + kotlinx.coroutines.runBlocking { + rpc.events.emit(ChatEventDto.QuestionAsked("ses_1", rpcQuestion("q1"))) + } + settle() + + assertEquals(mapOf("ses_1" to SessionActivityKind.QUESTION), manager.activity()) + assertEquals(KiloBundle.message("history.badge.question"), history.badgeText(0)) + + kotlinx.coroutines.runBlocking { + rpc.events.emit(ChatEventDto.SessionUpdated("ses_1", session("ses_1", "/test", "Live"))) + } + settle() + + assertEquals(mapOf("ses_1" to "Live"), manager.titles()) + assertEquals("Live", history.titleText(0)) + } + fun `test dispose removes active component`() { val manager = manager() @@ -215,6 +371,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test history back restores latest open session`() { + useLongInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) @@ -225,6 +382,20 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertSame(first, active(manager)) } + fun `test history back restores latest session focus`() { + useLongInactiveDisposeTimeout() + val requests = mutableListOf() + val manager = manager(request = { requests.add(it) }) + + manager.openSession(session("ses_1")) + val first = active(manager) as SessionUi + manager.showHistory() + requests.clear() + back(manager) + + assertSame(first.defaultFocusedComponent, requests.single()) + } + fun `test history back without latest session opens new session`() { val manager = manager() @@ -259,7 +430,25 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { open(SessionRef.Local(session("ses_1"))) assertTrue(active(manager) is SessionUi) - assertEquals(listOf("/test" to "ses_1"), created) + assertTrue(created.contains("/test" to "ses_1")) + } + + fun `test opening local history item restores session focus`() { + lateinit var open: (SessionRef) -> Unit + val requests = mutableListOf() + val manager = manager( + history = { _, fn, _ -> + open = fn + JLabel("History") + }, + request = { requests.add(it) }, + ) + + manager.showHistory() + open(SessionRef.Local(session("ses_1"))) + val active = active(manager) as SessionUi + + assertSame(active.defaultFocusedComponent, requests.single()) } fun `test opening cloud history item shows session ui before import`() { @@ -287,6 +476,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same cloud session while in-flight reuses existing ui`() { + useLongInactiveDisposeTimeout() rpc.historyGate = kotlinx.coroutines.CompletableDeferred() rpc.importedCloudSession = session("ses_imported") val manager = manager() @@ -306,6 +496,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test imported cloud session is reused when opened as local`() { + useLongInactiveDisposeTimeout() rpc.importedCloudSession = session("ses_imported") val manager = manager() @@ -331,6 +522,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same local session while in-flight reuses existing ui`() { + useLongInactiveDisposeTimeout() val gate = kotlinx.coroutines.CompletableDeferred() rpc.historyGate = gate val manager = manager() @@ -368,13 +560,109 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf("/test" to "ses_1", "/test" to "ses_1"), created) } + fun `test activity reports permission for live session ui`() { + val manager = manager() + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + manager.openSession(session("ses_1")) + val active = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + active.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1"))) + } + + assertEquals(mapOf("ses_1" to SessionActivityKind.PERMISSION), manager.activity()) + } + + fun `test activity includes service running without retained ui`() { + val manager = manager() + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + settle() + + assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), manager.activity()) + } + + fun `test titles reports live session ui title`() { + val manager = manager() + manager.openSession(session("ses_1", "/test", "Stored")) + val active = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + active.controller().model.setSession(session("ses_1", "/test", "Live")) + } + + assertEquals(mapOf("ses_1" to "Live"), manager.titles()) + } + + fun `test activity reports plan and question separately`() { + useLongInactiveDisposeTimeout() + val manager = manager() + manager.openSession(session("ses_plan")) + val plan = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + plan.controller().model.setState(SessionState.AwaitingQuestion(question(plan = true))) + } + manager.openSession(session("ses_question")) + val question = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + question.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false))) + } + + assertEquals( + mapOf( + "ses_plan" to SessionActivityKind.PLAN, + "ses_question" to SessionActivityKind.QUESTION, + ), + manager.activity(), + ) + } + + fun `test hidden permission session ui is disposed after timeout`() { + useShortInactiveDisposeTimeout() + val manager = manager() + manager.openSession(session("ses_1")) + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1"))) + } + manager.openSession(session("ses_2")) + expire() + + assertFalse(ui.contains(first)) + assertEquals(emptyMap(), manager.activity()) + } + + fun `test hidden busy session ui is disposed after timeout`() { + useShortInactiveDisposeTimeout() + val manager = manager() + manager.openSession(session("ses_1")) + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.Busy("running")) + } + manager.openSession(session("ses_2")) + expire() + + assertFalse(ui.contains(first)) + } + + fun `test activity ignores disposed idle session ui`() { + useShortInactiveDisposeTimeout() + val manager = manager() + manager.openSession(session("ses_1")) + val first = active(manager) + manager.openSession(session("ses_2")) + expire() + + assertFalse(ui.contains(first)) + assertEquals(emptyMap(), manager.activity()) + } + private fun manager( history: ((com.intellij.openapi.Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null, + request: (JComponent) -> Unit = {}, ): SessionSidePanelManager { val manager = SessionSidePanelManager( project = project, root = workspace, - create = { project, workspace, owner, ref -> + create = { project, workspace, owner, ref, timers -> val id = when (ref) { is SessionRef.Local -> ref.id is SessionRef.Cloud -> ref.key @@ -382,13 +670,26 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } created.add(workspace.directory to id) refs.add(ref) - SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner).also { + SessionUi( + project, + workspace, + sessions, + app, + scope, + ref = ref, + manager = owner, + workspaces = workspaces, + timers = timers, + ).also { ui.add(it) Disposer.register(it) { ui.remove(it) } } }, resolve = { workspaces.workspace(it) }, + status = { sessions.activity() }, history = history, + timers = timers, + request = request, ) managers.add(manager) return manager @@ -396,6 +697,37 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + private fun empty(panel: JPanel): Boolean { + var empty = false + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + empty = panel.controller().model.isEmpty() + } + return empty + } + + private fun useShortInactiveDisposeTimeout() = setInactiveDisposeTimeout(10) + + private fun useLongInactiveDisposeTimeout() = setInactiveDisposeTimeout(60_000) + + private fun setInactiveDisposeTimeout(ms: Int) { + val key = "kilo.session.inactive.disposeTimeoutMs" + Registry.mutateContributedKeys { + it + (key to RegistryKeyDescriptor( + key, + "Milliseconds before hidden session UI is disposed after switching away.", + "180000", + false, + false, + null, + null, + )) + } + Disposer.register(testRootDisposable) { + Registry.mutateContributedKeys { it - key } + } + Registry.get(key).setValue(ms, testRootDisposable) + } + private fun JPanel.controller(): ai.kilocode.client.session.controller.SessionController { val field = SessionUi::class.java.getDeclaredField("controller") field.isAccessible = true @@ -414,10 +746,16 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { method.invoke(manager) } + private fun expire() { + timers.advanceBy(10) + } + private fun settle() = kotlinx.coroutines.runBlocking { repeat(5) { kotlinx.coroutines.delay(100) - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } } } @@ -432,6 +770,13 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { time = SessionTimeDto(created = 1.0, updated = 2.0), ) + private fun msg(id: String, session: String, role: String) = MessageDto( + id = id, + sessionID = session, + role = role, + time = MessageTimeDto(created = 1.0), + ) + private fun cloud(id: String) = CloudSessionDto( id = id, title = "Cloud $id", @@ -440,4 +785,41 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { version = 1.0, ) + private fun permission(id: String) = Permission( + id = "perm_$id", + sessionId = id, + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(), + ) + + private fun question(plan: Boolean) = Question( + id = "qst", + items = listOf( + QuestionItem( + question = "Question?", + header = "Header", + options = emptyList(), + multiple = false, + custom = false, + questionKey = if (plan) "plan.followup.question" else null, + ), + ), + ) + + private fun rpcQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_1", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = emptyList(), + multiple = false, + custom = true, + ), + ), + ) + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 75b3c17d393..30160c71c5f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.cancel class SessionUiFactoryTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope private lateinit var workspace: Workspace + private lateinit var workspaces: KiloWorkspaceService private lateinit var sessions: KiloSessionService private lateinit var app: KiloAppService @@ -33,7 +34,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) - val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) }) workspace = workspaces.workspace("/test") @@ -56,7 +57,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test factory wires open callback`() { val manager = FakeManager() val rpc = session("ses_1") - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { @@ -69,9 +70,9 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test empty panel opens through SessionRef via controller`() { val manager = FakeManager() val rpc = session("ses_1") - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) panel.clickRecent(0) @@ -81,13 +82,14 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test empty panel show history routes through manager`() { val manager = FakeManager() - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel( + val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel( testRootDisposable, controller, emptyList(), - ) { manager.showHistory() } + history = { manager.showHistory() }, + ) panel.clickShowHistory() 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 4b101d140c2..9fa3063df02 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 @@ -8,8 +8,9 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel -import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel +import ai.kilocode.client.session.ui.SessionDropOverlay import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionMessageListPanel @@ -31,14 +32,17 @@ import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") class SessionUiLayoutTest : SessionUiTestBase() { - fun `test root contains content and overlay layers`() { + fun `test root contains content overlay and blocker layers`() { val root = find(ui) - assertEquals(2, root.componentCount) + assertEquals(3, root.componentCount) assertSame(root.content, root.components.first { it === root.content }) assertSame(root.overlay, root.components.first { it === root.overlay }) + assertSame(root.blocker, root.components.first { it === root.blocker }) assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker)) + assertFalse(root.blocker.isVisible) } fun `test bottom stack contains connection and prompt only`() { @@ -53,6 +57,59 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(listOf(connection, prompt), stack.components.toList()) } + fun `test drop overlay is attached under root overlay layer`() { + val root = find(ui) + val drop = find(ui) + + assertSame(root.overlay, drop.parent) + assertTrue(drop.isVisible) + assertFalse(drop.contains(1, 1)) + assertFalse(root.blocker.components.contains(drop)) + } + + fun `test drop overlay is visual only and not native file drop target`() { + val drop = find(ui) + + assertNull(drop.dropTarget) + } + + fun `test prompt file drag leave does not immediately hide drop overlay`() { + val prompt = find(ui) + val drop = find(ui) + val card = dropCard(drop) + + layout() + prompt.onFileDrag(true) + assertFalse(drop.contains(1, 1)) + assertTrue(card.isVisible) + + prompt.onFileDrag(false) + assertFalse(drop.contains(1, 1)) + assertTrue(card.isVisible) + + prompt.onFileDrag(true) + drop.setActive(false) + } + + fun `test drop overlay covers full session after layout`() { + val root = find(ui) + val drop = find(ui) + + layout() + + assertEquals(java.awt.Rectangle(0, 0, root.overlay.width, root.overlay.height), drop.bounds) + } + + fun `test drop overlay is above account and scroll overlays`() { + val root = find(ui) + val drop = find(ui) + val account = find(ui) + val jump = jumpButton() + + assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(account)) + assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(jump)) + } + fun `test active views are children of message list panel`() { ui = newUi(id = "ses_test") settle() @@ -68,7 +125,8 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test header is docked above shared scroll pane and hidden while empty`() { val root = find(ui) val header = find(ui) - val scroll = find(ui) + // Search from root.content to avoid finding the migration wizard scroll panes + val scroll = find(root.content) assertSame(root.content, header.parent.parent) assertSame(scroll.parent, header.parent) @@ -223,6 +281,37 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(find(ui), scrollView()) } + fun `test retry status renders in loading panel instead of message body`() { + 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)) + layout() + + val panel = find(ui) + assertSame(panel, scrollView()) + assertEquals("Cannot connect to API", panel.labelText()) + + controller().model.setState(SessionState.Idle) + layout() + + assertSame(find(ui), scrollView()) + } + + fun `test offline status renders in loading panel with fallback`() { + rpc.history.addAll(history(1)) + ui = newUi(id = "ses_test") + settle() + + controller().model.setState(SessionState.Offline("", requestId = "req1")) + layout() + + val panel = find(ui) + assertSame(panel, scrollView()) + assertEquals("Connection offline", panel.labelText()) + } + fun `test empty explicit session id shows message body`() { rpc.recent.add(session("ses_recent")) settle() @@ -280,14 +369,13 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test existing session history shows header above scroll pane`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) - ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply { - setSize(800, 600) - } + ui = newUi(id = "ses_test") settle() layout() + val root = find(ui) val header = find(ui) - val scroll = find(ui) + val scroll = find(root.content) assertTrue(header.isVisible) assertTrue(header.y + header.height <= scroll.y) } @@ -431,4 +519,11 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(top, overlay.y) assertEquals(root.overlay.width - overlay.width - right, overlay.x) } + + private fun dropCard(drop: SessionDropOverlay) = drop.components + .single() + .let { it as javax.swing.JComponent } + .components + .single() + .let { it as javax.swing.JComponent } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 289094a1fe2..de7051b9222 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -4,12 +4,15 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.migration.FakeMigrationUiController +import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.rpc.dto.ChatEventDto @@ -24,20 +27,21 @@ import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.openapi.util.Disposer 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.Container import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent import javax.swing.JLabel import javax.swing.JComponent import javax.swing.JScrollBar @Suppress("UnstableApiUsage") abstract class SessionUiTestBase : BasePlatformTestCase() { + private lateinit var coroutines: TestCoroutines protected lateinit var scope: CoroutineScope protected lateinit var sessions: KiloSessionService protected lateinit var app: KiloAppService @@ -49,7 +53,8 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun setUp() { super.setUp() - scope = CoroutineScope(SupervisorJob()) + coroutines = TestCoroutines() + scope = coroutines.scope rpc = FakeSessionRpcApi() appRpc = FakeAppRpcApi().also { @@ -70,7 +75,8 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun tearDown() { try { - scope.cancel() + Disposer.dispose(ui) + coroutines.close { UIUtil.dispatchAllInvocationEvents() } } finally { super.tearDown() } @@ -80,6 +86,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { id: String? = null, displayMs: Long = 0, open: ((SessionRef) -> Unit)? = null, + migration: MigrationUiController = FakeMigrationUiController(), ): SessionUi { val manager = open?.let { fn -> object : SessionManager { @@ -88,7 +95,14 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun openSession(ref: SessionRef) = fn(ref) } } - return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply { + return SessionUi( + project, workspace, sessions, app, scope, + ref = SessionRef.from(id), + displayMs = displayMs, + manager = manager, + workspaces = workspaces, + migration = migration, + ).apply { setSize(800, 600) } } @@ -103,11 +117,8 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { (scrollView() as? Container)?.doLayout() } - protected fun settle() = runBlocking { - repeat(5) { - delay(100) - UIUtil.dispatchAllInvocationEvents() - } + protected fun settle() { + coroutines.drain { UIUtil.dispatchAllInvocationEvents() } } protected fun settleShort(ms: Long) = runBlocking { @@ -128,7 +139,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) emit(ChatEventDto.PartUpdated("ses_test", part("part_$i", id, "text", text(i))), flush = false) } - settleShort(100) + settle() forceFlush() drainScroll() } @@ -136,7 +147,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { protected fun emit(event: ChatEventDto, flush: Boolean = true) { runBlocking { rpc.events.emit(event) } if (flush) { - settleShort(20) + settle() forceFlush() } } @@ -179,9 +190,19 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { } protected fun setValue(bar: JScrollBar, value: Int) { + wheelNoop() + setValuePassive(bar, value) + } + + protected fun setValuePassive(bar: JScrollBar, value: Int) { bar.value = value.coerceIn(bar.minimum, bottom(bar)) } + protected fun wheelNoop() { + val event = MouseWheelEvent(scrollComponent(), MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), 0, 1, 1, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 1, 1) + for (listener in scrollComponent().mouseWheelListeners) listener.mouseWheelMoved(event) + } + protected fun assertBottom(bar: JScrollBar) { assertTrue("value=${bar.value} bottom=${bottom(bar)} max=${bar.maximum} visible=${bar.visibleAmount}", bar.value >= bottom(bar) - 1) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt index 312913c29c1..1f5f444dd4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt @@ -10,10 +10,16 @@ import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import kotlinx.coroutines.flow.flow +import java.util.concurrent.CopyOnWriteArrayList class ChatLoggingFlowTest : SessionControllerTestBase() { fun `test prompt creates session and subscribes before dispatch`() { + val calls = CopyOnWriteArrayList() + rpc.eventFlow = { id, _ -> + calls.add(id) + rpc.events + } projectRpc.state.value = workspaceReady() appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) val m = controller() @@ -23,6 +29,7 @@ class ChatLoggingFlowTest : SessionControllerTestBase() { flush() assertEquals(1, rpc.creates) + assertEquals(listOf("ses_test"), calls.toList()) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/CommandLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/CommandLifecycleTest.kt new file mode 100644 index 00000000000..21b97350835 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/CommandLifecycleTest.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto + +class CommandLifecycleTest : SessionControllerTestBase() { + + fun `test command creates new session and calls RPC`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy"))) + val m = controller() + + flush() + edt { m.command("deploy", "prod") } + flush() + + assertEquals(1, rpc.creates) + assertEquals(1, rpc.commands.size) + val call = rpc.commands.single() + assertEquals("ses_test", call.id) + assertEquals("/test", call.directory) + assertEquals("deploy", call.command) + assertEquals("prod", call.arguments) + } + + fun `test command reuses existing session`() { + val (m, _, _) = prompted() + val created = rpc.creates + + edt { m.command("deploy", "prod") } + flush() + + assertEquals(created, rpc.creates) + assertEquals("ses_test", rpc.commands.single().id) + } + + fun `test command records telemetry`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy"))) + val m = controller() + + flush() + edt { m.command("deploy", "prod") } + flush() + + val sent = appRpc.telemetry.single { it.event == "Conversation Send Clicked" } + assertEquals("command", sent.properties["source"]) + val message = appRpc.telemetry.single { it.event == "Conversation Message" } + assertEquals("command", message.properties["source"]) + } + + fun `test command errors set state and telemetry`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy"))) + rpc.commandThrows = IllegalStateException("boom") + val m = controller() + + flush() + edt { m.command("deploy", "prod") } + flush() + + assertTrue(m.model.state is SessionState.Error) + val event = appRpc.telemetry.single { it.event == "Session Error" } + assertEquals("command", event.properties["context"]) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt index 349171f6549..7b060ef0daf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt @@ -1,14 +1,18 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.testing.TestUiTimers import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.ui.UIUtil -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking class DelayedStateTest : BasePlatformTestCase() { private val states = mutableListOf() + private lateinit var timers: TestUiTimers + + override fun setUp() { + super.setUp() + timers = TestUiTimers() + } override fun tearDown() { try { @@ -25,7 +29,7 @@ class DelayedStateTest : BasePlatformTestCase() { val delay = delayed(30) delay.run("loading", { state }) { out.add(it) } - pause(120) + timers.advanceBy(30) assertEquals(listOf("loading"), out) } @@ -37,7 +41,7 @@ class DelayedStateTest : BasePlatformTestCase() { delay.run("loading", { state }) { out.add(it) } edt { state = "loaded" } - pause(120) + timers.advanceBy(30) assertTrue(out.isEmpty()) } @@ -50,7 +54,7 @@ class DelayedStateTest : BasePlatformTestCase() { delay.run("loading", { first }) { out.add("first:$it") } delay.run("connecting", { second }) { out.add("second:$it") } - pause(120) + timers.advanceBy(30) assertEquals(listOf("first:loading", "second:connecting"), out) } @@ -63,7 +67,7 @@ class DelayedStateTest : BasePlatformTestCase() { delay.run("first", { state }) { out.add(it) } edt { state = "second" } delay.run("second", { state }) { out.add(it) } - pause(120) + timers.advanceBy(30) assertEquals(listOf("second"), out) } @@ -75,7 +79,7 @@ class DelayedStateTest : BasePlatformTestCase() { delay.run("loading", { state }) { out.add(it) } delay.cancel() - pause(120) + timers.advanceBy(30) assertTrue(out.isEmpty()) } @@ -85,9 +89,9 @@ class DelayedStateTest : BasePlatformTestCase() { val delay = delayed(30) delay.run("loading", { state }) {} - pause(10) + timers.advanceBy(10) delay.cancel() - pause(10) + timers.advanceBy(10) assertFalse(delay.active()) } @@ -97,7 +101,7 @@ class DelayedStateTest : BasePlatformTestCase() { val delay = delayed(30) delay.run("loading", { state }) {} - pause(120) + timers.advanceBy(30) assertFalse(delay.active()) } @@ -110,7 +114,7 @@ class DelayedStateTest : BasePlatformTestCase() { delay.run("loading", { state }) { out.add(it) } Disposer.dispose(delay) states.remove(delay) - pause(120) + timers.advanceBy(30) assertTrue(out.isEmpty()) } @@ -123,25 +127,17 @@ class DelayedStateTest : BasePlatformTestCase() { delay.run("loading", { state }) { out.add(ApplicationManager.getApplication().isDispatchThread) } - pause(30) + timers.advanceBy(0) assertEquals(listOf(true), out) } private fun delayed(ms: Long): DelayedState { - val state = DelayedState(ms) + val state = DelayedState(ms, timers) states.add(state) return state } - private fun pause(ms: Long) = runBlocking { - val tick = 10L - repeat((ms / tick).coerceAtLeast(1).toInt()) { - delay(tick) - edt { UIUtil.dispatchAllInvocationEvents() } - } - } - private fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeAndWait(block) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index baefa04e2be..da883cdbda0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -1,7 +1,14 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto class HistoryLoadingTest : SessionControllerTestBase() { @@ -76,4 +83,66 @@ class HistoryLoadingTest : SessionControllerTestBase() { c, ) } + + fun `test loaded history derives agent from latest message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user").copy(agent = "plan", time = MessageTimeDto(1.0)), emptyList())) + rpc.history.add(MessageWithPartsDto(msg("msg2", "ses_test", "assistant").copy(agent = "code", time = MessageTimeDto(2.0)), emptyList())) + + val c = controller("ses_test") + flush() + + assertEquals("code", c.model.agent) + } + + fun `test loaded history derives model from latest user message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady( + agents = agents(), + default = "plan", + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")), + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + models = mapOf("claude" to ModelDto(id = "claude", name = "Claude")), + ), + ), + connected = listOf("kilo", "anthropic"), + defaults = mapOf("plan" to "kilo/gpt-5", "code" to "kilo/gpt-5"), + ) + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user").copy( + agent = "code", + providerID = "anthropic", + modelID = "claude", + time = MessageTimeDto(1.0), + ), emptyList())) + + val c = controller("ses_test") + flush() + + assertEquals("code", c.model.agent) + assertEquals("anthropic/claude", c.model.model) + assertFalse(c.model.modelOverride) + } + + fun `test empty loaded history keeps workspace default agent`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") + + val c = controller("ses_test") + flush() + + assertEquals("plan", c.model.agent) + } + + private fun agents() = listOf( + AgentDto(name = "plan", displayName = "Plan", mode = "plan"), + AgentDto(name = "code", displayName = "Code", mode = "code"), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptEnhancerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptEnhancerTest.kt new file mode 100644 index 00000000000..a16c6f9cc07 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptEnhancerTest.kt @@ -0,0 +1,79 @@ +package ai.kilocode.client.session.controller + +import com.intellij.openapi.application.ApplicationManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking + +class PromptEnhancerTest : SessionControllerTestBase() { + + fun `test enhance prompt completes on EDT with workspace directory`() { + val controller = controller() + rpc.enhanced = "Use a focused implementation plan" + var result: Result? = null + + edt { + controller.enhancePrompt("make a plan") { + assertTrue(ApplicationManager.getApplication().isDispatchThread) + result = it + } + } + flush() + + assertEquals(listOf("/test" to "make a plan"), rpc.enhancements) + assertEquals("Use a focused implementation plan", result!!.getOrThrow()) + } + + fun `test enhance prompt records telemetry`() { + val controller = controller() + + edt { controller.enhancePrompt("make a plan") {} } + flush() + + val click = appRpc.telemetry.single { it.event == "Prompt Enhance Clicked" } + assertEquals("short", click.properties["textLength"]) + val done = appRpc.telemetry.single { it.event == "Prompt Enhanced" } + assertEquals("short", done.properties["textLength"]) + } + + fun `test enhance prompt reports failure without changing session state`() { + val controller = controller() + rpc.enhanceThrows = IllegalStateException("provider unavailable") + val before = edt { controller.model.state } + var result: Result? = null + + edt { controller.enhancePrompt("make a plan") { result = it } } + flush() + + assertEquals("provider unavailable", result!!.exceptionOrNull()!!.message) + assertSame(before, edt { controller.model.state }) + } + + fun `test enhance prompt cancels pending completions on disposal`() { + val controller = controller() + val gate = CompletableDeferred() + val results = mutableListOf>() + rpc.enhanceGate = gate + + edt { + controller.enhancePrompt("make a plan") { + assertTrue(ApplicationManager.getApplication().isDispatchThread) + results.add(it) + } + controller.enhancePrompt("rewrite a plan") { + assertTrue(ApplicationManager.getApplication().isDispatchThread) + results.add(it) + } + } + settle() + controller.dispose() + + assertEquals(2, results.size) + assertTrue(results.all { it.exceptionOrNull() is CancellationException }) + + runBlocking { gate.complete(Unit) } + settle() + + assertEquals(2, results.size) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index aa095e0a136..07dcfb934f3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -1,23 +1,50 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.SessionRef +import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionFileDiffDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto -import com.intellij.ide.util.PropertiesComponent +import java.util.concurrent.CopyOnWriteArrayList class PromptLifecycleTest : SessionControllerTestBase() { + override fun setUp() { + super.setUp() + edt { KiloPluginSettings.unsetAutoApprove() } + } + + override fun tearDown() { + try { + edt { KiloPluginSettings.unsetAutoApprove() } + } finally { + super.tearDown() + } + } + + fun `test prompt records send intent telemetry`() { + prompted() + + val event = appRpc.telemetry.single { it.event == "Conversation Send Clicked" } + assertEquals("user", event.properties["source"]) + assertEquals("false", event.properties["hasExistingSession"]) + assertEquals("short", event.properties["textLength"]) + } + fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() @@ -113,6 +140,103 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.AwaitingPermission) } + fun `test auto approve replies once to permission request`() { + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test disabling auto approve before reply restores awaiting permission`() { + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")), flush = false) + edt { m.setAutoApprove(false) } + flush() + + assertTrue(rpc.permissionReplies.isEmpty()) + assertSession( + """ + permission#perm1 + tool: msg1/call1 + name: edit + patterns: *.kt + always: + file: src/A.kt + state: RESPONDING + metadata: kind=edit + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, + ) + } + + fun `test enabling auto approve drains current permission`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + edt { m.setAutoApprove(true) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test enabling auto approve drains pending permissions`() { + val (m, _, _) = prompted() + rpc.pendingPermissionList.add(permission("perm_pending")) + + edt { m.setAutoApprove(true) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("perm_pending", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + } + + fun `test auto approve drains pending permissions during recovery`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.pendingPermissionList.add(permission("perm_pending")) + edt { KiloPluginSettings.setAutoApprove(true) } + + val m = controller("ses_test") + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("perm_pending", rpc.permissionReplies[0].first) + assertFalse(m.model.state is SessionState.AwaitingPermission) + } + + fun `test auto approve persists in properties`() { + val (m, _, _) = prompted() + + assertFalse(KiloPluginSettings.getAutoApprove()) + edt { m.setAutoApprove(true) } + + assertTrue(KiloPluginSettings.getAutoApprove()) + assertTrue(m.autoApprove) + } + fun `test QuestionReplied with wrong requestID is ignored`() { val (m, _, _) = prompted() @@ -169,6 +293,153 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("q1", rpc.questionReplies[0].first) } + fun `test plan follow-up question enters awaiting state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + assertSession( + """ + question#q_plan + tool: + header: Implement + prompt: Ready to implement? + option: Start new session - Implement in a fresh session with a clean context + option: Continue here - Implement the plan in this session + multiple: false + custom: true + + [code] [kilo/gpt-5] [awaiting-question] + """, + m, + ) + } + + fun `test continue here reflects CLI mode after canonical reply`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = planWorkspace() + val m = controller() + val events = collect(m) + flush() + edt { m.prompt("go") } + flush() + events.clear() + edt { m.model.agent = "plan" } + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Continue here"))), + listOf(listOf("Continue here")), + ) + } + flush() + + assertEquals("plan", m.model.agent) + assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies) + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_code", "ses_test", "user").copy( + agent = "code", + providerID = "anthropic", + modelID = "claude", + ))) + + assertEquals("code", m.model.agent) + assertEquals("anthropic/claude", m.model.model) + assertFalse(m.model.modelOverride) + assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertControllerEvents("WorkspaceReady", events) + } + + fun `test custom plan follow-up answer does not switch mode`() { + val (m, _, _) = prompted() + edt { m.model.agent = "plan" } + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Need to adjust scope"))), + listOf(emptyList()), + ) + } + flush() + + assertEquals("plan", m.model.agent) + assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies) + } + + fun `test start new session adopts matching created session from selected option`() { + val opened = mutableListOf() + val m = controller(open = { opened.add(it) }) + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Use a fresh implementation session"))), + listOf(listOf("Start new session")), + ) + } + emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test"))) + flush() + + assertEquals("ses_new", (opened.last() as SessionRef.Local).id) + assertEquals(1, rpc.prompts.size) + } + + fun `test start new session reply text without selected option is ignored`() { + val opened = mutableListOf() + val m = controller(open = { opened.add(it) }) + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Start new session"))), + listOf(emptyList()), + ) + } + emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test"))) + + assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" }) + } + + fun `test unrelated session created is ignored`() { + val opened = mutableListOf() + val m = controller(open = { opened.add(it) }) + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan"))) + edt { + m.replyQuestion( + "q_plan", + QuestionReplyDto(listOf(listOf("Start new session"))), + listOf(listOf("Start new session")), + ) + } + + emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/other"))) + + assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" }) + } + fun `test rejectQuestion calls RPC`() { val (m, _, _) = prompted() emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) @@ -226,6 +497,20 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("ses_child", perm.sessionId) } + fun `test repeated task part subscribes to child once`() { + val calls = CopyOnWriteArrayList() + rpc.eventFlow = { id, _ -> + calls.add(id) + rpc.events + } + prompted() + + emit(taskPart("ses_child"), flush = false) + emit(taskPart("ses_child")) + + assertEquals(1, calls.count { it == "ses_child" }) + } + fun `test child PermissionAsked moves root model to AwaitingPermission`() { val (m, _, _) = prompted() @@ -348,4 +633,43 @@ class PromptLifecycleTest : SessionControllerTestBase() { ), tool = ToolRefDto("msg1", "call1"), ) + + private fun planQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Ready to implement?", + header = "Implement", + options = listOf( + QuestionOptionDto("Start new session", "Implement in a fresh session with a clean context"), + QuestionOptionDto("Continue here", "Implement the plan in this session", mode = "code"), + ), + multiple = false, + custom = true, + ), + ), + ) + + private fun planWorkspace() = workspaceReady( + agents = listOf( + AgentDto(name = "plan", displayName = "Plan", mode = "plan"), + AgentDto(name = "code", displayName = "Code", mode = "code"), + ), + default = "plan", + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")), + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + models = mapOf("claude" to ModelDto(id = "claude", name = "Claude")), + ), + ), + connected = listOf("kilo", "anthropic"), + defaults = mapOf("plan" to "kilo/gpt-5", "code" to "kilo/gpt-5"), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 51bd2bf2e4e..2cc6d6fadd2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -8,6 +8,8 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionRef @@ -27,6 +29,7 @@ import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer @@ -34,8 +37,6 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil import java.awt.event.HierarchyEvent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -94,7 +95,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected lateinit var app: KiloAppService protected lateinit var workspaces: KiloWorkspaceService protected lateinit var workspace: Workspace + protected lateinit var timers: TestUiTimers + private lateinit var coroutines: TestCoroutines protected lateinit var scope: CoroutineScope protected lateinit var parent: Disposable @@ -103,8 +106,10 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { rpc = FakeSessionRpcApi() appRpc = FakeAppRpcApi() projectRpc = FakeWorkspaceRpcApi() + timers = TestUiTimers() - scope = CoroutineScope(SupervisorJob()) + coroutines = TestCoroutines() + scope = coroutines.scope parent = Disposer.newDisposable("test") sessions = KiloSessionService(project, scope, rpc) @@ -116,7 +121,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { override fun tearDown() { try { Disposer.dispose(parent) - scope.cancel() + coroutines.close { edt { UIUtil.dispatchAllInvocationEvents() } } } finally { super.tearDown() } @@ -128,8 +133,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { id: String? = null, flushMs: Long = Long.MAX_VALUE, displayMs: Long = Long.MAX_VALUE, + open: (SessionRef) -> Unit = {}, ): SessionController { - return controller(id, flushMs, true, displayMs = displayMs) + return controller(id, flushMs, true, displayMs = displayMs, open = open) } protected fun controller( @@ -148,22 +154,26 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { session: SessionDto? = null, beforeUpdate: () -> Boolean = { false }, afterUpdate: (Boolean) -> Unit = {}, + open: (SessionRef) -> Unit = {}, ref: SessionRef? = if (session != null) SessionRef.Local(session) else SessionRef.from(id), ): SessionController { val root = Root() val m = SessionController( - parent, - ref, - sessions, - workspace, - app, - scope, - root, - flushMs, - condense, - displayMs, + parent = parent, + ref = ref, + sessions = sessions, + workspace = workspace, + app = app, + cs = scope, + comp = root, + flushMs = flushMs, + condense = condense, + displayMs = displayMs, + open = open, beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, + telemetry = { event, props -> appRpc.telemetry.add(TelemetryCaptureDto(event, props)) }, + timers = timers, ) controllers.add(m) roots[m] = root @@ -217,33 +227,38 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ EDT + coroutine helpers ------ - /** Let coroutines settle without forcing buffered controller delivery. */ - protected fun settle() = runBlocking { - repeat(5) { - delay(100) + /** Drain background coroutine and EDT work without forcing buffered controller delivery. */ + protected fun settle() { + drain(false) + } + + /** Drain background work, force buffered controller delivery, then drain EDT. */ + protected fun flush() { + drain(true) + } + + protected fun pause(ms: Long) = runBlocking { + settleFast() + timers.advanceBy(ms) + settleFast() + } + + private suspend fun settleFast() { + repeat(3) { + delay(1) edt { UIUtil.dispatchAllInvocationEvents() } } } - /** Let coroutines settle, force buffered controller delivery, then drain EDT. */ - protected fun flush() = runBlocking { - repeat(5) { - delay(100) + private fun drain(force: Boolean) { + coroutines.drain { edt { - controllers.forEach { it.flushEvents() } + if (force) controllers.forEach { it.flushEvents() } UIUtil.dispatchAllInvocationEvents() } } } - protected fun pause(ms: Long) = runBlocking { - val tick = 10L - repeat((ms / tick).coerceAtLeast(1).toInt()) { - delay(tick) - edt { UIUtil.dispatchAllInvocationEvents() } - } - } - protected fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeAndWait(block) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt index 698dec3f5e0..e7b24845e52 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt @@ -43,6 +43,34 @@ class SessionCreationTest : SessionControllerTestBase() { assertEquals("ses_test", rpc.prompts[1].first) } + fun `test same-turn first prompts share session creation`() { + val m = controller() + + edt { + m.prompt("first") + m.prompt("second") + } + flush() + + assertEquals(1, rpc.creates) + assertEquals(listOf("ses_test", "ses_test"), rpc.prompts.map { it.first }) + assertEquals(listOf("first", "second"), rpc.prompts.map { it.third.parts.single().text.toString() }.sorted()) + } + + fun `test same-turn first prompt and command share session creation`() { + val m = controller() + + edt { + m.prompt("first") + m.command("deploy", "prod") + } + flush() + + assertEquals(1, rpc.creates) + assertEquals("ses_test", rpc.prompts.single().first) + assertEquals("ses_test", rpc.commands.single().id) + } + fun `test prompt with existing ID skips creation`() { val m = controller("existing") collect(m) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index 8cb0c1f3507..abe72480c28 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -1,6 +1,9 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -20,6 +23,7 @@ class SessionRecoveryTest : SessionControllerTestBase() { super.setUp() // Set a pre-existing session in the fake API rpc.session = rpc.session.copy(id = "ses_test") + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) } fun `test pending permission is recovered on history load`() { @@ -32,7 +36,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -52,7 +55,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -72,7 +74,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -98,7 +99,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt index 1a10efeabc6..38115406e46 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt @@ -6,8 +6,13 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.ToolRefDto +import kotlinx.coroutines.ExperimentalCoroutinesApi class SessionUpdateQueueTest : SessionControllerTestBase() { @@ -38,6 +43,66 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.Busy) } + fun `test hidden controller applies question metadata without flushing transcript`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + settle() + + assertModelEvents(""" + StateChanged AwaitingQuestion + """, modelEvents) + assertTrue(m.model.state is SessionState.AwaitingQuestion) + assertNull(m.model.message("msg1")) + + show(m) + settle() + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + assertNotNull(m.model.message("msg1")) + } + + fun `test hidden controller applies session title metadata without show`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + flush() + + hide(m) + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Hidden title")), flush = false) + settle() + + assertEquals("Hidden title", m.model.session?.title) + assertNull(m.model.message("msg1")) + } + + fun `test hidden controller consumes matching question reply metadata`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = false) + emit(ChatEventDto.QuestionReplied("ses_test", "q1"), flush = false) + settle() + + assertModelEvents(""" + StateChanged AwaitingQuestion + StateChanged Busy + """, modelEvents) + assertTrue(m.model.state is SessionState.Busy) + } + fun `test hidden controller condenses while hidden but does not flush`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -170,6 +235,184 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertEquals(listOf("hello world"), delta.map { it.delta }) } + fun `test text snapshot covered delta is not duplicated`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import")), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import"), flush = false) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + import + """, + m, + ) + } + + fun `test pure text deltas preserve incidental overlap`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hel")) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "lo")) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + hello + """, + m, + ) + } + + fun `test pure text deltas preserve split closing fence`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "```python\nprint(1)\n``")) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "`\n\nafter")) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + ```python + print(1) + ``` + + after + """, + m, + ) + } + + fun `test text snapshot covered prefix is trimmed from merged delta`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import java")), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", " java.util.List;"), flush = false) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + import java.util.List; + """, + m, + ) + } + + fun `test repeated snapshots then lagging merged delta is not duplicated`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import java"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import java"), flush = false) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + import java + """, + m, + ) + } + + fun `test multi round snapshot delta interleave stays single copy`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", " java")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java.util.List;\n"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", ".util.List;\n")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java.util.List;\n\npublic class StreamBasics {\n}\n```"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "\npublic class StreamBasics {\n}\n```")) + + assertModel( + """ + assistant#msg1 + text#prt1: + ```java + import java.util.List; + + public class StreamBasics { + } + ``` + """, + m, + ) + } + + fun `test per token snapshot plus delta interleave does not double text or code`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + // Reproduces the streamed screenshot: for every token the backend sends a full + // PartUpdated snapshot AND a matching incremental PartDelta. The old dedup doubled + // each token ("ReadRead", "inputinput.txt.txt"); glue must keep a single copy. + val tokens = listOf( + "**Python**", "\n\n", "Read", " a", " file", " line", " by", " line", ",", + " which", " is", " stream-like", " because", " it", " avoids", " loading", + " the", " whole", " file", " into", " memory", ":", "\n\n", + "```python\n", "with", " open", "(\"input.txt\",", " \"r\")", " as", " file:", "\n", + " for", " line", " in", " file:", "\n", " print", "(line.strip())", "\n", + "```", + ) + val sb = StringBuilder() + for (token in tokens) { + sb.append(token) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = sb.toString())), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", token), flush = false) + flush() + } + + val text = (m.model.message("msg1")!!.parts["prt1"] as ai.kilocode.client.session.model.Text).content.toString() + assertEquals(sb.toString(), text) + } + fun `test visible controller flushes after cadence`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -282,15 +525,15 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertEquals(SessionState.Idle, m.model.state) } + @OptIn(ExperimentalCoroutinesApi::class) fun `test condensed and raw controller end with same final state on large corpus`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val events = corpus() - val condensed = runCorpus(events, true) - val raw = runCorpus(events, false) - val a = snapshot(condensed) - val b = snapshot(raw) + val a = snapshot(runCorpus(events, true)) + rpc.events.resetReplayCache() + val b = snapshot(runCorpus(events, false)) if (a != b) fail("condensed=\n$a\nraw=\n$b") assertEquals(SessionState.Idle, a.state) @@ -298,7 +541,7 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(a.body.contains("assistant#msg2")) assertTrue(a.body.contains("diff: src/A.kt src/B.kt")) assertTrue(a.body.contains("todo: [completed] ship feature")) - assertEquals(4, a.compacted) + assertEquals(2, a.compacted) } fun `test update hooks run on EDT around queued model batch`() { @@ -363,6 +606,21 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.Busy) } + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = listOf(QuestionOptionDto("A", "Option A")), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) + private fun corpus(): List = buildList { add(ChatEventDto.TurnOpen("ses_test")) add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index 841d2cc0963..002d7509c65 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -10,6 +10,8 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto class TurnLifecycleTest : SessionControllerTestBase() { @@ -72,6 +74,20 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test TurnClose completed preserves AwaitingQuestion state`() { + val (m, _, _) = prompted() + + emit( + ChatEventDto.QuestionAsked( + "ses_test", + QuestionRequestDto("q1", "ses_test", listOf(QuestionInfoDto("Pick one", "Choice"))), + ), + ) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + } + fun `test Error fires StateChanged to Error`() { val (m, _, _) = prompted() @@ -164,6 +180,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { )) assertTrue(m.model.state is SessionState.LoginRequired) + assertTrue(appRpc.telemetry.any { + it.event == "Account Overlay Shown" && it.properties["reason"] == "paid_model_auth" + }) assertSession( """ [code] [kilo/gpt-5] [login-required] [Go to User Profile settings to sign in, then continue this session.] @@ -330,6 +349,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { edt { m.dismissLoginRequired() } flush() + assertTrue(appRpc.telemetry.any { + it.event == "Account Overlay Dismissed" && it.properties["reason"] == "paid_model_auth" + }) assertSession( """ [code] [kilo/gpt-5] [idle] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt index 8d33870f7f3..cc2c1abe24e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto class WorkspaceWatchingTest : SessionControllerTestBase() { @@ -10,13 +12,28 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { flush() events.clear() - projectRpc.state.value = workspaceReady() + projectRpc.state.value = workspaceReady( + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf( + "gpt-5" to ModelDto( + id = "gpt-5", + name = "GPT-5", + mayTrainOnYourPrompts = true, + ), + ), + ), + ), + ) flush() assertEquals(1, m.model.agents.size) assertEquals("code", m.model.agents[0].name) assertEquals(1, m.model.models.size) assertEquals("gpt-5", m.model.models[0].id) + assertTrue(m.model.models[0].mayTrainOnYourPrompts) assertFalse(m.model.isReady()) assertControllerEvents(""" AccountOverlayChanged show loggedIn=false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt new file mode 100644 index 00000000000..1c7c1930752 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.session.SessionActivityKind +import junit.framework.TestCase + +class HistoryActivitySnapshotTest : TestCase() { + fun `test activity kind change is changed`() { + val prev = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING)) + val next = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.QUESTION)) + + assertEquals(setOf("ses_1"), prev.changed(next)) + } + + fun `test activity removal is changed`() { + val prev = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING)) + + assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot())) + } + + fun `test title change is changed`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.RUNNING), + titles = mapOf("ses_1" to "Old"), + ) + val next = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.RUNNING), + titles = mapOf("ses_1" to "New"), + ) + + assertEquals(setOf("ses_1"), prev.changed(next)) + } + + fun `test title removal is changed`() { + val prev = HistoryActivitySnapshot(titles = mapOf("ses_1" to "Live")) + + assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot())) + } + + fun `test disposed overlay removal is changed once`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.PERMISSION), + titles = mapOf("ses_1" to "Live"), + ) + + assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot())) + } + + fun `test unchanged maps are not changed`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.PERMISSION), + titles = mapOf("ses_1" to "Live"), + ) + + assertEquals(emptySet(), prev.changed(prev.copy())) + } + + fun `test changed ids are unioned`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.RUNNING), + titles = mapOf("ses_2" to "Old"), + ) + val next = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.QUESTION), + titles = mapOf("ses_2" to "New"), + ) + + assertEquals(setOf("ses_1", "ses_2"), prev.changed(next)) + } +} 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 74fab599b59..3ccfb9de9a7 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 @@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.SessionRef import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -102,6 +104,21 @@ class HistoryControllerTest : BasePlatformTestCase() { assertEquals(FakeSessionRpcApi.CloudCall("/test", "next_1", 50, null), rpc.cloudCalls[1]) } + fun `test cloud load records page telemetry`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + rpc.cloudCursor = "next_1" + val events = mutableListOf>>() + val controller = telemetryController(events) + + controller.reloadCloud() + flush() + + val event = events.single { it.first == "History Cloud Page Loaded" } + assertEquals("true", event.second["reset"]) + assertEquals("1", event.second["count"]) + assertEquals("true", event.second["hasNextCursor"]) + } + fun `test local delete calls rpc and removes item`() { rpc.listed += session("ses_1", "Local One") val controller = controller() @@ -115,6 +132,145 @@ class HistoryControllerTest : BasePlatformTestCase() { assertTrue(controller.local.items.isEmpty()) } + fun `test opening history items records source telemetry`() { + val events = mutableListOf>>() + val controller = telemetryController(events) + + controller.open(LocalHistoryItem(session("ses_1", "Local One"))) + controller.open(CloudHistoryItem(cloud("cloud_1", "Cloud One"))) + flush() + + val opened = events.filter { it.first == "History Session Opened" } + assertEquals(listOf("local", "cloud"), opened.map { it.second["source"] }) + } + + fun `test activity returns typed items`() { + rpc.statuses.value = mapOf( + "ses_busy" to SessionStatusDto("busy"), + "ses_idle" to SessionStatusDto("idle"), + "ses_retry" to SessionStatusDto("retry"), + "ses_offline" to SessionStatusDto("offline"), + ) + flush() + + val activity = sessions.activity() + + assertEquals(mapOf("ses_busy" to SessionActivityKind.RUNNING), activity) + } + + fun `test controller activity returns service activity`() { + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + + val activity = controller().activity() + + assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), activity) + } + + fun `test local history renderer shows running badge for active id`() { + val item = LocalHistoryItem(session("ses_1", "Running")) + val controller = controller() + controller.local.replace(listOf(item)) + val renderer = LocalHistoryRenderer(controller.local, activity = { mapOf("ses_1" to SessionActivityKind.RUNNING) }) + + renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + assertTrue(renderer.runningVisible()) + } + + fun `test local history renderer uses title overlay`() { + val item = LocalHistoryItem(session("ses_1", "Stored")) + val controller = controller() + controller.local.replace(listOf(item)) + val renderer = LocalHistoryRenderer(controller.local, titles = { mapOf("ses_1" to "Live") }) + + renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + assertEquals("Live", renderer.titleText()) + } + + fun `test cloud history renderer hides running badge for inactive id`() { + val item = CloudHistoryItem(cloud("cloud_1", "Cloud")) + val controller = controller() + controller.cloud.replace(listOf(item), null) + val renderer = CloudHistoryRenderer(controller.cloud) { emptyMap() } + + renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + assertFalse(renderer.runningVisible()) + } + + fun `test history panel sync updates running badges`() { + rpc.listed += session("ses_1", "Local One") + val panel = HistoryPanel(parent, controller()) + flush() + assertFalse(panel.runningBadgeVisible(0)) + + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + panel.syncActivity() + + assertTrue(panel.runningBadgeVisible(0)) + assertEquals(KiloBundle.message("session.part.tool.running"), panel.badgeText(0)) + } + + fun `test history panel overlay shows specific badge`() { + rpc.listed += session("ses_1", "Local One") + val panel = HistoryPanel(parent, controller(), manager = object : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + override fun activity() = mapOf("ses_1" to SessionActivityKind.PERMISSION) + }) + flush() + + panel.syncActivity() + + assertEquals(KiloBundle.message("history.badge.permission"), panel.badgeText(0)) + } + + fun `test history panel sync repaints activity kind change`() { + rpc.listed += session("ses_1", "Local One") + var kind: SessionActivityKind? = null + val panel = HistoryPanel(parent, controller(), manager = object : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + override fun activity() = sessions.activity() + kind?.let { mapOf("ses_1" to it) }.orEmpty() + }) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + + panel.syncActivity() + assertEquals(KiloBundle.message("session.part.tool.running"), panel.badgeText(0)) + + kind = SessionActivityKind.QUESTION + panel.syncActivity() + + assertEquals(KiloBundle.message("history.badge.question"), panel.badgeText(0)) + } + + fun `test history panel sync uses live title overlay`() { + rpc.listed += session("ses_1", "Stored") + var title = "Live" + val panel = HistoryPanel(parent, controller(), manager = object : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + override fun titles() = title.takeIf { it.isNotBlank() }?.let { mapOf("ses_1" to it) }.orEmpty() + }) + flush() + + panel.syncActivity() + + assertEquals("Live", panel.titleText(0)) + + title = "" + panel.syncActivity() + + assertEquals("Stored", panel.titleText(0)) + } + fun `test panel filters and switches source`() { rpc.listed += session("ses_1", "Alpha") rpc.listed += session("ses_2", "Beta") @@ -670,6 +826,13 @@ class HistoryControllerTest : BasePlatformTestCase() { private fun controller() = HistoryController(sessions, workspace, scope) + private fun telemetryController(events: MutableList>>) = HistoryController( + sessions, + workspace, + scope, + telemetry = { event, props -> events.add(event to props) }, + ) + private fun controllerWithGit(url: String?) = HistoryController( sessions, workspace, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index fd8eea08536..075c26fc9a2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -9,16 +9,21 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer -import com.intellij.testFramework.UsefulTestCase +import com.intellij.testFramework.fixtures.BasePlatformTestCase -class SessionModelTest : UsefulTestCase() { +@Suppress("UnstableApiUsage") +class SessionModelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable @@ -49,6 +54,15 @@ class SessionModelTest : UsefulTestCase() { assertEquals(SessionState.Idle, model.state) } + fun `test model mutation works through EDT`() { + // The test fixture does not consistently throw for @RequiresEdt when called + // from a pooled thread, so keep this as a behavioral EDT contract check. + edt { model.addMessage(msg("on_edt", "assistant")) } + + assertNotNull(edt { model.message("on_edt") }) + assertTrue(events.any { it is SessionModelEvent.MessageAdded && it.info.info.id == "on_edt" }) + } + fun `test isReady requires app and workspace readiness`() { model.app = KiloAppStateDto(KiloAppStatusDto.READY) assertFalse(model.isReady()) @@ -121,6 +135,109 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events.single() is SessionModelEvent.ContentUpdated) } + fun `test updateContent ignores new empty text content`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = " ")) + + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + + fun `test updateContent removes existing text when it becomes empty`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "visible")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "")) + + assertNull(model.message("m1")!!.parts["p1"]) + assertEquals("ContentRemoved m1/p1", events.single().toString()) + } + + fun `test updateContent skips user synthetic text`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "raw content", synthetic = true)) + + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + + fun `test updateContent removes existing user text when marked synthetic`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "visible")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true)) + + assertNull(model.message("m1")!!.parts["p1"]) + assertEquals("ContentRemoved m1/p1", events.single().toString()) + } + + fun `test assistant synthetic text remains visible`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "visible", synthetic = true)) + + assertEquals("visible", (model.message("m1")!!.parts["p1"] as Text).content.toString()) + assertEquals("ContentAdded m1/p1", events.single().toString()) + } + + fun `test updateContent non synthetic text unhides previous synthetic user text`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true)) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "visible now")) + + assertModel(""" + user#m1 + text#p1: + visible now + """) + assertEquals("ContentAdded m1/p1", events.single().toString()) + } + + fun `test removeMessage clears hiddenText so a later non-synthetic part renders`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true)) + + model.removeMessage("m1") + events.clear() + + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "visible now")) + + assertModel(""" + user#m1 + text#p1: + visible now + """) + assertEquals("ContentAdded m1/p1", events.last().toString()) + } + + fun `test clear resets hiddenText so a later non-synthetic part renders`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true)) + + model.clear() + events.clear() + + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "visible now")) + + assertModel(""" + user#m1 + text#p1: + visible now + """) + assertEquals("ContentAdded m1/p1", events.last().toString()) + } + fun `test updateContent reasoning creates Reasoning content`() { model.addMessage(msg("m1", "assistant")) @@ -141,6 +258,31 @@ class SessionModelTest : UsefulTestCase() { assertTrue(p.done) } + fun `test updateContent file creates attachment content and updates metadata`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.updateContent("m1", filePart("f1", "m1", "image/png", "file:///tmp/a.png", "a.png")) + + val file = model.message("m1")!!.parts["f1"] as FileAttachment + assertEquals("image/png", file.mime) + assertEquals("file:///tmp/a.png", file.url) + assertEquals("a.png", file.filename) + assertEquals("ContentAdded m1/f1", events.single().toString()) + + model.updateContent("m1", filePart("f1", "m1", "application/pdf", "file:///tmp/b.pdf", "b.pdf")) + + assertSame(file, model.message("m1")!!.parts["f1"]) + assertEquals("application/pdf", file.mime) + assertEquals("file:///tmp/b.pdf", file.url) + assertEquals("b.pdf", file.filename) + assertTrue(events.any { it.toString() == "ContentUpdated m1/f1" }) + assertModel(""" + user#m1 + file#f1 application/pdf b.pdf + """) + } + fun `test updateContent tool creates Tool content and tracks state`() { model.addMessage(msg("m1", "assistant")) @@ -171,6 +313,8 @@ class SessionModelTest : UsefulTestCase() { fun `test updateContent tool stores rich fields`() { model.addMessage(msg("m1", "assistant")) + val todos = listOf(TodoDto("Write tests", "completed", "high", changed = true)) + val view = TodoViewDto("compact", todos, hiddenBefore = 1, hiddenAfter = 2, changed = 1) model.updateContent( "m1", @@ -184,6 +328,8 @@ class SessionModelTest : UsefulTestCase() { output = "abc123 init", error = "failed", time = PartTimeDto(1.0, 2.0), + todos = todos, + todoView = view, ), ) @@ -195,6 +341,8 @@ class SessionModelTest : UsefulTestCase() { assertEquals("failed", p.error) assertEquals(1.0, p.time?.start) assertEquals(2.0, p.time?.end) + assertEquals(todos, p.todos) + assertEquals(view, p.todoView) } fun `test updateContent tool updates lifecycle`() { @@ -214,15 +362,24 @@ class SessionModelTest : UsefulTestCase() { model.addMessage(msg("m1", "assistant")) model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending")) events.clear() + val todos = listOf(TodoDto("Review", "pending", "medium")) model.updateContent( "m1", - part("p1", "m1", "tool", tool = "bash", state = "completed", input = mapOf("command" to "git remote -v"), output = "origin"), + part( + "p1", "m1", "tool", + tool = "bash", + state = "completed", + input = mapOf("command" to "git remote -v"), + output = "origin", + todos = todos, + ), ) val p = model.message("m1")!!.parts["p1"] as Tool assertEquals("git remote -v", p.input["command"]) assertEquals("origin", p.output) + assertEquals(todos, p.todos) assertTrue(events.single() is SessionModelEvent.ContentUpdated) } @@ -314,6 +471,17 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events[1] is SessionModelEvent.ContentDelta) } + fun `test appendDelta ignores hidden synthetic user text part`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true)) + events.clear() + + model.appendDelta("m1", "p1", "still hidden") + + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + fun `test appendDelta on tool content is noop`() { model.addMessage(msg("m1", "assistant")) model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running")) @@ -493,18 +661,55 @@ class SessionModelTest : UsefulTestCase() { assertEquals("snapshot", (entry.parts["p2"] as Generic).type) } - fun `test loadHistory drops step-start and preserves step-finish parts`() { + fun `test loadHistory drops silent parts and preserves step-finish parts`() { val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") val stepStart = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "step-start") val stepFinish = PartDto(id = "p3", sessionID = "s1", messageID = "m1", type = "step-finish") + val patch = PartDto(id = "p4", sessionID = "s1", messageID = "m1", type = "patch") - model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish)))) + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish, patch)))) val entry = model.message("m1")!! assertEquals(listOf("p1", "p3"), entry.parts.keys.toList()) assertTrue(entry.parts["p3"] is StepFinish) } + fun `test loadHistory skips user synthetic text`() { + model.loadHistory(listOf(MessageWithPartsDto( + msg("m1", "user"), + listOf( + part("p1", "m1", "text", text = "visible"), + part("p2", "m1", "text", text = "hidden", synthetic = true), + ), + ))) + + assertModel(""" + user#m1 + text#p1: + visible + """) + } + + fun `test file attachment preserves source metadata`() { + val source = PartSourceDto("file", PartSourceTextDto("@src/a.kt", 0.0, 9.0), path = "src/a.kt") + model.addMessage(msg("m1", "user")) + + model.updateContent("m1", filePart("f1", "m1", "text/plain", "file:///tmp/a.kt", "a.kt", source)) + + val file = model.message("m1")!!.parts["f1"] as FileAttachment + assertEquals(source, file.source) + } + + fun `test updateContent drops patch parts`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "patch")) + + assertFalse(model.message("m1")!!.parts.containsKey("p1")) + assertTrue(events.isEmpty()) + } + fun `test upsertMessage adds new message and returns true`() { val added = model.upsertMessage(msg("m1", "user")) @@ -808,12 +1013,24 @@ class SessionModelTest : UsefulTestCase() { reason: String? = null, cost: Double? = null, tokens: TokensDto? = null, + todos: List = emptyList(), + todoView: TodoViewDto? = null, + mime: String? = null, + url: String? = null, + filename: String? = null, + synthetic: Boolean? = null, + source: PartSourceDto? = null, ) = PartDto( id = id, sessionID = "ses", messageID = mid, type = type, text = text, + mime = mime, + url = url, + filename = filename, + synthetic = synthetic, + source = source, tool = tool, state = state, title = title, @@ -822,11 +1039,23 @@ class SessionModelTest : UsefulTestCase() { output = output, error = error, time = time, + todos = todos, + todoView = todoView, reason = reason, cost = cost, tokens = tokens, ) + private fun filePart(id: String, mid: String, mime: String, url: String, filename: String, source: PartSourceDto? = null) = part( + id = id, + mid = mid, + type = "file", + mime = mime, + url = url, + filename = filename, + source = source, + ) + private fun question(id: String) = Question( id = id, items = listOf( @@ -852,4 +1081,11 @@ class SessionModelTest : UsefulTestCase() { private fun assertModel(expected: String) { assertEquals(expected.trimIndent().trim(), model.toString().trim()) } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } } 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 16536b70276..7507db6943f 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 @@ -91,6 +91,8 @@ class ConnectionPanelTest : SessionControllerTestBase() { flush() assertEquals(1, appRpc.retries) + assertEquals("Connection Retry Clicked", appRpc.telemetry.last().event) + assertEquals("ERROR", appRpc.telemetry.last().properties["appStatus"]) } fun `test ready warnings show collapsed banner with retry`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index ca717ded514..d0c6f0b0eab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -4,11 +4,14 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.ui.empty.EmptySessionPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -17,6 +20,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -25,8 +29,11 @@ import com.intellij.util.ui.components.BorderLayoutPanel 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 javax.swing.JButton @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { @@ -34,6 +41,8 @@ class EmptySessionPanelTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private lateinit var workspace: Workspace private lateinit var controller: SessionController + private lateinit var rpc: FakeSessionRpcApi + private lateinit var sessions: KiloSessionService private val opened = mutableListOf() override fun setUp() { @@ -46,10 +55,12 @@ class EmptySessionPanelTest : BasePlatformTestCase() { it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) }) workspace = workspaces.workspace("/test") + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) controller = SessionController( parent = testRootDisposable, ref = null, - sessions = KiloSessionService(project, scope, FakeSessionRpcApi()), + sessions = sessions, workspace = workspace, app = app, cs = scope, @@ -72,10 +83,10 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertFalse(panel.loadingVisible()) } - fun `test recent section remains visible when empty`() { + fun `test recent section is hidden when empty`() { val panel = panel() - assertTrue(panel.recentVisible()) + assertFalse(panel.recentVisible()) assertEquals(0, panel.recentCount()) } @@ -150,12 +161,21 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(ai.kilocode.client.plugin.KiloBundle.message("session.showHistory"), panel.showHistoryText()) } + fun `test feedback button uses localized text and icon`() { + val panel = panel() + + assertEquals(KiloBundle.message("feedback.button"), panel.feedbackText()) + assertNotNull(panel.feedbackIcon()) + } + fun `test action controls use hand cursor and no show history outline`() { val panel = panel() assertFalse(panel.showHistoryBorderPainted()) + assertFalse(panel.feedbackBorderPainted()) assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor()) - assertEquals(Cursor.HAND_CURSOR, panel.recentCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.feedbackCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.recent.list.cursor.type) } fun `test clicking show history delegates callback`() { @@ -167,6 +187,36 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(1, calls) } + fun `test feedback popup content opens expected destinations`() { + val panel = panel() + val opened = mutableListOf() + val content = panel.feedbackContent { opened.add(it) } + val buttons = UIUtil.uiTraverser(content).filter(JButton::class.java).toList() + + assertEquals( + listOf( + KiloBundle.message("feedback.dialog.github"), + KiloBundle.message("feedback.dialog.discord"), + KiloBundle.message("feedback.dialog.support"), + ), + buttons.map { it.text }, + ) + + buttons.forEach { it.doClick() } + + assertEquals(panel.feedbackUrls(), opened) + } + + fun `test feedback discord action has icon`() { + val panel = panel() + val content = panel.feedbackContent() + val discord = UIUtil.uiTraverser(content) + .filter(JButton::class.java) + .first { it.text == KiloBundle.message("feedback.dialog.discord") } + + assertNotNull(discord.icon) + } + fun `test renderer aligns title center and time east`() { val cell = panel().rendererComponent(session("ses_1")) as BorderLayoutPanel val layout = cell.layout as BorderLayout @@ -193,6 +243,94 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals("Untitled", label?.text) } + fun `test renderer uses title overlay`() { + val panel = panel( + recents = listOf(session("ses_1", title = "Stored")), + titles = { mapOf("ses_1" to "Live") }, + ) + + panel.syncActivity() + val cell = panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel + + assertEquals("Live", titleText(cell)) + } + + fun `test sync activity removes title overlay`() { + var title = "Live" + val panel = panel( + recents = listOf(session("ses_1", title = "Stored")), + titles = { title.takeIf { it.isNotBlank() }?.let { mapOf("ses_1" to it) }.orEmpty() }, + ) + + panel.syncActivity() + assertEquals("Live", titleText(panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel)) + + title = "" + panel.syncActivity() + + assertEquals("Stored", titleText(panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel)) + } + + fun `test renderer shows running badge for busy recent session`() { + val panel = panel(listOf(session("ses_1"))) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + panel.syncActivity() + + val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel + + assertEquals(KiloBundle.message("session.part.tool.running"), badgeText(cell)) + } + + fun `test renderer shows overlay badge for active recent session`() { + val panel = panel( + recents = listOf(session("ses_1")), + activity = { sessions.activity() + mapOf("ses_1" to SessionActivityKind.QUESTION) }, + ) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + panel.syncActivity() + + val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel + + assertEquals(KiloBundle.message("history.badge.question"), badgeText(cell)) + } + + fun `test sync activity updates recent badge kind change`() { + var kind: SessionActivityKind? = null + val panel = panel( + recents = listOf(session("ses_1")), + activity = { sessions.activity() + kind?.let { mapOf("ses_1" to it) }.orEmpty() }, + ) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + + panel.syncActivity() + assertEquals( + KiloBundle.message("session.part.tool.running"), + badgeText(panel.rendererComponent(session("ses_1")) as BorderLayoutPanel), + ) + + kind = SessionActivityKind.QUESTION + panel.syncActivity() + + assertEquals( + KiloBundle.message("history.badge.question"), + badgeText(panel.rendererComponent(session("ses_1")) as BorderLayoutPanel), + ) + } + + fun `test renderer hides running badge for idle recent session`() { + val panel = panel(listOf(session("ses_1"))) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) + flush() + panel.syncActivity() + + val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel + + assertNull(badgeText(cell)) + } + fun `test timestamp normalization handles seconds and milliseconds`() { assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000)))) assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000_000)))) @@ -208,8 +346,28 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) } - private fun panel(recents: List = emptyList(), history: () -> Unit = {}) = - EmptySessionPanel(testRootDisposable, controller, recents, history) + private fun panel( + recents: List = emptyList(), + history: () -> Unit = {}, + activity: () -> Map = { sessions.activity() }, + titles: () -> Map = { emptyMap() }, + ) = EmptySessionPanel(testRootDisposable, controller, recents, history, activity, titles) + + private fun flush() = runBlocking { + delay(100) + UIUtil.dispatchAllInvocationEvents() + } + + private fun badgeText(cell: BorderLayoutPanel): String? = UIUtil.uiTraverser(cell) + .filter(JBLabel::class.java) + .mapNotNull { (it.icon as? FilledBadgeIcon)?.takeIf { _ -> it.isVisible }?.text } + .firstOrNull() + + private fun titleText(cell: BorderLayoutPanel): String? = UIUtil.uiTraverser(cell) + .filter(JBLabel::class.java) + .filter { it.icon == null } + .firstOrNull() + ?.text private fun session(id: String, updated: Long = 2_000L, title: String = "Title $id") = SessionDto( id = id, 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 271a89e5617..7974709b08d 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 @@ -4,9 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -45,6 +48,15 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Thinking\u2026", panel.labelText()) } + fun `test panel uses transcript row padding`() { + val ins = panel.insets + + assertEquals(UiStyle.Gap.sm(), ins.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.bottom) + assertEquals(0, ins.right) + } + fun `test panel hides on Idle`() { model.setState(SessionState.Busy("Thinking\u2026")) model.setState(SessionState.Idle) @@ -60,6 +72,18 @@ 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)) + + assertFalse(panel.isVisible) + } + + fun `test panel hides on Offline`() { + model.setState(SessionState.Offline("Computer appears offline", requestId = "req1")) + + assertFalse(panel.isVisible) + } + fun `test panel hides on Error state`() { model.setState(SessionState.Busy("Thinking\u2026")) model.setState(SessionState.Error("something went wrong")) 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 1b210456b57..bac03d4c9ca 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 @@ -1,22 +1,116 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.PromptAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider +import ai.kilocode.client.session.ui.prompt.MentionAction +import ai.kilocode.client.session.ui.prompt.PROMPT_ATTACHMENT_PASTE_HANDLER_KEY +import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler +import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.SlashAction +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.test.CopyProviderSink +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.FileSearchResultDto +import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.ide.actions.UndoRedoAction import com.intellij.icons.AllIcons +import com.intellij.codeInsight.lookup.Lookup +import com.intellij.codeInsight.lookup.LookupManager +import com.intellij.codeInsight.lookup.LookupPositionStrategy +import com.intellij.codeInsight.lookup.impl.LookupImpl +import com.intellij.notification.Notification +import com.intellij.notification.Notifications +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DataKey import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +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.actions.PasteAction +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.util.Producer import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.awt.Container +import java.awt.BorderLayout +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.Base64 +import javax.imageio.ImageIO +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.ImageIcon +import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class PromptPanelTest : BasePlatformTestCase() { + private val roots = mutableListOf() + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var workspaces: KiloWorkspaceService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + rpc = FakeWorkspaceRpcApi() + workspaces = KiloWorkspaceService(scope, rpc) + } + + override fun tearDown() { + try { + roots.asReversed().forEach { it.removeNotify() } + roots.clear() + scope.cancel() + } finally { + super.tearDown() + } + } fun `test prompt input uses editor font settings`() { val style = SessionEditorStyle.current() - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val font = panel.inputFont() assertEquals(style.editorFamily, font.name) @@ -25,13 +119,13 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test prompt input uses editor background`() { val style = SessionEditorStyle.current() - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background) } fun `test applyStyle updates prompt input and height`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val style = SessionEditorStyle.create(family = "Courier New", size = 26) panel.applyStyle(style) @@ -41,8 +135,545 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(panel.preferredSize.height >= 26) } + fun `test prompt editor grows when lines are added`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val min = editor.preferredSize.height + + realize(panel, 260, 400) + editor.text = "one\ntwo\nthree\nfour\nfive" + + assertTrue(editor.preferredSize.height > min) + } + + fun `test prompt editor keeps three line minimum`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val view = editor.getEditor(false)!! + val min = view.lineHeight * SessionUiStyle.View.Prompt.EDITOR_LINES + + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME) + + assertEquals(min, editor.preferredSize.height) + } + + fun `test prompt editor grows when single line wraps`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val min = editor.preferredSize.height + + realize(panel, 180, 400) + editor.text = List(80) { "wrapped" }.joinToString(" ") + UIUtil.dispatchAllInvocationEvents() + + assertTrue(editor.preferredSize.height > min) + } + + fun `test enhanced prompt result resizes wrapped input`() { + var complete: ((Result) -> Unit)? = null + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done }) + val editor = panel.defaultFocusedComponent as EditorTextField + val min = editor.preferredSize.height + panel.setReady(true) + realize(panel, 180, 400) + editor.text = "draft" + + enhanceButton(panel).doClick() + complete!!(Result.success(List(80) { "enhanced" }.joinToString(" "))) + UIUtil.dispatchAllInvocationEvents() + + assertTrue(editor.preferredSize.height > min) + } + + fun `test empty enhance explanation resizes wrapped input`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val min = editor.preferredSize.height + panel.setReady(true) + realize(panel, 80, 400) + + enhanceButton(panel).doClick() + UIUtil.dispatchAllInvocationEvents() + + assertTrue(editor.preferredSize.height > min) + assertEquals(KiloBundle.message("prompt.action.enhance.description"), editor.text) + } + + fun `test prompt shell height is capped by session root`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val root = realize(panel, 260, 600) + + editor.text = (1..40).joinToString("\n") { "line $it" } + root.doLayout() + panel.doLayout() + UIUtil.dispatchAllInvocationEvents() + + val chrome = (panel.preferredSize.height - editor.preferredSize.height).coerceAtLeast(0) + assertTrue(editor.preferredSize.height <= root.height / 3 - chrome + 1) + } + + fun `test attachment strip is included in session root cap`() { + val plain = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val attached = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + realize(plain, 260, 600) + realize(attached, 260, 600) + val plainEditor = plain.defaultFocusedComponent as EditorTextField + val attachedEditor = attached.defaultFocusedComponent as EditorTextField + + plainEditor.text = (1..40).joinToString("\n") { "line $it" } + attached.addAttachmentForTest(PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt")) + attachedEditor.text = (1..40).joinToString("\n") { "line $it" } + UIUtil.dispatchAllInvocationEvents() + + assertTrue(attachedEditor.preferredSize.height < plainEditor.preferredSize.height) + } + + fun `test prompt editor scroll policy keeps horizontal disabled`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + realize(panel, 180, 400) + + val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertTrue(editor.settings.isUseSoftWraps) + assertFalse(editor.settings.isPaintSoftWraps) + } + + fun `test prompt editor highlights validated commands and mentions`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + rpc.fileResolver = { emptyList() } + + realize(panel, 260, 400) + field.text = "/new use ${MentionAction.GIT_CHANGES.token} and @unknown " + field.getEditor(false)!!.caretModel.moveToOffset(field.text.length) + panel.refreshHighlights() + waitForSend { spans(field).any { it.first == "@unknown" } } + + val spans = spans(field) + assertTrue(spans.contains("/new" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains(MentionAction.GIT_CHANGES.token to DefaultLanguageHighlighterColors.METADATA)) + assertTrue(spans.contains("@unknown" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES)) + } + + fun `test prompt editor exposes file editor for undo redo`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val editor = field.getEditor(false)!! + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(0, "hello") + } + val sink = TestSink() + (field as UiDataProvider).uiDataSnapshot(sink) + val file = sink.file as? TextEditor ?: error("missing file editor") + + assertNotNull(file) + assertSame(editor.document, file.editor.document) + UndoManager.getInstance(project).undo(file) + assertEquals("", editor.document.text) + UndoManager.getInstance(project).redo(file) + assertEquals("hello", editor.document.text) + } + + fun `test prompt editor platform undo redo actions target prompt editor`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val editor = field.getEditor(false)!! + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(0, "hello") + } + assertSame(true, editor.contentComponent.getClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER)) + val sink = TestSink() + (field as UiDataProvider).uiDataSnapshot(sink) + val file = sink.file as? TextEditor ?: error("missing file editor") + assertSame(editor.document, file.editor.document) + assertTrue("prompt file editor should have undo", UndoManager.getInstance(project).isUndoAvailable(file)) + + invokeAction(IdeActions.ACTION_UNDO, editor.contentComponent, file) + assertEquals("", editor.document.text) + invokeAction(IdeActions.ACTION_REDO, editor.contentComponent, file) + assertEquals("hello", editor.document.text) + } + + fun `test prompt editor component undo redo shortcuts target prompt editor`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val editor = field.getEditor(false)!! + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(0, "hello") + } + + invokeComponentAction("Kilo Session Undo", editor) + assertEquals("", editor.document.text) + invokeComponentAction("Kilo Session Redo", editor) + assertEquals("hello", editor.document.text) + } + + fun `test prompt editor highlights missing mention as wrong reference`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + rpc.fileResolver = { emptyList() } + + realize(panel, 260, 400) + field.text = "@missing " + field.getEditor(false)!!.caretModel.moveToOffset(field.text.length) + panel.refreshHighlights() + waitForSend { spans(field).contains("@missing" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES) } + + assertTrue(spans(field).contains("@missing" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES)) + } + + fun `test accepted file mention highlights immediately`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts"))) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "@dep" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + + invokeCompletionAction(editor) + waitForLookupItems(editor) + acceptLookup(editor) + waitForSend { spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA) } + + assertTrue(spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA)) + } + + fun `test invalid edited file mention highlights after caret leaves token`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts"))) + rpc.fileResolver = { path -> if (path == "src/deploy.ts") listOf(file(path)) else emptyList() } + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "@dep" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + invokeCompletionAction(editor) + waitForLookupItems(editor) + acceptLookup(editor) + waitForSend { spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA) } + + val offset = field.text.indexOf(' ') + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(offset, "x") + } + editor.caretModel.moveToOffset(offset + 1) + UIUtil.dispatchAllInvocationEvents() + editor.caretModel.moveToOffset(field.text.length) + waitForSend { spans(field).contains("@src/deploy.tsx" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES) } + + assertTrue(spans(field).contains("@src/deploy.tsx" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES)) + } + + fun `test prompt clear removes prompt highlighters`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "use ${MentionAction.GIT_CHANGES.token}" + UIUtil.dispatchAllInvocationEvents() + assertEquals(1, field.getEditor(false)!!.markupModel.allHighlighters.size) + + panel.clear() + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, field.getEditor(false)!!.markupModel.allHighlighters.size) + } + + fun `test prompt highlighters stay bounded across edits`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + repeat(50) { + field.text = if (it % 2 == 0) { + "/new ${MentionAction.GIT_CHANGES.token}" + } else { + "/new ${MentionAction.GIT_CHANGES.token} now" + } + UIUtil.dispatchAllInvocationEvents() + assertTrue(field.getEditor(false)!!.markupModel.allHighlighters.size <= 2) + } + } + + fun `test prompt local completion shortcut opens mention lookup`() { + rpc.searchResult = ai.kilocode.rpc.dto.FileSearchResultDto( + files = listOf(ai.kilocode.rpc.dto.WorkspaceFileDto("src/deploy.ts", "deploy.ts")), + ) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "@dep" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + + invokeCompletionAction(editor) + val items = waitForLookupItems(editor) + + assertTrue("items=$items", items.contains("src/deploy.ts")) + } + + fun `test prompt local completion shortcut opens slash lookup`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "/ne" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + + invokeCompletionAction(editor) + val items = waitForLookupItems(editor) + + assertTrue("items=$items", items.contains("new")) + } + + fun `test prompt completion lookup is positioned above caret`() { + rpc.searchResult = FileSearchResultDto( + files = listOf(WorkspaceFileDto("src/deploy.ts", "deploy.ts")), + ) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "@dep" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + + invokeCompletionAction(editor) + waitForLookupItems(editor) + val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup") + + assertEquals(LookupPositionStrategy.ONLY_ABOVE, lookup.presentation.positionStrategy) + } + + fun `test prompt editor shrinks when lines are removed`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val min = editor.preferredSize.height + editor.text = "one\ntwo\nthree\nfour\nfive" + assertTrue(editor.preferredSize.height > min) + + editor.text = "one" + + assertEquals(min, editor.preferredSize.height) + } + + fun `test prompt editor shrinks after clear`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val min = editor.preferredSize.height + editor.text = "one\ntwo\nthree\nfour\nfive" + assertTrue(editor.preferredSize.height > min) + + panel.clear() + + assertEquals(min, editor.preferredSize.height) + } + + fun `test prompt editor exposes selection copy provider`() { + val selection = SessionSelection() + val panel = PromptPanel(project = project, selection = selection, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val host = JPanel() + host.add(panel) + host.addNotify() + try { + editor.text = "alpha prompt" + + editor.getEditor(true)!!.selectionModel.setSelection(0, 5) + val sink = TestSink() + (editor as UiDataProvider).uiDataSnapshot(sink) + sink.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + editor.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + selection.dispose() + } + } + + fun `test prompt editor copies full content without selection`() { + val selection = SessionSelection() + val panel = PromptPanel(project = project, selection = selection, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val host = JPanel() + host.add(panel) + host.addNotify() + try { + editor.text = "alpha prompt" + + val sink = TestSink() + (editor as UiDataProvider).uiDataSnapshot(sink) + sink.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha prompt", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + editor.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + selection.dispose() + } + } + + fun `test attachment only prompt can send`() { + var sent = false + val panel = PromptPanel(project, { text, files -> + sent = text.isBlank() && files.single().url == "file:///tmp/a.png" + }, {}, { _, _ -> }) + panel.setReady(true) + + panel.addAttachmentForTest(PromptAttachment("a", "a.png", "image/png", "file:///tmp/a.png")) + panel.send() + waitForSend { sent } + + assertTrue(sent) + } + + fun `test submit resolves mentions from current text`() { + var text: String? = null + var sent: List? = null + val part = PromptPartDto(type = "file", mime = "text/plain", url = "file:///repo/src/x.kt") + val panel = PromptPanel( + project = project, + onSend = { _, files -> sent = files }, + onAbort = {}, + onEnhance = { _, _ -> }, + onMentions = { value -> + text = value + listOf(part) + }, + ) + val editor = panel.defaultFocusedComponent as EditorTextField + panel.setReady(true) + + editor.text = "read @src/x.kt" + panel.send() + waitForSend { sent != null } + + assertEquals("read @src/x.kt", text) + assertEquals(listOf(part), sent) + } + + fun `test clear removes attachments`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt")) + assertEquals(1, panel.attachmentCountForTest()) + + panel.clear() + + assertEquals(0, panel.attachmentCountForTest()) + } + + fun `test removed attachment can be added again`() { + val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt") + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(item) + attachmentRemoveButton(panel, item).doClick() + panel.addAttachmentForTest(item) + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test attachment card is compact icon only with tooltip metadata and hover remove`() { + val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a%20b.txt") + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(item) + + val button = attachmentRemoveButton(panel, item) + val card = attachmentCard(panel) + + assertFalse(button.isVisible) + assertTrue(card.toolTipText.contains("a.txt")) + assertTrue(card.toolTipText.contains("text/plain")) + assertTrue(card.toolTipText.contains("/tmp/a b.txt")) + assertFalse(card.toolTipText.contains("file:///")) + assertTrue(card.toolTipText.startsWith("")) + assertTrue(card.toolTipText.contains("Name: a.txt
    Type: text/plain
    Location: /tmp/a b.txt")) + assertFalse(labels(card).any { it.text == "a.txt" || it.text == "text/plain" || it.text == "/tmp/a b.txt" }) + assertTrue(components(card).filterIsInstance().any { it !== button && it.toolTipText == card.toolTipText }) + assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_WIDTH), card.preferredSize.width) + assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT), card.preferredSize.height) + assertEquals(0, card.getComponentZOrder(button)) + + val label = labels(card).first() + label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + + assertTrue(button.isVisible) + val icon = button.icon + button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + assertNotSame(icon, button.icon) + button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + assertSame(icon, button.icon) + } + + fun `test attachment tooltip hides embedded binary content`() { + val item = PromptAttachment("a", "a.png", "image/png", "data:image/png;base64,aGVsbG8=") + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(item) + + val tip = attachmentCard(panel).toolTipText + + assertTrue(tip.contains("Name: a.png")) + assertTrue(tip.contains("Type: image/png")) + assertTrue(tip.contains("Location: ${KiloBundle.message("prompt.attachment.embedded")}")) + assertFalse(tip.contains("data:image/png")) + assertFalse(tip.contains("base64")) + assertFalse(tip.contains("aGVsbG8=")) + } + + fun `test attachment child click opens item`() { + var opened = false + val card = AttachmentCard( + AttachmentCardItem("a.txt", "text/plain", "file:///tmp/a.txt"), + open = { opened = true }, + ) + + val label = labels(card).first() + label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + + assertTrue(opened) + } + + fun `test attachment card previews embedded image data`() { + val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) + val out = ByteArrayOutputStream() + ImageIO.write(image, "png", out) + val card = AttachmentCard( + AttachmentCardItem("a.png", "image/png", "data:image/png;base64,${Base64.getEncoder().encodeToString(out.toByteArray())}"), + ) + + card.addNotify() + repeat(20) { + UIUtil.dispatchAllInvocationEvents() + if (labels(card).any { it.icon is ImageIcon }) return@repeat + Thread.sleep(20) + } + + assertTrue(labels(card).any { it.icon is ImageIcon }) + } + fun `test reasoning picker hides when variants are empty`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.reasoning.setItems(emptyList()) @@ -50,12 +681,13 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test reasoning picker shows selected variant`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.reasoning.setItems(listOf(ReasoningPicker.Item("low", "Low"), ReasoningPicker.Item("high", "High")), "high") assertTrue(panel.reasoning.isVisible) assertEquals("high", panel.reasoning.selectedForTest()?.id) + assertEquals("High ▾", panel.reasoning.text) } fun `test reasoning picker aligns unchecked rows`() { @@ -73,7 +705,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test reset visibility can be toggled`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setResetVisible(true) @@ -81,7 +713,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt editor exposes send context`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val sink = TestSink() (panel.defaultFocusedComponent as UiDataProvider).uiDataSnapshot(sink) @@ -90,7 +722,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt button exposes send context`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val sink = TestSink() (panel.buttonForTest() as UiDataProvider).uiDataSnapshot(sink) @@ -98,8 +730,106 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(panel, sink.send) } + fun `test prompt paste provider invokes registered handler`() { + val editor = createEditor() + val item = FileListTransferable(listOf(File.createTempFile("kilo-paste", ".txt"))) + var seen: Transferable? = null + editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { seen = it }) + + try { + PromptAttachmentPasteProvider().performPaste(pasteContext(editor, item)) + + assertSame(item, seen) + } finally { + EditorFactory.getInstance().releaseEditor(editor) + } + } + + fun `test file list paste adds attachment`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".txt") + file.writeText("hello") + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file)))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test frontend file attachment defers data url encoding until send`() { + val file = File.createTempFile("kilo-paste", ".txt") + file.writeText("hello") + + val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single() + + assertTrue(item.url.startsWith("file://")) + assertTrue(item.part().url.orEmpty().startsWith("data:text/plain;base64,")) + } + + fun `test pasted frontend file sends data url payload`() { + var sent: ai.kilocode.rpc.dto.PromptPartDto? = null + val panel = PromptPanel(project, { _, files -> sent = files.single() }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".txt") + file.writeText("hello") + panel.setReady(true) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file)))) + UIUtil.dispatchAllInvocationEvents() + panel.send() + waitForSend { sent != null } + + val item = sent!! + assertEquals("text/plain", item.mime) + assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,")) + assertFalse(item.url.orEmpty().startsWith("file://")) + } + + fun `test raw image paste adds attachment`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(image))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test file paste ignores companion image flavor`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".png") + file.writeBytes(byteArrayOf()) + val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileImageTransferable(listOf(file), image))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test normal text paste is not intercepted`() { + val editor = createEditor() + val provider = PromptAttachmentPasteProvider() + editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler {}) + + try { + assertFalse(provider.isPasteEnabled(pasteContext(editor, StringSelection("hello")))) + } finally { + EditorFactory.getInstance().releaseEditor(editor) + } + } + + fun `test disabled media model blocks pasted image`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + panel.setAttachmentEnabled(false) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, panel.attachmentCountForTest()) + } + fun `test prompt button switches between send and stop state`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) assertEquals(KeymapUtil.createTooltipText("Send", "Kilo.SendPrompt"), panel.buttonForTest().toolTipText) assertFalse(panel.isStopEnabled) @@ -110,8 +840,152 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(panel.isStopEnabled) } + fun `test auto approve button toggles and updates tooltip`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val button = autoApproveButton(panel) + var seen: Boolean? = null + panel.onAutoApproveToggle = { seen = it } + + assertFalse(button.isSelected) + assertEquals(KiloBundle.message("prompt.action.autoApprove.enable"), button.accessibleContext.accessibleName) + assertEquals(KiloBundle.message("prompt.action.autoApprove.disabled.tooltip"), button.toolTipText) + val icon = button.icon + + button.doClick() + + assertEquals(true, seen) + + panel.setAutoApprove(true) + + assertTrue(button.isSelected) + assertNotSame(icon, button.icon) + assertEquals(KiloBundle.message("prompt.action.autoApprove.disable"), button.accessibleContext.accessibleName) + assertEquals(KiloBundle.message("prompt.action.autoApprove.enabled.tooltip"), button.toolTipText) + + button.doClick() + + assertEquals(false, seen) + + panel.setAutoApprove(false) + + assertSame(icon, button.icon) + } + + fun `test auto approve and enhance buttons sit next to send button`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val auto = autoApproveButton(panel) + val enhance = enhanceButton(panel) + val send = panel.buttonForTest() + val items = auto.parent.components.toList() + + assertTrue(SwingUtilities.isDescendingFrom(auto, panel.shellForTest())) + assertSame(auto.parent, enhance.parent) + assertSame(auto.parent, send.parent) + assertEquals(2, items.indexOf(enhance) - items.indexOf(auto)) + assertEquals(2, items.indexOf(send) - items.indexOf(enhance)) + } + + fun `test enhance button follows connection and busy state`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val enhance = enhanceButton(panel) + + assertFalse(enhance.isEnabled) + + panel.setReady(true) + assertTrue(enhance.isEnabled) + + panel.setBusy(true) + assertFalse(enhance.isEnabled) + + panel.setBusy(false) + assertTrue(enhance.isEnabled) + } + + fun `test enhance button rewrites active draft`() { + var seen: String? = null + var complete: ((Result) -> Unit)? = null + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { text, done -> + seen = text + complete = done + }) + val editor = panel.defaultFocusedComponent as EditorTextField + val enhance = enhanceButton(panel) + panel.setReady(true) + editor.text = " make a plan " + + enhance.doClick() + + assertEquals("make a plan", seen) + assertFalse(enhance.isEnabled) + assertTrue(enhance.icon is AnimatedIcon) + val icon = enhance.icon + + panel.setReady(true) + + assertSame(icon, enhance.icon) + + complete!!(Result.success("Use a focused implementation plan")) + + assertEquals("Use a focused implementation plan", editor.text) + assertTrue(enhance.isEnabled) + assertFalse(enhance.icon is AnimatedIcon) + } + + fun `test edit while enhancing ignores stale completion`() { + var complete: ((Result) -> Unit)? = null + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done }) + val editor = panel.defaultFocusedComponent as EditorTextField + val enhance = enhanceButton(panel) + panel.setReady(true) + editor.text = "first draft" + + enhance.doClick() + editor.text = "edited draft" + complete!!(Result.success("stale result")) + + assertEquals("edited draft", editor.text) + assertTrue(enhance.isEnabled) + } + + fun `test cancelled enhancement restores button without notification`() { + val notes = mutableListOf() + val listener = object : Notifications { + override fun notify(notification: Notification) { + notes.add(notification) + } + } + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener) + project.messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener) + var complete: ((Result) -> Unit)? = null + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done }) + val editor = panel.defaultFocusedComponent as EditorTextField + val enhance = enhanceButton(panel) + panel.setReady(true) + editor.text = "keep this draft" + + enhance.doClick() + complete!!(Result.failure(CancellationException("disposed"))) + + assertEquals("keep this draft", editor.text) + assertTrue(enhance.isEnabled) + assertFalse(enhance.icon is AnimatedIcon) + assertTrue(notes.isEmpty()) + } + + fun `test empty enhancement inserts explanation without request`() { + var requests = 0 + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> requests++ }) + val editor = panel.defaultFocusedComponent as EditorTextField + panel.setReady(true) + + enhanceButton(panel).doClick() + + assertEquals(0, requests) + assertEquals(KiloBundle.message("prompt.action.enhance.description"), editor.text) + } + fun `test pickers belong to rounded shell`() { - val panel = PromptPanel(project, {}, {}) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val shell = panel.shellForTest() assertTrue(SwingUtilities.isDescendingFrom(panel.mode, shell)) @@ -120,34 +994,237 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(shell, panel.mode.parent.parent) } - private class TestSink : DataSink { - var send: Any? = null + private fun autoApproveButton(panel: PromptPanel): JButton { + val enable = KiloBundle.message("prompt.action.autoApprove.enable") + val disable = KiloBundle.message("prompt.action.autoApprove.disable") + return buttons(panel).first { + val name = it.accessibleContext.accessibleName + name == enable || name == disable + } + } - override fun set(key: com.intellij.openapi.actionSystem.DataKey, data: T?) { - if (key == PromptDataKeys.SEND) send = data + private fun attachmentRemoveButton(panel: PromptPanel, item: PromptAttachment): JButton { + val name = KiloBundle.message("prompt.attachment.remove", item.name) + return buttons(panel).first { it.accessibleContext.accessibleName == name } + } + + private fun attachmentCard(root: java.awt.Component): AttachmentCard { + fun visit(node: java.awt.Component): AttachmentCard? { + if (node is AttachmentCard) return node + if (node is Container) { + for (child in node.components) { + val card = visit(child) + if (card != null) return card + } + } + return null + } + return visit(root)!! + } + + private fun enhanceButton(panel: PromptPanel): JButton { + val name = KiloBundle.message("prompt.action.enhance") + return buttons(panel).first { it.accessibleContext.accessibleName == name } + } + + private fun buttons(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + if (node is JButton) out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun labels(root: java.awt.Component): List { + return components(root).filterIsInstance() + } + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun realize(panel: PromptPanel, width: Int, height: Int): SessionRootPanel { + val root = SessionRootPanel() + root.setSize(width, height) + root.content.add(JPanel(BorderLayout()).apply { add(panel, BorderLayout.SOUTH) }, BorderLayout.CENTER) + root.addNotify() + root.doLayout() + panel.doLayout() + UIUtil.dispatchAllInvocationEvents() + roots.add(root) + return root + } + + private fun completion() = KiloPromptCompletionProvider( + workspace = workspaces.workspace("/test"), + service = workspaces, + actions = listOf( + SlashAction(SlashAction.NEW.name, "New") {}, + SlashAction("next", "Next") {}, + ), + mentions = listOf(MentionAction( + MentionAction.GIT_CHANGES.name, + "Git Changes", + available = MentionAction.GIT_CHANGES.available, + )), + scope = scope, + ) + + private fun invokeCompletionAction(editor: Editor) { + val action = ActionUtil.getActions(editor.contentComponent).first { item -> + item.templatePresentation.text == "Kilo Prompt Completion" } + val event = event(action, editor) + ActionUtil.updateAction(action, event) + ActionUtil.performAction(action, event) + } + + private fun invokeComponentAction(text: String, editor: Editor) { + val action = ActionUtil.getActions(editor.contentComponent).first { item -> + item.templatePresentation.text == text + } + val event = event(action, editor) + ActionUtil.updateAction(action, event) + assertTrue("action $text should be enabled", event.presentation.isEnabled) + ActionUtil.performAction(action, event) + UIUtil.dispatchAllInvocationEvents() + } + + private fun invokeAction(id: String, component: java.awt.Component, file: TextEditor) { + val action = ActionManager.getInstance().getAction(id) ?: error("missing action $id") + val ctx = DataContext { data -> + when (data) { + CommonDataKeys.PROJECT.name -> project + PlatformCoreDataKeys.CONTEXT_COMPONENT.name -> component + PlatformCoreDataKeys.FILE_EDITOR.name -> file + else -> null + } + } + val event = AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null) + ActionUtil.updateAction(action, event) + assertTrue("action $id should be enabled", event.presentation.isEnabled) + ActionUtil.performAction(action, event) + UIUtil.dispatchAllInvocationEvents() + } + + private fun waitForLookupItems(editor: Editor): List { + repeat(50) { + UIUtil.dispatchAllInvocationEvents() + val items = LookupManager.getActiveLookup(editor)?.items.orEmpty().map { item -> item.lookupString } + if (items.isNotEmpty()) return items + Thread.sleep(20) + } + return LookupManager.getActiveLookup(editor)?.items.orEmpty().map { it.lookupString } + } + + private fun acceptLookup(editor: Editor) { + val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup") + lookup.finishLookup(Lookup.NORMAL_SELECT_CHAR) + UIUtil.dispatchAllInvocationEvents() + } + + private fun file(path: String) = WorkspaceFileDto( + path = path, + name = path.substringAfterLast('/'), + ) + + private fun event(action: AnAction, editor: Editor): AnActionEvent { + val ctx = DataContext { id -> + when (id) { + CommonDataKeys.EDITOR.name -> editor + CommonDataKeys.PROJECT.name -> project + else -> null + } + } + return AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null) + } + + private fun spans(field: EditorTextField): List> { + val editor = field.getEditor(false)!! + return editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + } + + private fun createEditor(): Editor { + val factory = EditorFactory.getInstance() + return factory.createEditor(factory.createDocument(""), project) + } - override fun setNull(key: com.intellij.openapi.actionSystem.DataKey) { + private fun waitForSend(done: () -> Boolean) { + repeat(50) { + UIUtil.dispatchAllInvocationEvents() + if (done()) return + Thread.sleep(20) } + } + + private fun pasteContext(editor: Editor, item: Transferable) = DataContext { id -> + when (id) { + CommonDataKeys.EDITOR.name -> editor + PasteAction.TRANSFERABLE_PROVIDER.name -> Producer { item } + else -> null + } + } + + private class FileListTransferable(private val files: List) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.javaFileListFlavor) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.javaFileListFlavor - override fun lazyNull(key: com.intellij.openapi.actionSystem.DataKey) { + override fun getTransferData(flavor: DataFlavor): Any { + if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor) + return files } + } + + private class ImageTransferable(private val image: BufferedImage) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.imageFlavor) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor - override fun lazyValue( - key: com.intellij.openapi.actionSystem.DataKey, - data: (com.intellij.openapi.actionSystem.DataMap) -> T?, - ) { + override fun getTransferData(flavor: DataFlavor): Any { + if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor) + return image } + } + + private class FileImageTransferable( + private val files: List, + private val image: BufferedImage, + ) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf( + DataFlavor.javaFileListFlavor, + DataFlavor.imageFlavor, + ) - override fun uiDataSnapshot(provider: com.intellij.openapi.actionSystem.UiDataProvider) { - provider.uiDataSnapshot(this) + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean { + return flavor == DataFlavor.javaFileListFlavor || flavor == DataFlavor.imageFlavor } - override fun dataSnapshot(provider: com.intellij.openapi.actionSystem.DataSnapshotProvider) { - provider.dataSnapshot(this) + override fun getTransferData(flavor: DataFlavor): Any { + if (flavor == DataFlavor.javaFileListFlavor) return files + if (flavor == DataFlavor.imageFlavor) return image + throw java.awt.datatransfer.UnsupportedFlavorException(flavor) } + } + + private class TestSink : CopyProviderSink() { + var send: Any? = null + var file: Any? = null - override fun uiDataSnapshot(provider: com.intellij.openapi.actionSystem.DataProvider) { + override fun set(key: DataKey, data: T?) { + super.set(key, data) + if (key == PromptDataKeys.SEND) send = data + if (key == PlatformCoreDataKeys.FILE_EDITOR) file = data } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index cc5b1130da0..46fc602efa2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -9,32 +9,41 @@ import java.awt.Font @Suppress("UnstableApiUsage") class SessionEditorStyleTest : BasePlatformTestCase() { - fun `test transcript font uses editor settings`() { + fun `test transcript font uses ui family and editor size`() { val scheme = EditorColorsManager.getInstance().globalScheme val style = SessionEditorStyle.current() val font = style.transcriptFont - assertEquals(scheme.editorFontName, font.name) + assertEquals(UiStyle.Fonts.regular().name, font.name) assertEquals(scheme.editorFontSize, font.size) assertEquals(scheme.defaultForeground, style.editorForeground) assertEquals(scheme.defaultBackground, style.editorBackground) assertEquals(Font.PLAIN, font.style) } - fun `test bold editor font uses editor family and size`() { + fun `test editor font uses editor family and size`() { val style = SessionEditorStyle.current() - val font = style.boldEditorFont + val font = style.editorFont assertEquals(style.editorFamily, font.name) assertEquals(style.editorSize, font.size) + assertEquals(Font.PLAIN, font.style) + } + + fun `test bold transcript font uses ui family and editor size`() { + val style = SessionEditorStyle.current() + val font = style.boldEditorFont + + assertEquals(UiStyle.Fonts.regular().name, font.name) + assertEquals(style.editorSize, font.size) assertTrue(font.isBold) } - fun `test small editor font uses editor family with smaller editor-derived size`() { + fun `test small transcript font uses ui family with smaller editor-derived size`() { val style = SessionEditorStyle.current() val font = style.smallEditorFont - assertEquals(style.editorFamily, font.name) + assertEquals(UiStyle.Fonts.small().name, font.name) assertTrue(font.size < style.editorSize) } @@ -43,9 +52,10 @@ class SessionEditorStyleTest : BasePlatformTestCase() { assertEquals("Courier New", style.editorFamily) assertEquals(22, style.editorSize) - assertEquals("Courier New", style.transcriptFont.name) + assertEquals("Courier New", style.editorFont.name) + assertEquals(UiStyle.Fonts.regular().name, style.transcriptFont.name) assertEquals(22, style.transcriptFont.size) - assertEquals("Courier New", style.boldEditorFont.name) + assertEquals(UiStyle.Fonts.regular().name, style.boldEditorFont.name) assertEquals(22, style.boldEditorFont.size) assertTrue(style.boldEditorFont.isBold) assertTrue(style.smallEditorFont.size < style.editorSize) 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 45959beeb29..4b3e9c98e10 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -9,20 +9,35 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView +import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView +import ai.kilocode.client.session.views.MessageToolbar +import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.TextView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component import java.awt.Container +import java.awt.Point +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.swing.JPanel +import javax.swing.SwingUtilities +import javax.swing.border.Border /** * Tests for [SessionMessageListPanel] — structural and index integrity. @@ -37,12 +52,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable private lateinit var panel: SessionMessageListPanel + private val openFile: (String) -> Unit = {} override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent) + panel = SessionMessageListPanel(model, parent, openFile = openFile) } override fun tearDown() { @@ -161,6 +177,77 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.part("p1") is TextView) } + fun `test user prompt text part gets copy toolbar`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "hello")) + + val view = panel.findMessage("u1")!!.part("p1") as TextView + val message = panel.findMessage("u1")!! + assertNotNull(find(message)) + assertFalse(view.hasCopyToolbar()) + assertEquals(BorderLayout.LINE_END, message.promptToolbarAlignment()) + assertFalse(message.paintsPromptToolbar()) + + message.setPromptHovered(true) + + assertTrue(message.paintsPromptToolbar()) + + message.setPromptHovered(false) + + assertFalse(message.paintsPromptToolbar()) + } + + fun `test latest non blank assistant text part gets copy toolbar`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + model.updateContent("a1", part("p2", "a1", "text", text = "second")) + + val first = panel.findMessage("a1")!!.part("p1") as TextView + val second = panel.findMessage("a1")!!.part("p2") as TextView + + assertFalse(first.hasCopyToolbar()) + assertTrue(second.hasCopyToolbar()) + } + + fun `test assistant copy toolbar moves back when latest text is removed`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + model.updateContent("a1", part("p2", "a1", "text", text = "second")) + val first = panel.findMessage("a1")!!.part("p1") as TextView + + model.removeContent("a1", "p2") + + assertTrue(first.hasCopyToolbar()) + } + + fun `test assistant copy target spans newest assistant message in turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("a2", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + model.updateContent("a2", part("p2", "a2", "text", text = "second")) + + val first = panel.findMessage("a1")!!.part("p1") as TextView + val second = panel.findMessage("a2")!!.part("p2") as TextView + + assertFalse(first.hasCopyToolbar()) + assertTrue(second.hasCopyToolbar()) + } + + fun `test text markdown link uses panel url opener`() { + val urls = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) }) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "[docs](https://kilocode.ai/docs)")) + + val view = item.findMessage("a1")!!.part("p1") as TextView + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + fun `test ContentDelta appends text to TextView`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "hello ")) @@ -170,6 +257,91 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("hello world", tv.markdown()) } + fun `test ContentDelta preserves TextView and markdown component`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first\n\nsecond")) + val mv = panel.findMessage("a1")!! + val tv = mv.part("p1") as TextView + val comp = tv.md.component + val first = (comp as JPanel).components.first() + + model.appendDelta("a1", "p1", " more") + + assertSame(tv, mv.part("p1")) + assertSame(comp, tv.md.component) + assertSame(tv.copyButton(), (mv.part("p1") as TextView).copyButton()) + assertSame(first, comp.components.first()) + assertEquals("first\n\nsecond more", tv.markdown()) + } + + fun `test streaming assistant text keeps copy toolbar stable and bounded`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "start")) + val mv = panel.findMessage("a1")!! + val tv = mv.part("p1") as TextView + val comp = tv.md.component + val btn = tv.copyButton() + val count = count(tv) + + repeat(200) { model.appendDelta("a1", "p1", " token$it") } + + assertSame(tv, mv.part("p1")) + assertSame(comp, tv.md.component) + assertSame(btn, tv.copyButton()) + assertEquals(count, count(tv)) + assertTrue(tv.hasCopyToolbar()) + } + + fun `test streaming new assistant text updates copy target without rebuilding previous text`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + val first = panel.findMessage("a1")!!.part("p1") as TextView + val comp = first.md.component + val button = first.copyButton() + + model.appendDelta("a1", "p2", "second") + + val second = panel.findMessage("a1")!!.part("p2") as TextView + assertSame(first, panel.findMessage("a1")!!.part("p1")) + assertSame(comp, first.md.component) + assertSame(button, first.copyButton()) + assertFalse(first.hasCopyToolbar()) + assertTrue(second.hasCopyToolbar()) + } + + fun `test prompt box paints at wrapped prompt coordinates`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("file1", "u1", "file", text = null)) + model.updateContent("u1", part("p1", "u1", "text", text = "hello")) + val message = panel.findMessage("u1")!! + message.setSize(400, message.preferredSize.height) + message.doLayout() + layout(message) + val box = promptBox(message) + val point = SwingUtilities.convertPoint(box, Point(), message) + assertTrue("prompt box should be below attachment", point.y > 0) + + val image = BufferedImage(message.width, message.height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + message.paint(graphics) + graphics.dispose() + + val line = SessionUiStyle.View.Outline.color().rgb + assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb) + assertFalse(line == Color(image.getRGB(point.x + box.width / 2, 0), true).rgb) + } + + fun `test created ContentDelta is not double applied`() { + model.upsertMessage(msg("a1", "assistant")) + + model.appendDelta("a1", "p1", "hello") + + val tv = panel.findMessage("a1")!!.part("p1") as TextView + assertEquals("hello", tv.markdown()) + } + fun `test ContentRemoved removes PartView from MessageView`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "x")) @@ -254,6 +426,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertSame(message, panel.findMessage("a1")) assertSame(text, panel.findMessage("a1")!!.part("p1")) assertSame(comp, text.md.component) + assertTrue(text.md.overrideSheet().contains(style.transcriptFont.name)) assertTrue(text.md.overrideSheet().contains("Courier New")) assertTrue(text.md.overrideSheet().contains("24pt")) } @@ -266,6 +439,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { model.updateContent("a1", part("p1", "a1", "text", text = "hello")) val text = panel.findMessage("a1")!!.part("p1") as TextView + assertTrue(text.md.overrideSheet().contains(style.transcriptFont.name)) assertTrue(text.md.overrideSheet().contains("Courier New")) assertTrue(text.md.overrideSheet().contains("25pt")) } @@ -426,6 +600,27 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.part("tp1") is ToolView) } + fun `test todo tools are suppressed until todowrite completes`() { + val item = panelWithPrompts() + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("read", "a1", "todoread", "call1", state = "completed")) + model.updateContent("a1", toolPart("write", "a1", "todowrite", "call2", state = "running")) + + val mv = item.findMessage("a1")!! + assertEquals(emptyList(), mv.partIds()) + + model.updateContent( + "a1", + toolPart( + "write", "a1", "todowrite", "call2", state = "completed", + todos = listOf(TodoDto("Done", "completed", "high")), + ), + ) + + assertEquals(listOf("write"), mv.partIds()) + assertTrue(mv.part("write") is TodoWriteView) + } + fun `test completed question update replaces generic tool view with question result view`() { val item = panelWithPrompts() model.upsertMessage(msg("a1", "assistant")) @@ -449,19 +644,80 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf("tp1"), mv.partIds()) } + fun `test completed plan update replaces tool view and keeps open file action`() { + val opened = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = { opened.add(it) }) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("tp1", "a1", "plan_exit", "call1", state = "running")) + + val mv = item.findMessage("a1")!! + assertTrue(mv.part("tp1") is ToolView) + + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "plan_exit", "call1", state = "completed", + metadata = mapOf("plan" to ".kilo/plans/x.md"), + ), + ) + + val view = mv.part("tp1") as PlanExitView + view.simulateLink(".kilo/plans/x.md") + + assertEquals(listOf(".kilo/plans/x.md"), opened) + } + + fun `test entering a second hoverable part clears stale first hover`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "question", "call1", state = "completed", + input = mapOf("questions" to """[{"question":"First?"}]"""), + metadata = mapOf("answers" to """[["Yes"]]"""), + ), + ) + model.updateContent( + "a1", + toolPart( + "tp2", "a1", "question", "call2", state = "completed", + input = mapOf("questions" to """[{"question":"Second?"}]"""), + metadata = mapOf("answers" to """[["No"]]"""), + ), + ) + val first = panel.findMessage("a1")!!.part("tp1") as QuestionResultView + val second = panel.findMessage("a1")!!.part("tp2") as QuestionResultView + val firstRoot = root(first) + val secondRoot = root(second) + + first.toggle() + second.toggle() + + enter(header(first)) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(first).background.rgb) + assertLine(firstRoot.border) + + enter(header(second)) + + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header(first).background.rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(second).background.rgb) + assertLine(firstRoot.border) + assertLine(secondRoot.border) + } + // ------ helpers ------ private fun panelWithPrompts(): SessionMessageListPanel { val q = QuestionView( project = project, - reply = { _, _ -> }, + reply = { _, _, _ -> }, reject = { _ -> }, ) val p = PermissionView( reply = { _, _ -> }, ) val l = LoginRequiredView(openProfile = {}, dismiss = {}) - return SessionMessageListPanel(model, parent, q, p, l) + return SessionMessageListPanel(model, parent, q, p, l, openFile) } private inline fun find(root: Container): T? = findCls(root, T::class.java) @@ -517,8 +773,63 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { state: String = "running", input: Map = emptyMap(), metadata: Map = emptyMap(), + todos: List = emptyList(), ) = PartDto( id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state, - input = input, metadata = metadata, + input = input, metadata = metadata, todos = todos, ) + + private fun root(view: QuestionResultView) = view.components[0] as JPanel + + private fun header(view: QuestionResultView) = root(view).components[0] as JPanel + + private fun enter(component: Component) { + component.dispatchEvent(MouseEvent( + component, + MouseEvent.MOUSE_ENTERED, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } + + private fun count(root: Component): Int { + if (root !is Container) return 1 + return 1 + root.components.sumOf(::count) + } + + private fun layout(root: Container) { + root.doLayout() + for (child in root.components) if (child is Container) layout(child) + } + + private fun promptBox(root: MessageView): Component { + return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView } + } + + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(node: Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt index 8135144a541..6f8be31660e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -1,6 +1,10 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBFont import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension import java.awt.Rectangle @@ -9,17 +13,31 @@ import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") class SessionRootPanelTest : BasePlatformTestCase() { - fun `test root owns content and overlay layers`() { + fun `test root owns content overlay and blocker layers`() { val root = SessionRootPanel() - assertEquals(2, root.componentCount) + assertEquals(3, root.componentCount) assertSame(root.content, root.components.first { it === root.content }) assertSame(root.overlay, root.components.first { it === root.overlay }) + assertSame(root.blocker, root.components.first { it === root.blocker }) assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker)) } - fun `test root layout fills immediate children`() { + fun `test blocker is hidden by default`() { + val root = SessionRootPanel() + assertFalse(root.blocker.isVisible) + } + + fun `test blocker is opaque and uses panel background`() { + val root = SessionRootPanel() + + assertTrue(root.blocker.isOpaque) + assertEquals(UiStyle.Colors.bg(), root.blocker.background) + } + + fun `test root layout fills all immediate children`() { val root = SessionRootPanel().apply { setSize(320, 180) } @@ -28,9 +46,10 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds) } - fun `test root preferred size is max of immediate children`() { + fun `test root preferred size is max of content and overlay`() { val root = SessionRootPanel().apply { content.preferredSize = Dimension(300, 120) overlay.preferredSize = Dimension(180, 220) @@ -55,6 +74,115 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertTrue(child.laid) } + fun `test drop overlay starts visible but never captures hit tests`() { + val drop = SessionDropOverlay().apply { + setSize(200, 100) + } + val card = dropCard(drop) + + assertTrue(drop.isVisible) + assertFalse(drop.contains(50, 50)) + assertFalse(card.isVisible) + + drop.setActive(true) + assertFalse(drop.contains(50, 50)) + assertTrue(card.isVisible) + + drop.setActive(false) + assertFalse(drop.contains(50, 50)) + assertFalse(card.isVisible) + } + + fun `test drop overlay can fill root overlay bounds`() { + val root = SessionRootPanel().apply { + setSize(400, 260) + } + val drop = SessionDropOverlay() + + root.addOverlay(drop) { pane, _ -> + Rectangle(0, 0, pane.width, pane.height) + } + root.doLayout() + + assertEquals(Rectangle(0, 0, 400, 260), drop.bounds) + } + + fun `test drop overlay labels use platform heading fonts`() { + val drop = SessionDropOverlay() + val labels = dropLabels(drop) + + assertEquals("Drop files here", labels[0].text) + assertEquals(JBFont.h0(), labels[0].font) + assertEquals("to add them to the prompt", labels[1].text) + assertEquals(JBFont.h2(), labels[1].font) + assertEquals(AllIcons.Actions.Download.iconWidth * 3, labels[2].icon.iconWidth) + assertEquals(AllIcons.Actions.Download.iconHeight * 3, labels[2].icon.iconHeight) + } + + fun `test drop overlay is registered in overlay layer not blocker`() { + val root = SessionRootPanel() + val drop = SessionDropOverlay() + + root.addOverlay(drop) { pane, _ -> + Rectangle(0, 0, pane.width, pane.height) + } + + assertSame(root.overlay, drop.parent) + assertFalse(root.blocker.components.contains(drop)) + } + + fun `test setBlocked makes blocker visible and setBlocked false hides it`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.doLayout() + + assertFalse(root.blocker.isVisible) + + root.setBlocked(true) + assertTrue(root.blocker.isVisible) + + root.setBlocked(false) + assertFalse(root.blocker.isVisible) + } + + fun `test modal content is centered inside blocker`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + val child = Probe() + + root.setModalContent(child) + root.doLayout() + + assertTrue(root.blocker.isVisible) + assertEquals(1, root.blocker.componentCount) + assertEquals(Rectangle(60, 38, 80, 24), child.bounds) + } + + fun `test clearing modal content hides and removes blocker children`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.setModalContent(Probe()) + root.doLayout() + + root.setModalContent(null) + + assertFalse(root.blocker.isVisible) + assertEquals(0, root.blocker.componentCount) + } + + fun `test blocker contains returns false when hidden`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.doLayout() + + root.setBlocked(false) + assertFalse(root.blocker.contains(50, 50)) + } + + fun `test blocker contains returns true when visible`() { + val root = SessionRootPanel().apply { setSize(200, 100) } + root.doLayout() + + root.setBlocked(true) + assertTrue(root.blocker.contains(50, 50)) + } + private class Probe : BorderLayoutPanel() { var laid = false @@ -67,4 +195,18 @@ class SessionRootPanelTest : BasePlatformTestCase() { super.doLayout() } } + + private fun dropCard(drop: SessionDropOverlay) = drop.components + .single() + .let { it as javax.swing.JComponent } + .components + .single() + .let { it as javax.swing.JComponent } + + private fun dropLabels(drop: SessionDropOverlay): List { + val stack = dropCard(drop).components.single() as javax.swing.JComponent + return stack.components + .map { it as javax.swing.JComponent } + .map { it.components.single() as JBLabel } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt new file mode 100644 index 00000000000..a122c5838fd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -0,0 +1,351 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.ui.selection.SessionCopyTarget +import ai.kilocode.client.session.ui.selection.SessionContextMenu +import ai.kilocode.client.session.ui.selection.SessionHoverCopyOverlay +import ai.kilocode.client.session.ui.selection.SessionTargetResolver +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.test.CopyProviderSink +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.ide.CopyProvider +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.datatransfer.DataFlavor +import java.awt.event.MouseEvent +import java.awt.Point +import java.awt.image.BufferedImage +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.text.JTextComponent + +@Suppress("UnstableApiUsage") +class SessionSelectionCopyTest : SessionUiTestBase() { + companion object { + private const val RGB_MASK = 0x00ffffff + } + + fun `test transcript view exposes copy provider when selection exists`() { + val area = showTool("alpha output") + + select(area, "alpha") + val provider = copyProvider(area) + + assertNotNull(provider) + assertTrue(provider!!.isCopyEnabled(DataContext.EMPTY_CONTEXT)) + } + + fun `test copy provider writes active selected text`() { + val area = showTool("alpha output") + + select(area, "alpha") + copyProvider(area)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test copy provider writes full component text without selection`() { + val area = showTool("alpha output") + + copyProvider(area)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha output", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test selecting another transcript component changes copied text`() { + val one = showTool("alpha output", id = "tool_a") + val two = showTool("bravo output", id = "tool_b") + + select(one, "alpha") + select(two, "bravo") + copyProvider(two)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertTrue(one.selectedText.isNullOrEmpty()) + assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test code block child context exposes session copy provider`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + val editor = field.getEditor(true)!! + + editor.selectionModel.setSelection(0, 5) + val provider = copyProvider(field as UiDataProvider) + provider!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test code block child copies full content without selection`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + + copyProvider(field as UiDataProvider)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha code", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test session context menu resolves deepest component`() { + val root = JPanel(null) + val mid = JPanel(null) + val child = JPanel(null) + root.setBounds(0, 0, 100, 100) + mid.setBounds(10, 10, 80, 80) + child.setBounds(5, 5, 20, 20) + root.add(mid) + mid.add(child) + + assertSame(child, SessionContextMenu.target(root, root, Point(20, 20))) + } + + fun `test hover copy resolver skips overlay and resolves underlying target`() { + val root = JPanel(null) + val target = TargetPanel("alpha") + val overlay = JPanel(null) + root.setBounds(0, 0, 100, 100) + target.setBounds(10, 10, 80, 80) + overlay.setBounds(15, 15, 20, 20) + root.add(target) + root.add(overlay) + + val item = SessionTargetResolver.copy(root, root, Point(20, 20), overlay) + + assertSame(target, item) + } + + fun `test hover copy resolver prefers outer target for stable anchoring`() { + val root = JPanel(null) + val target = TargetPanel("alpha") + val child = TargetPanel("bravo") + root.setBounds(0, 0, 100, 100) + target.setBounds(10, 10, 80, 80) + child.setBounds(5, 5, 20, 20) + root.add(target) + target.add(child) + + val item = SessionTargetResolver.copy(root, root, Point(20, 20)) + + assertSame(target, item) + } + + fun `test code block hover copy target copies full content despite selection`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + field.setSize(200, 80) + field.getEditor(true)!!.selectionModel.setSelection(0, 5) + + val target = SessionTargetResolver.copy(field, field, Point(1, 1)) + + assertNotNull(target) + assertEquals("alpha code", target!!.copyText()) + } + + fun `test plain assistant text is not hover copy eligible`() { + showText("plain alpha") + val comp = textComponent("plain alpha") + comp.setSize(200, 80) + + val target = SessionTargetResolver.copy(comp, comp, Point(1, 1)) + + assertNull(target) + } + + fun `test session ui registers hover copy overlay in overlay layer`() { + val root = find(ui) + val overlay = find(ui) + + assertSame(root.overlay, overlay.parent) + assertFalse(root.blocker.components.contains(overlay)) + assertFalse(overlay.isVisible) + assertEquals(Cursor.HAND_CURSOR, overlay.components.single().cursor.type) + overlay.isVisible = true + overlay.clear() + assertFalse(overlay.isVisible) + } + + fun `test hover copy overlay ignores mouse events after disposal`() { + val root = ShowingPanel() + val parent = Disposer.newDisposable("overlay-test") + val target = TargetPanel("alpha") + val overlay = SessionHoverCopyOverlay(root, parent) + root.setBounds(0, 0, 100, 100) + target.setBounds(10, 10, 80, 80) + root.add(target) + root.add(overlay) + + Disposer.dispose(parent) + target.dispatchEvent(MouseEvent(target, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + + assertFalse(overlay.isVisible) + } + + fun `test session context menu can reinstall after parent disposal`() { + val root = JPanel(null) + val one = Disposer.newDisposable("context-one") + val two = Disposer.newDisposable("context-two") + + SessionContextMenu.install(root, one) + SessionContextMenu.install(root, one) + Disposer.dispose(one) + SessionContextMenu.install(root, two) + Disposer.dispose(two) + } + + fun `test hover copy button paints opaque background before and during pointer hover`() { + val overlay = find(ui) + val btn = overlay.components.single() + val size = btn.preferredSize + btn.setBounds(0, 0, size.width, size.height) + + assertEquals(rgb(UiStyle.Colors.bg()), argb(btn, 2, size.height / 2) and RGB_MASK) + + btn.dispatchEvent(MouseEvent(btn, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + + assertEquals(255, argb(btn, 2, size.height / 2) ushr 24) + } + + private fun argb(comp: Component, x: Int, y: Int): Int { + val size = comp.size + val image = BufferedImage(size.width, size.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + + try { + comp.paint(g) + } finally { + g.dispose() + } + return image.getRGB(x, y) + } + + private fun rgb(color: java.awt.Color): Int = color.rgb and RGB_MASK + + fun `test hover overlay keeps current target while pointer remains inside anchor`() { + val overlay = find(ui) + val target = TargetPanel("alpha") + target.setBounds(10, 10, 80, 80) + + assertTrue(overlay.contains(target, target, Point(79, 1))) + assertFalse(overlay.contains(target, target, Point(80, 1))) + } + + private fun select(area: JTextComponent, text: String) { + val start = area.text.indexOf(text) + assertTrue(start >= 0) + area.select(start, start + text.length) + } + + private fun showTool(text: String, id: String = "tool_msg"): JTextComponent { + if (controller().id == null) showMessages() + emit(ChatEventDto.MessageUpdated("ses_test", message(id))) + emit(ChatEventDto.PartUpdated( + "ses_test", + PartDto( + id = "part_$id", + sessionID = "ses_test", + messageID = id, + type = "tool", + tool = "bash", + state = "completed", + input = mapOf("command" to "printf"), + output = text, + ), + )) + for (view in toolViews(ui)) expand(view) + layout() + return textComponent(text) + } + + private fun showText(text: String) { + if (controller().id == null) showMessages() + emit(ChatEventDto.MessageUpdated("ses_test", message("msg_text"))) + emit(ChatEventDto.PartUpdated("ses_test", part("part_text", "msg_text", "text", text))) + layout() + } + + private fun toolViews(root: Container): List { + val out = mutableListOf() + if (root is ShellToolView || root is ToolView) out.add(root) + for (child in root.components) { + if (child is Container) out.addAll(toolViews(child)) + } + return out + } + + private fun expand(view: Container) = when (view) { + is ShellToolView -> view.expand() + is ToolView -> view.expand() + else -> false + } + + private fun copyProvider(provider: UiDataProvider): CopyProvider? { + val sink = CopyProviderSink() + provider.uiDataSnapshot(sink) + return sink.copy + } + + private fun copyProvider(component: Component): CopyProvider? { + (component as? UiDataProvider)?.let(::copyProvider)?.let { return it } + ancestors(component).filterIsInstance().firstNotNullOfOrNull(::copyProvider)?.let { return it } + val point = Point((component.width / 2).coerceAtLeast(0), (component.height / 2).coerceAtLeast(0)) + val target = SessionContextMenu.target(ui as JComponent, component, point) ?: component + ancestors(target).filterIsInstance().firstNotNullOfOrNull(::copyProvider)?.let { return it } + return providers(ui).firstNotNullOfOrNull(::copyProvider) + } + + private fun providers(root: Component): Sequence = sequence { + if (root is UiDataProvider) yield(root) + if (root is Container) { + for (child in root.components) yieldAll(providers(child)) + } + } + + private fun ancestors(component: Component): Sequence = sequence { + var comp: Component? = component + while (comp != null) { + yield(comp) + comp = comp.parent + } + } + + private fun textEditors(root: Container): List { + val out = mutableListOf() + if (root is EditorTextField) out.add(root) + for (child in root.components) { + if (child is JBScrollPane) (child.viewport.view as? EditorTextField)?.let(out::add) + if (child is Container) out.addAll(textEditors(child)) + } + return out.distinct() + } + + private fun textComponent(needle: String): JTextComponent = textComponents(ui) + .first { it.text.contains(needle) } + + private fun textComponents(root: Container): List { + val out = mutableListOf() + if (root is JTextComponent) out.add(root) + for (child in root.components) { + if (child is Container) out.addAll(textComponents(child)) + } + return out + } + + private class TargetPanel(private val value: String) : JPanel(), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = value + } + + private class ShowingPanel : JPanel(null) { + override fun isShowing() = true + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 161fa553118..59ef3d9759f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -2,14 +2,29 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.AttachmentView +import ai.kilocode.client.session.views.PromptAttachmentView +import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.TextView +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import java.awt.Container +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.ScrollPaneConstants /** * Integration test: mutate [SessionModel] directly on the EDT and verify @@ -28,7 +43,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent) + panel = SessionMessageListPanel(model, parent, openFile = {}) } override fun tearDown() { @@ -77,14 +92,43 @@ class SessionUiUpdateTest : BasePlatformTestCase() { // ------ tool lifecycle ------ - fun `test tool state transitions are reflected in ToolView`() { + fun `test tool state transitions are reflected in tool view`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", toolPart("t1", "a1", "bash", "pending")) model.updateContent("a1", toolPart("t1", "a1", "bash", "running")) model.updateContent("a1", toolPart("t1", "a1", "bash", "completed")) - val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.ToolView - assertFalse(tv.labelText().contains("Running")) + val view = panel.findMessage("a1")!!.part("t1") + val label = when (view) { + is ShellToolView -> view.labelText() + is ToolView -> view.labelText() + else -> error("unexpected tool view ${view?.javaClass?.name}") + } + assertFalse(label.contains("Running")) + } + + fun `test read tool renders as ReadToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "read", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.ReadToolView) + } + + fun `test glob tool renders as GlobToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "glob", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.GlobToolView) + } + + fun `test grep tool renders as SearchToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "grep", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.SearchToolView) } // ------ multiple turns update correctly ------ @@ -127,6 +171,318 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertNotNull(gv) assertTrue(gv is ai.kilocode.client.session.views.base.GenericView) assertTrue((gv as ai.kilocode.client.session.views.base.GenericView).labelText().contains("snapshot")) + assertNull(gv.border) + } + + fun `test assistant file part renders as attachment view`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "a1", + type = "file", + mime = "image/png", + url = "file:///tmp/a.png", + filename = "a.png", + ), + ) + + val view = panel.findMessage("a1")!!.part("f1") + assertTrue(view is AttachmentView) + assertEquals("AttachmentView#f1:a.png", view!!.dumpLabel()) + assertNotNull(find(view, AttachmentCard::class.java)) + assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") }) + } + + fun `test user file part renders as prompt attachment strip`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/a.png", + filename = "a.png", + ), + ) + + val view = panel.findMessage("u1")!!.part("f1") + assertTrue(view is PromptAttachmentView) + assertEquals("PromptAttachmentView#attachments:u1[f1]", view!!.dumpLabel()) + assertNotNull(find(view, AttachmentCard::class.java)) + assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") }) + } + + fun `test user text and attachments share one prompt container`() { + val opened = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { _, it -> opened.add(it.url) }) + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "look at this")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "data:image/png;base64,aGVsbG8=", + filename = "a.png", + ), + ) + model.updateContent( + "u1", + PartDto( + id = "f2", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,aGVsbG8=", + filename = "note.txt", + ), + ) + + val msg = item.findMessage("u1")!! + val attachment = msg.part("f1")!! + val other = msg.part("f2")!! + + assertSame(msg, attachment.parent) + assertSame(attachment, other) + assertEquals(listOf("p1", "f1", "f2"), msg.partIds()) + assertEquals(1, msg.components.filterIsInstance().size) + assertEquals(2, findAll(attachment, AttachmentCard::class.java).size) + + val cards = findAll(attachment, AttachmentCard::class.java) + for (card in cards) { + card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + } + + assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened) + } + + fun `test user file mention hides synthetic read payload and text attachment card`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "read @src/a.kt")) + model.updateContent("u1", PartDto( + id = "p2", + sessionID = "ses", + messageID = "u1", + type = "text", + text = "/tmp/a.kt\nraw", + synthetic = true, + )) + model.updateContent("u1", PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "file:///tmp/a.kt", + filename = "a.kt", + source = source("src/a.kt"), + )) + + val msg = panel.findMessage("u1")!! + + assertEquals(listOf("p1"), msg.partIds()) + assertNull(msg.part("p2")) + assertNull(msg.part("f1")) + assertEquals("read [@src/a.kt](src/a.kt)", (msg.part("p1") as TextView).markdown()) + assertEquals(0, msg.components.filterIsInstance().size) + } + + fun `test user git changes mention hides synthetic data attachment card`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "review @git-changes")) + model.updateContent("u1", PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "data:text/plain;charset=utf-8,diff%20content", + filename = "git-changes.txt", + source = PartSourceDto( + type = "file", + text = PartSourceTextDto("@git-changes", 7.0, 19.0), + path = "git-changes", + ), + )) + + val msg = panel.findMessage("u1")!! + + assertEquals(listOf("p1"), msg.partIds()) + assertNull(msg.part("f1")) + assertEquals("review [@git-changes](git-changes)", (msg.part("p1") as TextView).markdown()) + assertEquals(0, msg.components.filterIsInstance().size) + } + + fun `test source less text attachment still renders in prompt strip`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,aGVsbG8=", + filename = "note.txt", + )) + + val view = panel.findMessage("u1")!!.part("f1") + + assertTrue(view is PromptAttachmentView) + assertNotNull(find(view!!, AttachmentCard::class.java)) + } + + fun `test source backed image attachment still renders in prompt strip`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/a.png", + filename = "a.png", + source = source("src/a.png"), + )) + + val view = panel.findMessage("u1")!!.part("f1") + + assertTrue(view is PromptAttachmentView) + assertNotNull(find(view!!, AttachmentCard::class.java)) + } + + fun `test empty sanitized user text does not create prompt panel`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "read these screenshots")) + model.updateContent("u1", part("p2", "u1", "text", text = " ")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "data:image/png;base64,aGVsbG8=", + filename = "a.png", + ), + ) + + val msg = panel.findMessage("u1")!! + + assertNull(msg.part("p2")) + assertEquals(listOf("p1", "f1"), msg.partIds()) + assertTrue(msg.part("p1") is TextView) + assertEquals(1, msg.components.filterIsInstance().size) + } + + fun `test prompt text panel is removed when content becomes empty`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "visible")) + + assertNotNull(panel.findMessage("u1")!!.part("p1")) + + model.updateContent("u1", part("p1", "u1", "text", text = "")) + + val msg = panel.findMessage("u1")!! + assertNull(msg.part("p1")) + assertTrue(msg.partIds().isEmpty()) + assertEquals(0, msg.components.filterIsInstance().size) + } + + fun `test user attachment strip scrolls horizontally only`() { + model.upsertMessage(msg("u1", "user")) + for (i in 1..8) { + model.updateContent( + "u1", + PartDto( + id = "f$i", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/$i.png", + filename = "$i.png", + ), + ) + } + + val view = panel.findMessage("u1")!!.part("f1") as PromptAttachmentView + val height = view.preferredSize.height + val pane = view.scrollPane() + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertEquals(0, view.insets.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), view.insets.bottom) + assertEquals( + JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) + + pane.horizontalScrollBar.preferredSize.height + + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + height, + ) + + model.updateContent( + "u1", + PartDto( + id = "f9", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/9.png", + filename = "9.png", + ), + ) + + assertEquals(height, view.preferredSize.height) + assertEquals((1..9).map { "f$it" }, view.ids()) + } + + fun `test user read tool payload is hidden but assistant read tool renders`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("u1", toolPart("ut1", "u1", "read", "completed")) + model.updateContent("a1", toolPart("at1", "a1", "read", "completed")) + + val user = panel.findMessage("u1")!! + val assistant = panel.findMessage("a1")!! + + assertTrue(user.partIds().isEmpty()) + assertNull(user.part("ut1")) + assertTrue(assistant.part("at1") is ReadToolView) + } + + fun `test transcript attachment click delegates to attachment opener`() { + val opened = mutableListOf>() + val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { msg, it -> opened.add(msg to it.url) }) + model.upsertMessage(msg("u1", "user")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,aGVsbG8=", + filename = "note.txt", + ), + ) + + val card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!! + card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + + assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened) } // ------ silent part types ------ @@ -200,4 +556,41 @@ class SessionUiUpdateTest : BasePlatformTestCase() { private fun toolPart(id: String, mid: String, tool: String, state: String) = PartDto( id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, state = state, ) + + private fun source(path: String) = PartSourceDto( + type = "file", + path = path, + text = PartSourceTextDto("@$path", 5.0, (6 + path.length).toDouble()), + ) + + private fun find(root: java.awt.Component, type: Class): T? { + if (type.isInstance(root)) return type.cast(root) + if (root is Container) { + for (child in root.components) { + val found = find(child, type) + if (found != null) return found + } + } + return null + } + + private fun findAll(root: java.awt.Component, type: Class): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + if (type.isInstance(node)) out.add(type.cast(node)) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun buttons(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + if (node is JButton) out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } } 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 f28fd218cfa..4aaa851181c 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 @@ -3,8 +3,8 @@ package ai.kilocode.client.session.ui.account import ai.kilocode.client.session.controller.SessionControllerEvent import ai.kilocode.client.session.controller.SessionControllerEvent.AccountOverlaySnapshot import ai.kilocode.client.session.controller.SessionControllerTestBase +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon -import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto @@ -201,12 +201,12 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { } } - fun `test account switcher uses card background and border`() { + fun `test account switcher uses session view background and border`() { val prof = profile(email = "user@example.com") show(snap(prof)) edt { - assertEquals(UiStyle.Colors.cardBg(), panel.panelBackground()) - assertEquals(UiStyle.Colors.cardBorder(), panel.panelBorderColor()) + assertEquals(SessionUiStyle.AccountPopup.bgColor(), panel.panelBackground()) + assertEquals(SessionUiStyle.AccountPopup.outlineColor(), panel.panelBorderColor()) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt new file mode 100644 index 00000000000..0bdb0dca4d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt @@ -0,0 +1,262 @@ +package ai.kilocode.client.session.ui.attachment + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.vfs.KiloPath +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.client.vfs.KiloVirtualFileKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +class AttachmentEditorKindTest : BasePlatformTestCase() { + fun testAttachmentParamsUseStableIdentityFields() { + val item = FileAttachment("part1").apply { + mime = "text/plain" + url = "data:text/plain;base64,aGVsbG8=" + filename = "note.txt" + } + + val params = attachmentParams("ses1", "msg1", item, "note.txt", "/repo") + val path = KiloPath(AttachmentEditorKind.ID, params).canonical() + val json = KiloVirtualFileSystem.getInstance().getPath(path) + val decoded = KiloVirtualFileSystem.decode(json) + + assertEquals(path, decoded) + assertEquals(AttachmentEditorKind.ID, path.kind) + assertEquals("ses1", params["sessionId"]) + assertEquals("msg1", params["messageId"]) + assertEquals("part1", params["partId"]) + assertFalse(params["attachmentKey"].isNullOrBlank()) + assertEquals("note.txt", params["filename"]) + assertEquals("text/plain", params["mime"]) + assertEquals("/repo", params["directory"]) + assertFalse(json.contains("projectHash", ignoreCase = true)) + assertFalse(json.contains("launch", ignoreCase = true)) + assertFalse(json.contains("time", ignoreCase = true)) + assertFalse(json.contains("random", ignoreCase = true)) + } + + fun testSameParamsMapToSameVirtualPath() { + val params = linkedMapOf( + "sessionId" to "ses1", + "messageId" to "msg1", + "partId" to "part1", + "attachmentKey" to "key1", + "filename" to "note.txt", + "mime" to "text/plain", + "directory" to "/repo", + ) + + val one = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params)) + val two = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params.toList().reversed().toMap())) + + assertEquals(one, two) + assertFalse(one.contains("/system/kilo/editors")) + assertFalse(one.contains("kiloattachment")) + } + + fun testDuplicatePartAttachmentsMapToDistinctVirtualFiles() { + val first = FileAttachment("part1").apply { + mime = "text/plain" + url = "data:text/plain;base64,b25l" + filename = "note.txt" + } + val second = FileAttachment("part1").apply { + mime = "text/plain" + url = "data:text/plain;base64,dHdv" + filename = "note.txt" + } + val one = attachmentParams("ses1", "msg1", first, "note.txt", "/repo") + val two = attachmentParams("ses1", "msg1", second, "note.txt", "/repo") + + assertFalse(one == two) + assertFalse(one["attachmentKey"] == two["attachmentKey"]) + assertFalse(KiloPath(AttachmentEditorKind.ID, one) == KiloPath(AttachmentEditorKind.ID, two)) + } + + fun testVirtualFilesAreExcludedFromEditorHistory() { + ensureAttachmentEditorKind() + val file = KiloVirtualFile(KiloPath(AttachmentEditorKind.ID, mapOf( + "directory" to "/repo", + "sessionId" to "ses1", + "messageId" to "msg1", + "partId" to "part1", + "filename" to "note.txt", + ))) + + assertNull(VirtualFileManager.getInstance().findFileByUrl(file.url)) + } + + fun testAttachmentEditorKindAndVirtualFilesCanBeCleared() { + ensureAttachmentEditorKind() + val fs = KiloVirtualFileSystem.getInstance() + val path = KiloPath(AttachmentEditorKind.ID, mapOf( + "directory" to "/repo", + "sessionId" to "ses1", + "messageId" to "msg1", + "partId" to "part1", + "filename" to "note.txt", + )) + val file = fs.findOrCreateFile(path) + + assertNotNull(file) + assertNotNull(service().get(AttachmentEditorKind.ID)) + assertNotNull(service().get(AttachmentEditorKind.ID)) + + unregisterAttachmentEditorKind() + fs.clear() + + assertNull(service().get(AttachmentEditorKind.ID)) + assertNull(service().get(AttachmentEditorKind.ID)) + assertNull(fs.findOrCreateFile(path)) + } + + @Suppress("UnstableApiUsage") + fun testFetchUsesAttachmentKeyBeforeDuplicatePartId() { + val cs = CoroutineScope(SupervisorJob()) + val app = FakeAppRpcApi() + val rpc = FakeSessionRpcApi() + app.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + val first = PartDto( + id = "part1", + sessionID = "ses1", + messageID = "msg1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,b25l", + filename = "note.txt", + ) + val second = first.copy(url = "data:text/plain;base64,dHdv") + rpc.history.add(MessageWithPartsDto( + info = MessageDto( + id = "msg1", + sessionID = "ses1", + role = "user", + time = MessageTimeDto(created = 0.0), + ), + parts = listOf(first, second), + )) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable) + project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable) + val item = FileAttachment("part1").apply { + mime = "text/plain" + url = second.url.orEmpty() + filename = "note.txt" + } + val results = mutableListOf() + val parent = Disposer.newDisposable() + + try { + KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) { + results.add(it) + } + + waitFor { results.any { it is AttachmentData.Text } } + assertTrue(results.any { it is AttachmentData.Connecting }) + val data = results.last { it is AttachmentData.Text } as AttachmentData.Text + assertEquals("two", data.text) + assertEquals(1, rpc.attachmentParts.size) + assertEquals("msg1", rpc.attachmentParts.single().messageId) + assertEquals(0, rpc.historyCalls) + } finally { + Disposer.dispose(parent) + cs.cancel() + } + } + + @Suppress("UnstableApiUsage") + fun testLoadShowsConnectionFailedUntilRetryBecomesReady() = runBlocking { + val cs = CoroutineScope(SupervisorJob()) + val app = FakeAppRpcApi() + val rpc = FakeSessionRpcApi() + val part = PartDto( + id = "part1", + sessionID = "ses1", + messageID = "msg1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,b2s=", + filename = "note.txt", + ) + rpc.history.add(MessageWithPartsDto( + info = MessageDto( + id = "msg1", + sessionID = "ses1", + role = "user", + time = MessageTimeDto(created = 0.0), + ), + parts = listOf(part), + )) + app.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable) + project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable) + val item = FileAttachment("part1").apply { + mime = part.mime.orEmpty() + url = part.url.orEmpty() + filename = part.filename.orEmpty() + } + val results = mutableListOf() + val parent = Disposer.newDisposable() + + try { + KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) { + results.add(it) + } + + waitFor { results.any { it is AttachmentData.ConnectionFailed } } + assertTrue(results.any { it is AttachmentData.Connecting }) + assertTrue(results.any { it is AttachmentData.ConnectionFailed }) + + app.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + + waitFor { results.any { it is AttachmentData.Text } } + val data = results.last { it is AttachmentData.Text } as AttachmentData.Text + assertEquals("ok", data.text) + } finally { + Disposer.dispose(parent) + cs.cancel() + } + } + + private fun waitFor(done: () -> Boolean) { + val until = System.currentTimeMillis() + 5_000 + while (!done() && System.currentTimeMillis() < until) { + UIUtil.dispatchAllInvocationEvents() + Thread.sleep(50) + } + assertTrue(done()) + } + + private fun ref(session: String, message: String, item: FileAttachment, name: String, dir: String): AttachmentRef { + val params = attachmentParams(session, message, item, name, dir) + return AttachmentRef( + directory = params.getValue("directory"), + sessionId = params.getValue("sessionId"), + messageId = params.getValue("messageId"), + partId = params.getValue("partId"), + attachmentKey = params["attachmentKey"], + filename = params.getValue("filename"), + mime = params.getValue("mime"), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 361d1846e1c..a0402fb353f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerTestBase import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageDto @@ -16,11 +17,15 @@ import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto +import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent +import java.awt.Cursor import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent +import java.awt.image.BufferedImage +import javax.swing.UIManager class SessionHeaderPanelTest : SessionControllerTestBase() { @@ -57,6 +62,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) assertEquals("Generated title", panel.titleText()) assertEquals("$0.07", panel.costText()) + assertEquals("$0.07 spent in this session", panel.costTip()) assertEquals("1%", panel.contextText()) assertEquals("Tokens 13.7K 2.5K cache write 25 cache read 75", panel.tokenText()) assertEquals("Tokens used by the latest assistant response: input, output, cache writes, and cache reads.", panel.tokenTip()) @@ -71,7 +77,33 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { List(panel.foregrounds().size) { style.editorForeground }, panel.foregrounds(), ) - assertNotNull(panel.expandButton().icon) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) + assertEquals(Cursor.HAND_CURSOR, panel.expandButton().cursor.type) + assertNotSame(panel.compactButton().parent, panel.expandButton().parent) + } + + fun `test header has editor tab bottom separator`() { + val old = UIManager.getColor("EditorTabs.underTabsBorderColor") + val color = Color(12, 34, 56) + + try { + UIManager.put("EditorTabs.underTabsBorderColor", color) + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val ins = panel.border.getBorderInsets(panel) + val img = BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB) + val g = img.createGraphics() + + panel.border.paintBorder(panel, g, 0, 0, img.width, img.height) + g.dispose() + + assertEquals(0, ins.top) + assertEquals(1, ins.bottom) + assertEquals(color.rgb, img.getRGB(5, img.height - 1)) + } finally { + UIManager.put("EditorTabs.underTabsBorderColor", old) + } } fun `test compact button follows eligibility and invokes controller`() { @@ -90,6 +122,52 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals(1, rpc.compacts.size) } + fun `test todo list starts collapsed and toggles independently`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + click(panel.expandButton()) + assertTrue(panel.isExpanded()) + assertTrue(panel.todoVisible()) + assertFalse(panel.todoListVisible()) + + click(panel.todoRowPanel()) + + assertTrue(panel.isExpanded()) + assertTrue(panel.todoListVisible()) + assertEquals(2, panel.todoListPanel().rowCount()) + assertTrue(panel.todoListPanel().rowText(0).contains("Write tests")) + assertTrue(panel.todoListPanel().rowChecked(0)) + assertFalse(panel.todoListPanel().rowChecked(1)) + + click(panel.todoLabel()) + assertTrue(panel.isExpanded()) + assertFalse(panel.todoListVisible()) + } + + fun `test all done todos use success foreground`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + emit(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("Done", "completed", "high")))) + + assertEquals("All 1 todos complete", panel.todoText()) + assertEquals(SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3]) + } + + fun `test timeline colors honor semantic named color keys`() { + val old = UIManager.getColor("Kilo.Session.Timeline.Read") + val color = Color(12, 34, 56) + + try { + UIManager.put("Kilo.Session.Timeline.Read", color) + + assertEquals(color.rgb, SessionUiStyle.Timeline.READ.rgb) + } finally { + UIManager.put("Kilo.Session.Timeline.Read", old) + } + } + fun `test retained labels update on later header event`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) @@ -136,7 +214,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val bar = panel.contextBar() assertFalse(panel.isExpanded()) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertSame(body, panel.bodyPanel()) @@ -213,7 +291,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertNull(panel.timelineToolTip()) assertEquals(-1, panel.timelineHover()) - panel.expandButton().doClick() + click(panel.expandButton()) assertFalse(panel.isExpanded()) assertSame(body, panel.bodyPanel()) @@ -249,26 +327,33 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) + assertNotSame(panel.compactButton().parent, panel.expandButton().parent) - panel.expandButton().doClick() + click(panel.expandButton()) emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) assertTrue(panel.isExpanded()) assertEquals("Hide session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronDown, panel.expandButton().icon) - panel.expandButton().doClick() + click(panel.expandButton()) emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) } fun `test collapse persists and new header starts collapsed`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - panel.expandButton().doClick() - panel.expandButton().doClick() + click(panel.expandButton()) + click(panel.expandButton()) assertFalse(panel.isExpanded()) assertFalse(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, true)) @@ -286,7 +371,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertTrue(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, false)) @@ -348,8 +433,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) repeat(12) { idx -> - emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx"))) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")), flush = false) } + flush() panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) panel.timelineViewport().doLayout() panel.timelineViewport().viewPosition = Point(0, 0) @@ -386,8 +472,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) repeat(12) { idx -> - emit(ChatEventDto.PartUpdated("ses_test", tool("tool_touch_$idx", "bash", "running", "Touch $idx"))) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_touch_$idx", "bash", "running", "Touch $idx")), flush = false) } + flush() panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) panel.timelineViewport().doLayout() panel.timelineViewport().viewPosition = Point(0, 0) @@ -420,8 +507,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) repeat(12) { idx -> - emit(ChatEventDto.PartUpdated("ses_test", tool("tool_wheel_$idx", "bash", "running", "Wheel $idx"))) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_wheel_$idx", "bash", "running", "Wheel $idx")), flush = false) } + flush() panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) panel.timelineViewport().doLayout() panel.timelineViewport().viewPosition = Point(0, 0) @@ -454,8 +542,9 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) repeat(12) { idx -> - emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx"))) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx")), flush = false) } + flush() panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) panel.timelineViewport().doLayout() panel.timelineViewport().viewPosition = Point(0, 0) @@ -495,16 +584,17 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { edt { c.prompt("go") } flush() - emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title"))) - emit(ChatEventDto.MessageUpdated("ses_test", assistant())) - emit(ChatEventDto.PartUpdated("ses_test", reasoning(done = false, text = "Thinking"))) - emit(ChatEventDto.PartUpdated("ses_test", tool("tool_1", "bash", "running", "Run tests", input = mapOf("cmd" to "test", "files" to "src")))) - emit(ChatEventDto.PartUpdated("ses_test", tool("tool_2", "edit", "error", "Edit file", input = mapOf("cmd" to "test", "files" to "src")))) - emit(ChatEventDto.PartUpdated("ses_test", stepFinish())) + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title")), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", assistant()), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", reasoning(done = false, text = "Thinking")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_1", "bash", "running", "Run tests", input = mapOf("cmd" to "test", "files" to "src"))), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_2", "edit", "error", "Edit file", input = mapOf("cmd" to "test", "files" to "src"))), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", stepFinish()), flush = false) emit(ChatEventDto.TodoUpdated("ses_test", listOf( TodoDto("Write tests", "completed", "high"), TodoDto("Ship it", "pending", "medium"), - ))) + )), flush = false) + flush() return c } @@ -571,6 +661,19 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { )) } + private fun click(component: java.awt.Component) { + component.dispatchEvent(MouseEvent( + component, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + 1, + 1, + 1, + false, + )) + } + private fun reset() { PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index c1ad8e5f07f..d34f6a541e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -35,10 +35,10 @@ class ModelPickerTest : BasePlatformTestCase() { ), listOf(ModelSelectionDto("openai", "gpt-4o")), "") assertEquals("Favorites", modelPickerSectionTitle(rows, 0)) - assertEquals("openai/gpt-4o", rows[0].item.key) + assertEquals("openai/gpt-4o", rows[0].key) assertEquals("Recommended", modelPickerSectionTitle(rows, 1)) - assertEquals("kilo/auto", rows[1].item.key) - assertEquals("anthropic/claude", rows[2].item.key) + assertEquals("kilo/auto", rows[1].key) + assertEquals("anthropic/claude", rows[2].key) } fun `test favorites are hidden while filtering`() { @@ -49,7 +49,7 @@ class ModelPickerTest : BasePlatformTestCase() { assertFalse(rows.indices.any { modelPickerSectionTitle(rows, it) == "Favorites" }) - assertEquals("openai/gpt-4o", rows.single().item.key) + assertEquals("openai/gpt-4o", rows.single().key) } fun `test favorites match provider qualified key when model ids collide`() { @@ -58,7 +58,7 @@ class ModelPickerTest : BasePlatformTestCase() { item("gpt", "Azure GPT", "azure", "Azure"), ), listOf(ModelSelectionDto("azure", "gpt")), "") - assertEquals("azure/gpt", rows[0].item.key) + assertEquals("azure/gpt", rows[0].key) assertEquals("Favorites", modelPickerSectionTitle(rows, 0)) } @@ -72,7 +72,7 @@ class ModelPickerTest : BasePlatformTestCase() { ModelSelectionDto("openai", "a"), ), "") - assertEquals(listOf("openai/b", "openai/a"), rows.take(2).map { it.item.key }) + assertEquals(listOf("openai/b", "openai/a"), rows.take(2).map { it.key }) assertTrue(rows.take(2).all { it.favorite }) } @@ -82,7 +82,7 @@ class ModelPickerTest : BasePlatformTestCase() { item("gpt", "GPT", "openai", "OpenAI"), ), emptyList(), "anth") - assertEquals(listOf("anthropic/claude-sonnet"), rows.map { it.item.key }) + assertEquals(listOf("anthropic/claude-sonnet"), rows.map { it.key }) } fun `test kilo provider group is first and other providers keep source order`() { @@ -111,7 +111,7 @@ class ModelPickerTest : BasePlatformTestCase() { ), listOf(ModelSelectionDto("openai", "b")), "") assertEquals(1, modelPickerIndex(rows, 1)) - assertEquals("openai/a", rows[modelPickerIndex(rows, 1)].item.key) + assertEquals("openai/a", rows[modelPickerIndex(rows, 1)].key) } fun `test index caps after favorite removal`() { @@ -123,6 +123,66 @@ class ModelPickerTest : BasePlatformTestCase() { assertEquals(-1, modelPickerIndex(emptyList(), 1)) } + fun `test allowEmpty inserts not set row`() { + val rows = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + emptyList(), + "", + allowEmpty = true, + emptyText = "Not set", + ) + + assertTrue(rows.first().isEmpty) + assertEquals("Not set", rows.first().emptyText) + assertEquals(0, modelPickerIndex(rows, null)) + } + + fun `test allowEmpty filters not set row`() { + val rows = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + emptyList(), + "not", + allowEmpty = true, + emptyText = "Not set", + ) + val missing = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + emptyList(), + "openai", + allowEmpty = true, + emptyText = "Not set", + ) + + assertTrue(rows.first().isEmpty) + assertFalse(missing.any { it.isEmpty }) + } + + fun `test favorites only apply to real rows`() { + val rows = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + listOf(ModelSelectionDto("openai", "a")), + "", + allowEmpty = true, + emptyText = "Not set", + ) + + assertFalse(rows.first().favorite) + assertTrue(rows.drop(1).any { it.favorite }) + } + + fun `test small rows are included only when requested`() { + val items = listOf( + item("auto-small", "Small", "kilo", "Kilo"), + item("auto", "Auto", "kilo", "Kilo"), + ) + + val default = modelPickerRows(items, emptyList(), "") + val small = modelPickerRows(items, emptyList(), "", includeSmall = true) + + assertEquals(listOf("kilo/auto"), default.mapNotNull { it.key }) + assertEquals(listOf("kilo/auto", "kilo/auto-small"), small.mapNotNull { it.key }.sorted()) + } + fun `test setItems preserves selected model when refreshed without default`() { val picker = ModelPicker() val rows = listOf( @@ -136,6 +196,58 @@ class ModelPickerTest : BasePlatformTestCase() { assertEquals("openai/b", picker.selectedForTest()?.key) } + fun `test setItems auto-selects first item by default`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("a", "A", "openai", "OpenAI"))) + + assertEquals("openai/a", picker.selectionKeyForTest()) + assertEquals("OpenAI / A ▾", picker.text) + } + + fun `test non-kilo selected model uses provider prefix`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("gpt-55", "GPT-5.5", "openai", "OpenAI"))) + + assertEquals("OpenAI / GPT-5.5 ▾", picker.text) + } + + fun `test non-kilo selected model strips duplicate provider prefix`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("gpt-55", "OpenAI GPT-5.5", "openai", "OpenAI"))) + + assertEquals("OpenAI / GPT-5.5 ▾", picker.text) + } + + fun `test non-kilo selected model strips vscode provider prefix`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("gpt-55", "OpenAI: GPT-5.5", "openai", "OpenAI"))) + + assertEquals("OpenAI / GPT-5.5 ▾", picker.text) + } + + fun `test kilo selected model remains unprefixed`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("auto", "Kilo Auto", "kilo", "Kilo"))) + + assertEquals("Auto ▾", picker.text) + } + + fun `test allowEmpty keeps empty selection`() { + val picker = ModelPicker() + picker.allowEmpty = true + picker.emptyText = "Not set" + + picker.setItems(listOf(item("a", "A", "openai", "OpenAI"))) + + assertNull(picker.selectionKeyForTest()) + assertEquals("Not set ▾", picker.text) + } + fun `test provider qualified default selects duplicate model id from correct provider`() { val picker = ModelPicker() @@ -153,6 +265,25 @@ class ModelPickerTest : BasePlatformTestCase() { assertEquals(listOf("low", "high"), item.variants) } + fun `test selected paid model with training flag indicates data collection`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("paid", "Paid", "kilo", "Kilo", training = true))) + + assertFalse(picker.text.contains("Data may be used for training")) + assertSame(ModelPickerRenderer.DATA_COLLECTED, picker.icon) + assertEquals("Select model
    The current selected model may be used for training", picker.toolTipText) + } + + fun `test selected free model without training flag does not indicate data collection`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("free", "Free", "kilo", "Kilo", free = true))) + + assertNull(picker.icon) + assertEquals("Select model", picker.toolTipText) + } + fun `test display parts split provider prefix`() { val parts = ModelText.parts(item("claude-opus", "Anthropic Claude Opus 4.7", "anthropic", "Anthropic")) @@ -261,6 +392,63 @@ class ModelPickerTest : BasePlatformTestCase() { renderer.getListCellRendererComponent(list, row, 0, false, false) assertTrue(renderer.badgeVisible()) + assertEquals("Free", renderer.badgeText()) + assertFalse(renderer.warningVisible()) + } + + fun `test renderer shows data collection warning for paid training model`() { + val row = ModelPickerRow(item("paid", "Paid", "kilo", "Kilo", training = true), "Kilo", false) + val model = CollectionListModel(listOf(row)) + val renderer = ModelPickerRenderer(model, { null }, { emptySet() }) + val list = JBList(model) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + + assertFalse(renderer.badgeVisible()) + assertTrue(renderer.warningVisible()) + assertEquals("Data may be used for training", renderer.warningTooltip()) + } + + fun `test renderer shows BYOK instead of free when both are available`() { + val row = ModelPickerRow( + ModelPicker.Item("claude", "Claude", "kilo", "Kilo", free = true, byok = true), + "Kilo", + false, + ) + val model = CollectionListModel(listOf(row)) + val renderer = ModelPickerRenderer(model, { null }, { emptySet() }) + val list = JBList(model) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + + assertTrue(renderer.byokVisible()) + assertFalse(renderer.badgeVisible()) + } + + fun `test renderer hides data collection warning without training flag`() { + val row = ModelPickerRow(ModelPicker.Item("free", "Free", "openrouter", "OpenRouter", free = true), "OpenRouter", false) + val model = CollectionListModel(listOf(row)) + val renderer = ModelPickerRenderer(model, { null }, { emptySet() }) + val list = JBList(model) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + + assertTrue(renderer.badgeVisible()) + assertEquals("Free", renderer.badgeText()) + assertFalse(renderer.warningVisible()) + } + + fun `test renderer hides favorite and free affordances for empty row`() { + val row = ModelPickerRow(null, null, false, "Not set") + val model = CollectionListModel(listOf(row)) + val renderer = ModelPickerRenderer(model, { null }, { setOf("kilo/auto") }) + val list = JBList(model) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertSame(EmptyIcon.ICON_16, renderer.starIcon()) + assertFalse(renderer.badgeVisible()) + assertFalse(renderer.byokVisible()) } private fun item( @@ -270,7 +458,8 @@ class ModelPickerTest : BasePlatformTestCase() { name: String, index: Double? = null, free: Boolean = false, - ) = ModelPicker.Item(id, display, provider, name, index, free = free) + training: Boolean = false, + ) = ModelPicker.Item(id, display, provider, name, index, free = free, mayTrainOnYourPrompts = training) private fun favoriteInset(list: JBList<*>): Int { if (!NewUI.isEnabled()) return 0 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt new file mode 100644 index 00000000000..76077daeabb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -0,0 +1,390 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.FileSearchResultDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.icons.AllIcons +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.textCompletion.TextCompletionUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@Suppress("UnstableApiUsage") +class KiloPromptCompletionProviderTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var provider: KiloPromptCompletionProvider + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + rpc = FakeWorkspaceRpcApi() + val workspaces = KiloWorkspaceService(scope, rpc) + provider = KiloPromptCompletionProvider( + workspace = workspaces.workspace("/test"), + service = workspaces, + actions = listOf( + SlashAction(SlashAction.NEW.name, "New", listOf("clear")) {}, + SlashAction("sessions", "Sessions", listOf("continue")) {}, + SlashAction("next", "Next") {}, + ), + mentions = listOf(MentionAction( + MentionAction.GIT_CHANGES.name, + "Git Changes", + available = MentionAction.GIT_CHANGES.available, + )), + scope = scope, + ) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test mention completion shows backend fuzzy results without local filtering`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/foo/Bar.kt"))) + + complete("@sfb") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "src/foo/Bar.kt") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + assertEquals(listOf("sfb"), rpc.searchQueries) + } + + fun `test mention completion opens in middle of token`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts"))) + + complete("@deploy") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "src/deploy.ts") + assertEquals(listOf("dep"), rpc.searchQueries) + } + + fun `test slash completion opens in middle of token`() { + complete("/new") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + } + + fun `test mention completion reuses identical prefix result`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt"))) + + complete("@main") + complete("@main") + + assertEquals(listOf("main"), rpc.searchQueries) + } + + fun `test clearing mentions resets cached prefix result`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt"))) + + complete("@main") + provider.clearMentions() + complete("@main") + + assertEquals(listOf("main", "main"), rpc.searchQueries) + } + + fun `test mention completion includes matching special items`() { + rpc.searchResult = FileSearchResultDto(git = true) + + complete("@git") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), MentionAction.GIT_CHANGES.name) + assertEquals(listOf("git"), rpc.searchQueries) + } + + fun `test mention completion keeps no-match placeholder`() { + rpc.searchResult = FileSearchResultDto() + + complete("@zzz") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), noMatches()) + assertFalse(myFixture.lookupElementStrings.orEmpty().contains("src/foo/Bar.kt")) + assertEquals(listOf("zzz"), rpc.searchQueries) + } + + fun `test accepting mention no-match placeholder preserves prefix`() { + rpc.searchResult = FileSearchResultDto() + + complete("@zzz") + myFixture.type('\n') + + assertEquals("@zzz", myFixture.editor.document.text) + assertNull(provider.mentionAt("@zzz", 1)) + } + + fun `test accepting mention mid token replaces glued suffix`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("backend/deploy-dev.sh"))) + + complete("@backend/deploy-dev.sh") + myFixture.type('\n') + + assertEquals("@backend/deploy-dev.sh ", myFixture.editor.document.text) + assertEquals( + KiloPromptCompletionProvider.MentionHit(0, 22, "backend/deploy-dev.sh", true), + provider.mentionAt(myFixture.editor.document.text, 1), + ) + } + + fun `test accepting mention mid token trims before trailing content`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("backend/deploy-dev.sh"))) + + complete("@backend/deploy-dev.sh tail") + myFixture.type('\n') + + assertEquals("@backend/deploy-dev.sh tail", myFixture.editor.document.text) + assertEquals( + KiloPromptCompletionProvider.MentionHit(0, 22, "backend/deploy-dev.sh", true), + provider.mentionAt(myFixture.editor.document.text, 1), + ) + } + + fun `test slash completion keeps no-match placeholder`() { + complete("/zzz") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), noMatches()) + } + + fun `test slash completion hides placeholder for real matches`() { + complete("/ne") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + } + + fun `test slash completion matches client aliases`() { + complete("/cle") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + } + + fun `test blank mention completion includes special and root entries`() { + rpc.searchResult = FileSearchResultDto( + files = listOf(file("src", directory = true), file("README.md")), + git = true, + ) + + complete("@") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), MentionAction.GIT_CHANGES.name, "src", "README.md") + val items = myFixture.lookupElementStrings.orEmpty() + assertEquals(MentionAction.GIT_CHANGES.name, items.first()) + assertEquals(listOf(""), rpc.searchQueries) + } + + fun `test prewarm serves blank mention completion from cache`() { + rpc.searchResult = FileSearchResultDto( + files = listOf(file("src", directory = true), file("README.md")), + git = true, + ) + + provider.prewarm() + waitFor { rpc.searchQueries.contains("") } + complete("@") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), MentionAction.GIT_CHANGES.name, "src", "README.md") + assertEquals(listOf(""), rpc.searchQueries) + } + + fun `test mention completion renders file type icons`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("image.png"), file("src", directory = true))) + + complete("@") + + assertEquals(FileTypeManager.getInstance().getFileTypeByFileName("image.png").icon ?: AllIcons.FileTypes.Text, icon("image.png")) + assertSame(AllIcons.Nodes.Folder, icon("src")) + } + + fun `test highlights known slash command at start`() { + assertEquals( + listOf(KiloPromptCompletionProvider.Highlight(0, 4, KiloPromptCompletionProvider.HighlightKind.COMMAND)), + provider.highlights("/new start fresh"), + ) + } + + fun `test highlights client slash command aliases at start`() { + assertEquals( + listOf(KiloPromptCompletionProvider.Highlight(0, 6, KiloPromptCompletionProvider.HighlightKind.COMMAND)), + provider.highlights("/clear"), + ) + } + + fun `test highlights server slash command at start`() { + rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("deploy"))) + + waitFor { provider.highlights("/deploy prod").isNotEmpty() } + + assertEquals( + listOf(KiloPromptCompletionProvider.Highlight(0, 7, KiloPromptCompletionProvider.HighlightKind.COMMAND)), + provider.highlights("/deploy prod"), + ) + } + + fun `test highlights ignore unknown and non-leading slash commands`() { + assertTrue(provider.highlights("/bogus now").isEmpty()) + assertTrue(provider.highlights("hi /new").isEmpty()) + } + + fun `test highlights special mentions without tracked paths`() { + assertEquals( + listOf( + KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.MENTION), + ), + provider.highlights("use ${MentionAction.GIT_CHANGES.token}").sortedBy { it.start }, + ) + } + + fun `test serverCommand routes only known server commands`() { + rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("deploy"))) + + waitFor { provider.serverCommand("/deploy x") != null } + + assertEquals("deploy" to "x", provider.serverCommand("/deploy x")) + assertNull(provider.serverCommand("/new")) + assertNull(provider.serverCommand("hi /deploy")) + assertNull(provider.serverCommand("/unknown")) + } + + fun `test clientAction resolves canonical names and aliases`() { + assertEquals(SlashAction.NEW.name, provider.clientAction("/new")?.name) + assertEquals(SlashAction.NEW.name, provider.clientAction("/clear")?.name) + assertEquals("sessions", provider.clientAction("/continue")?.name) + assertNull(provider.clientAction("hi /clear")) + } + + fun `test serverCommand does not route client aliases`() { + rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("clear"))) + + assertNull(provider.serverCommand("/clear")) + } + + fun `test highlights tracked mentions longest first`() { + addMention("src/a.ts", "@ts") + addMention("src/a.tsx", "@tsx") + + assertEquals( + listOf(KiloPromptCompletionProvider.Highlight(4, 14, KiloPromptCompletionProvider.HighlightKind.MENTION)), + provider.highlights("see @src/a.tsx"), + ) + } + + fun `test highlights unknown mentions are pending before validation`() { + assertTrue(provider.highlights("see @unknownPath").isEmpty()) + } + + fun `test highlights unresolved mention after validation`() { + var done = false + rpc.fileResolver = { emptyList() } + + provider.validate("see @unknownPath", -1) { done = true } + waitFor { done } + + assertEquals( + listOf(KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.INVALID)), + provider.highlights("see @unknownPath", -1), + ) + } + + fun `test highlights existing hand typed mention after validation`() { + var done = false + rpc.fileResolver = { path -> if (path == "src/x.kt") listOf(file(path)) else emptyList() } + + provider.validate("see @src/x.kt", -1) { done = true } + waitFor { done } + + assertEquals( + listOf(KiloPromptCompletionProvider.Highlight(4, 13, KiloPromptCompletionProvider.HighlightKind.MENTION)), + provider.highlights("see @src/x.kt", -1), + ) + } + + fun `test mention under caret is not flagged`() { + assertTrue(provider.highlights("@nope", caret = 5).isEmpty()) + } + + fun `test mentionAt resolves tracked file mention`() { + addMention("src/a.ts", "@ts") + + assertEquals( + KiloPromptCompletionProvider.MentionHit(4, 13, "src/a.ts", true), + provider.mentionAt("see @src/a.ts", 6), + ) + } + + fun `test mentionAt marks unresolved file mention after validation`() { + var done = false + rpc.fileResolver = { emptyList() } + + provider.validate("see @missing.ts", -1) { done = true } + waitFor { done } + + assertEquals( + KiloPromptCompletionProvider.MentionHit(4, 15, "missing.ts", false), + provider.mentionAt("see @missing.ts", 6), + ) + } + + fun `test mentionAt ignores special pending and out of range`() { + assertNull(provider.mentionAt("use ${MentionAction.GIT_CHANGES.token}", 6)) + assertNull(provider.mentionAt("see @unvalidated.ts", 6)) + assertNull(provider.mentionAt("hello world", 3)) + } + + fun `test navigate opens resolved mention file`() { + rpc.fileResolver = { path -> if (path == "src/a.ts") listOf(file(path)) else emptyList() } + + provider.navigate("src/a.ts") + waitFor { rpc.opened.contains("src/a.ts") } + + assertTrue(rpc.opened.contains("src/a.ts")) + } + + private fun complete(text: String) { + val file = myFixture.configureByText("prompt.txt", text) + TextCompletionUtil.installProvider(file, provider, true) + myFixture.completeBasic() + } + + private fun addMention(path: String, query: String) { + rpc.searchResult = FileSearchResultDto(files = listOf(file(path))) + complete("$query") + myFixture.type('\n') + assertEquals(path, provider.mentionAt("@$path", 1)?.value) + } + + private fun waitFor(done: () -> Boolean) { + repeat(50) { + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + if (done()) return + Thread.sleep(20) + } + } + + private fun icon(value: String) = LookupElementPresentation().also { item(value).renderElement(it) }.icon + + private fun item(value: String) = myFixture.lookupElements.orEmpty().first { it.lookupString == value } + + private fun file(path: String, directory: Boolean = false) = WorkspaceFileDto( + path = path, + name = path.substringAfterLast('/'), + directory = directory, + ) + + private fun noMatches() = KiloBundle.message("prompt.completion.noMatches") +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/MentionNavigatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/MentionNavigatorTest.kt new file mode 100644 index 00000000000..fff14a03527 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/MentionNavigatorTest.kt @@ -0,0 +1,119 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.EditorMouseEvent +import com.intellij.openapi.editor.event.EditorMouseEventArea +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.awt.event.InputEvent +import java.awt.event.MouseEvent + +@Suppress("UnstableApiUsage") +class MentionNavigatorTest : BasePlatformTestCase() { + // "see @src/a.ts and @missing.ts" — resolved token spans 4..13, unresolved spans 18..29. + private val text = "see @src/a.ts and @missing.ts" + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var provider: KiloPromptCompletionProvider + private lateinit var editor: EditorEx + private lateinit var navigator: MentionNavigator + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + rpc = FakeWorkspaceRpcApi() + rpc.fileResolver = { path -> if (path == "src/a.ts") listOf(file(path)) else emptyList() } + val workspaces = KiloWorkspaceService(scope, rpc) + provider = KiloPromptCompletionProvider( + workspace = workspaces.workspace("/test"), + service = workspaces, + actions = emptyList(), + mentions = emptyList(), + scope = scope, + ) + val factory = EditorFactory.getInstance() + editor = factory.createEditor(factory.createDocument(text), project) as EditorEx + navigator = MentionNavigator(editor, provider) + navigator.install() + provider.validate(text, -1) {} + waitFor { + provider.mentionAt(text, 6)?.resolved == true && provider.mentionAt(text, 20)?.resolved == false + } + } + + override fun tearDown() { + try { + EditorFactory.getInstance().releaseEditor(editor) + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test hovering unresolved mention sets tooltip`() { + navigator.mouseMoved(event(20)) + + assertEquals(KiloBundle.message("prompt.mention.unresolved", "missing.ts"), editor.contentComponent.toolTipText) + } + + fun `test leaving mention clears tooltip`() { + navigator.mouseMoved(event(20)) + navigator.mouseMoved(event(1)) + + assertNull(editor.contentComponent.toolTipText) + } + + fun `test modifier hover highlights resolved mention as link`() { + navigator.mouseMoved(event(6, modifier = true)) + assertTrue(editor.markupModel.allHighlighters.any { it.layer == HighlighterLayer.HYPERLINK }) + + navigator.mouseMoved(event(6, modifier = false)) + assertFalse(editor.markupModel.allHighlighters.any { it.layer == HighlighterLayer.HYPERLINK }) + } + + fun `test modifier click opens resolved mention`() { + navigator.mouseClicked(event(6, modifier = true)) + waitFor { rpc.opened.contains("src/a.ts") } + + assertTrue(rpc.opened.contains("src/a.ts")) + } + + private fun event(offset: Int, modifier: Boolean = false): EditorMouseEvent { + val mask = if (modifier) InputEvent.META_DOWN_MASK or InputEvent.CTRL_DOWN_MASK else 0 + val point = editor.offsetToXY(offset) + val mouse = MouseEvent(editor.contentComponent, MouseEvent.MOUSE_MOVED, 0L, mask, point.x, point.y, 1, false) + return EditorMouseEvent( + editor, + mouse, + EditorMouseEventArea.EDITING_AREA, + offset, + editor.offsetToLogicalPosition(offset), + editor.offsetToVisualPosition(offset), + true, + null, + null, + null, + ) + } + + private fun file(path: String) = WorkspaceFileDto(path = path, name = path.substringAfterLast('/')) + + private fun waitFor(done: () -> Boolean) { + repeat(50) { + UIUtil.dispatchAllInvocationEvents() + if (done()) return + Thread.sleep(20) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt new file mode 100644 index 00000000000..585ba6e4bce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt @@ -0,0 +1,161 @@ +package ai.kilocode.client.session.ui.prompt + +import junit.framework.TestCase +import kotlinx.coroutines.runBlocking +import java.nio.file.Path + +class PromptMentionPartsTest : TestCase() { + + fun `test promptMentions parses boundary mentions`() { + assertEquals( + listOf(Mention("src/Main.kt", 5, 17), Mention("README.md", 22, 32)), + promptMentions("read @src/Main.kt and @README.md"), + ) + assertEquals(listOf(Mention("src/x.kt", 4, 13)), promptMentions("see @src/x.kt")) + } + + fun `test promptMentions ignores embedded and bare markers`() { + assertTrue(promptMentions("read foo@src/Main.kt and @").isEmpty()) + } + + fun `test promptMentions handles whitespace and punctuation as token text`() { + assertEquals( + listOf(Mention("src/a.kt", 4, 13), Mention("src/b.kt,", 14, 24)), + promptMentions("see\t@src/a.kt\n@src/b.kt,"), + ) + } + + fun `test fileMentions drops reserved names and deduplicates`() { + assertEquals( + listOf(Mention("src/a.kt", 5, 14), Mention("src/b.kt", 38, 47)), + fileMentions("read @src/a.kt ${MentionAction.GIT_CHANGES.token} @src/a.kt @src/b.kt", setOf(MentionAction.GIT_CHANGES.name)), + ) + } + + fun `test mentionFileParts builds file part for tracked relative path`() { + val parts = mentionFileParts("read @src/Main.kt", setOf("src/Main.kt"), "/repo") + + assertEquals(1, parts.size) + val part = parts.single() + assertEquals("file", part.type) + assertEquals("text/plain", part.mime) + assertEquals(Path.of("/repo/src/Main.kt").toUri().toString(), part.url) + assertEquals("Main.kt", part.filename) + assertEquals("file", part.source?.type) + assertEquals("src/Main.kt", part.source?.path) + assertEquals("@src/Main.kt", part.source?.text?.value) + assertEquals(5.0, part.source?.text?.start) + assertEquals(17.0, part.source?.text?.end) + } + + fun `test mentionFileParts ignores untracked path`() { + assertTrue(mentionFileParts("read @src/Main.kt", setOf("src/Other.kt"), "/repo").isEmpty()) + } + + fun `test mentionFileParts ignores edited mention suffix`() { + assertTrue(mentionFileParts("read @src/Main.kt-extra", setOf("src/Main.kt"), "/repo").isEmpty()) + assertTrue(mentionFileParts("read @src/Main.kt.bak", setOf("src/Main.kt"), "/repo").isEmpty()) + } + + fun `test mentionFileParts ignores embedded mention text`() { + assertTrue(mentionFileParts("read foo@src/Main.kt", setOf("src/Main.kt"), "/repo").isEmpty()) + } + + fun `test mentionFileParts keeps absolute paths absolute`() { + val path = "/tmp/abs.txt" + val part = mentionFileParts("read @$path", setOf(path), "/repo").single() + + assertEquals(Path.of(path).toUri().toString(), part.url) + assertEquals("abs.txt", part.filename) + } + + fun `test mentionParts validates hand typed mention at send time`() = runBlocking { + val parts = mentionParts( + text = "read @src/Main.kt", + directory = "/repo", + reserved = setOf(MentionAction.GIT_CHANGES.name), + resolve = { path -> path == "src/Main.kt" }, + gitChanges = { null }, + ) + + val part = parts.single() + assertEquals(Path.of("/repo/src/Main.kt").toUri().toString(), part.url) + assertEquals("@src/Main.kt", part.source?.text?.value) + assertEquals(5.0, part.source?.text?.start) + } + + fun `test mentionParts drops unresolved file mentions`() = runBlocking { + assertTrue(mentionParts( + text = "read @src/Main.kt", + directory = "/repo", + reserved = setOf(MentionAction.GIT_CHANGES.name), + resolve = { false }, + gitChanges = { null }, + ).isEmpty()) + } + + fun `test mentionParts handles mixed resolved reserved and git changes`() = runBlocking { + val parts = mentionParts( + text = "read @src/Main.kt @missing.kt ${MentionAction.GIT_CHANGES.token}", + directory = "/repo", + reserved = setOf(MentionAction.GIT_CHANGES.name), + resolve = { path -> path == "src/Main.kt" }, + gitChanges = { "diff" }, + ) + + assertEquals(2, parts.size) + assertEquals("Main.kt", parts[0].filename) + assertEquals(MentionAction.GIT_CHANGES.filename, parts[1].filename) + assertEquals(MentionAction.GIT_CHANGES.uri, parts[1].source?.path) + } + + fun `test mentionParts skips blank git changes`() = runBlocking { + assertTrue(mentionParts( + text = "review ${MentionAction.GIT_CHANGES.token}", + directory = "/repo", + reserved = setOf(MentionAction.GIT_CHANGES.name), + resolve = { true }, + gitChanges = { " " }, + ).isEmpty()) + } + + fun `test mentionParts skips git lookup without token`() = runBlocking { + var called = false + + mentionParts( + text = "read @src/Main.kt", + directory = "/repo", + reserved = setOf(MentionAction.GIT_CHANGES.name), + resolve = { false }, + gitChanges = { + called = true + "diff" + }, + ) + + assertFalse(called) + } + + fun `test gitChangesPart builds encoded data part`() { + val part = gitChangesPart("review ${MentionAction.GIT_CHANGES.token}", "hello world+plus")!! + + assertEquals("file", part.type) + assertEquals("text/plain", part.mime) + assertEquals(MentionAction.GIT_CHANGES.filename, part.filename) + assertEquals("data:text/plain;charset=utf-8,hello%20world%2Bplus", part.url) + assertEquals("file", part.source?.type) + assertEquals(MentionAction.GIT_CHANGES.uri, part.source?.path) + assertNull(part.source?.clientName) + assertNull(part.source?.uri) + assertEquals(MentionAction.GIT_CHANGES.token, part.source?.text?.value) + assertEquals(7.0, part.source?.text?.start) + assertEquals(19.0, part.source?.text?.end) + } + + fun `test gitChangesPart ignores missing blank and non boundary matches`() { + assertNull(gitChangesPart("review ${MentionAction.GIT_CHANGES.token}", null)) + assertNull(gitChangesPart("review ${MentionAction.GIT_CHANGES.token}", " ")) + assertNull(gitChangesPart("review ${MentionAction.GIT_CHANGES.token}-foo", "diff")) + assertNull(gitChangesPart("review foo${MentionAction.GIT_CHANGES.token}", "diff")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt new file mode 100644 index 00000000000..f3d6736a982 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt @@ -0,0 +1,72 @@ +package ai.kilocode.client.session.ui.selection + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextArea +import java.awt.event.MouseEvent + +@Suppress("UnstableApiUsage") +class SessionSelectionTest : BasePlatformTestCase() { + fun `test selecting second text component clears first`() { + val selection = SessionSelection() + val one = JBTextArea("first value") + val two = JBTextArea("second value") + selection.register(one) + selection.register(two) + + one.select(0, 5) + two.select(0, 6) + + assertNull(one.selectedText) + assertEquals("second", two.selectedText) + assertEquals("second", selection.selectedText()) + } + + fun `test clearing active selection disables copy text`() { + val selection = SessionSelection() + val area = JBTextArea("selected value") + selection.register(area) + + area.select(0, 8) + area.select(0, 0) + + assertNull(selection.selectedText()) + } + + fun `test starting mouse selection clears previous text component immediately`() { + val selection = SessionSelection() + val one = JBTextArea("first value") + val two = JBTextArea("second value") + selection.register(one) + selection.register(two) + + one.select(0, 5) + val event = MouseEvent(two, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), 0, 1, 1, 1, false) + for (listener in two.mouseListeners) listener.mousePressed(event) + + assertNull(one.selectedText) + assertNull(selection.selectedText()) + } + + fun `test unregistering active selection clears active state`() { + val selection = SessionSelection() + val area = JBTextArea("selected value") + val reg = selection.register(area) + + area.select(0, 8) + Disposer.dispose(reg) + + assertNull(selection.selectedText()) + } + + fun `test applyStyle updates swing selection colors`() { + val selection = SessionSelection() + val area = JBTextArea("selected value") + selection.register(area) + selection.applyStyle(SessionEditorStyle.current()) + + assertNotNull(area.selectionColor) + assertNotNull(area.selectedTextColor) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt new file mode 100644 index 00000000000..67c69d9bf8a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -0,0 +1,155 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class GlobToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test header renders title directory and pattern rows`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Glob")) + assertEquals(listOf("/repo/src", "pattern=**/*.kt"), view.targetTexts()) + assertTrue(view.targetVisible(1)) + } + + fun `test pattern row hides when pattern is absent`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src") + }) + + assertEquals(listOf("/repo/src"), view.targetTexts()) + assertFalse(view.targetVisible(1)) + } + + fun `test repo path displays relative directory`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test repo root directory is hidden`() { + val exact = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "**/*.kt") + }, repo = "/repo") + val dot = GlobToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=**/*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=**/*.kt"), dot.targetTexts()) + assertFalse(exact.targetVisible(1)) + assertFalse(dot.targetVisible(1)) + } + + fun `test outside repo directory stays absolute`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test target labels use regular font`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + + fun `test completed glob starts collapsed and expands output`() { + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + } + + fun `test glob body is lazy and reused`() { + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt" })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + val editor = view.bodyEditor() + assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps glob body uncreated`() { + val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt" }) + + view.update(tool().also { it.output = "/repo/src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("/repo/src/B.kt", view.bodyText()) + } + + fun `test view factory routes glob to glob tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is GlobToolView) + } + + fun `test should replace when glob renderer changes`() { + val glob = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob), read)) + assertFalse(ViewFactory.shouldReplace(GlobToolView(glob), glob)) + } + + private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: GlobToolView): GlobToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt index 63aa4cbaa87..5f651adcb81 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -62,7 +63,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() { val view = LoginRequiredView(openProfile = {}, dismiss = {}) view.show("Sign in required.") val btn = view.openProfileButton() - assertEquals(SessionUiStyle.View.surface(), btn.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), btn.background) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt new file mode 100644 index 00000000000..9e6845f53c7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -0,0 +1,78 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.md.MdView +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font + +@Suppress("UnstableApiUsage") +class PlanExitViewTest : BasePlatformTestCase() { + fun `test completed plan exit renders ready transcript text and path`() { + val tool = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/x.md") + } + + val view = PlanExitView(tool) {} + + assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown()) + } + + fun `test view factory replaces running tool with plan exit view when completed`() { + val running = tool(ToolExecState.RUNNING) + val existing = ViewFactory.create(running, {}) {} + assertTrue(existing is ToolView) + + val done = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/x.md") + } + + assertTrue(ViewFactory.shouldReplace(existing, done)) + assertTrue(ViewFactory.create(done, {}) {} is PlanExitView) + } + + fun `test clicking plan link opens href`() { + val opened = mutableListOf() + val tool = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/my%20plan.md") + } + + val view = PlanExitView(tool) { opened.add(it) } + view.simulateLink(".kilo/plans/my%20plan.md") + + assertEquals(listOf(".kilo/plans/my%20plan.md"), opened) + } + + fun `test applyStyle refreshes nested markdown role colors`() { + val view = PlanExitView(tool(ToolExecState.COMPLETED)) {} + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + val style = SessionEditorStyle.create(scheme = scheme) + + view.applyStyle(style) + + assertTrue(md(view).overrideSheet().contains("a { color: #778899")) + } + + private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply { + this.state = state + output = "Plan is ready at .kilo/plans/x.md. Ending planning turn." + } + + private fun md(view: PlanExitView): MdView { + val field = PlanExitView::class.java.getDeclaredField("md") + field.isAccessible = true + return field.get(view) as MdView + } +} 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 8d03559f920..868ba5a4cdc 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 @@ -4,8 +4,19 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind 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 com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.swing.Icon +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.border.Border @Suppress("UnstableApiUsage") class QuestionResultViewTest : BasePlatformTestCase() { @@ -122,6 +133,46 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } + fun `test toggle uses right and down chevron icons`() { + val view = QuestionResultView(completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + )) + + assertTrue(icons(view).contains(SessionViewIcons.chevronCollapsed)) + assertTrue(icons(view).contains(SessionViewIcons.chevronRight)) + val closed = SessionViewIcons.chevronCollapsed + + view.toggle() + + assertTrue(icons(view).contains(SessionViewIcons.chevronExpanded)) + assertTrue(icons(view).contains(SessionViewIcons.chevronDown)) + assertEquals(closed.iconWidth, SessionViewIcons.chevronExpanded.iconWidth) + assertEquals(closed.iconHeight, SessionViewIcons.chevronExpanded.iconHeight) + } + + fun `test hover only changes header background`() { + val view = QuestionResultView(completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + )) + val root = view.node(0) + val header = root.node(0) + + assertEquals(0, paint(root.border).alpha) + view.toggle() + val body = root.node(1) + + view.setHovered(true) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header.background.rgb) + assertLine(root.border) + assertEquals(SessionUiStyle.View.Outline.brightColor().rgb, paint(body.border).rgb) + view.setHovered(false) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header.background.rgb) + assertLine(root.border) + } + // ------ view factory routing ------ fun `test view factory uses question result view for completed parsable question tool`() { @@ -129,7 +180,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), ) - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool, {}) {} assertTrue(view is QuestionResultView) } @@ -139,14 +190,14 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = emptyMap(), metadata = emptyMap(), ) - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool, {}) {} assertTrue(view is ToolView) } fun `test view factory falls back to tool view for running question`() { val tool = runningTool("question") - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool, {}) {} assertTrue(view is ToolView) } @@ -237,4 +288,57 @@ class QuestionResultViewTest : BasePlatformTestCase() { private fun runningTool(name: String, id: String = "tp1"): Tool = Tool(id, name, toolKind(name)).apply { state = ToolExecState.RUNNING } + + private fun Container.node(index: Int) = components[index] as JPanel + + private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED) + + private fun exit(component: Component) = event(component, MouseEvent.MOUSE_EXITED) + + private fun event(component: Component, id: Int) { + component.dispatchEvent(MouseEvent( + component, + id, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } + + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } + + private fun icons(component: Component): List { + val found = mutableListOf() + collect(component, found) + 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/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index b35c7e1538b..9305d3114a8 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 @@ -5,8 +5,10 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -15,17 +17,19 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import java.awt.BorderLayout import java.awt.Component import java.awt.Container import kotlin.math.abs import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class QuestionViewTest : BasePlatformTestCase() { - private val replies = mutableListOf>() + private val replies = mutableListOf>>>() private val rejects = mutableListOf() private var scrolls = 0 private lateinit var view: QuestionView @@ -34,7 +38,7 @@ class QuestionViewTest : BasePlatformTestCase() { super.setUp() view = QuestionView( project = project, - reply = { id, dto -> replies.add(id to dto) }, + reply = { id, dto, opts -> replies.add(Triple(id, dto, opts)) }, reject = { id -> rejects.add(id) }, scroll = { scrolls++ }, ) @@ -128,6 +132,22 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(findAll(view).isEmpty()) } + fun `test single question hides progress summary`() { + view.show(singleSelectQuestion("req_summary")) + + assertTrue(findAll(view).none { it.text == "1 of 1 questions" && it.isVisible }) + } + + fun `test single question uses roomy card spacing`() { + view.show(singleSelectQuestion("q_single_spacing")) + + val card = card() + val ins = card.border.getBorderInsets(card) + + assertEquals(UiStyle.Gap.xl(), ins.top) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test single question submit sends selected answer`() { view.show(singleSelectQuestion("req_2")) @@ -139,6 +159,7 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(1, replies.size) assertEquals("req_2", replies.single().first) assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + assertEquals(listOf(listOf("Minimal")), replies.single().third) } fun `test submit is disabled until question is answered`() { @@ -287,6 +308,23 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers) } + fun `test multi question progress header has top padding`() { + view.show(twoItemQuestion("q_progress_padding")) + + val card = card() + val outer = card.border.getBorderInsets(card) + val summary = findAll(view).first { it.text == "1 of 2 questions" } + val panel = summary.parent as JComponent + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.sm(), outer.top) + assertEquals(0, ins.top) + assertEquals(0, ins.left) + assertEquals(UiStyle.Gap.sm(), ins.bottom) + assertEquals(0, ins.right) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test multi question uses review before submit`() { view.show(twoItemQuestion("q_review")) @@ -461,8 +499,8 @@ class QuestionViewTest : BasePlatformTestCase() { val dismiss = button(view, "Dismiss") val submit = button(view, "Submit") - assertEquals(SessionUiStyle.View.surface(), dismiss.background) - assertEquals(SessionUiStyle.View.surface(), submit.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), dismiss.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), submit.background) } fun `test review submit and back buttons have correct primary state on review page`() { @@ -508,6 +546,7 @@ class QuestionViewTest : BasePlatformTestCase() { fun `test selection requests scroll to bottom`() { view.show(singleSelectQuestion("q_scroll")) + scrolls = 0 option(view, "Minimal").doClick() @@ -516,6 +555,7 @@ class QuestionViewTest : BasePlatformTestCase() { fun `test question navigation requests scroll to bottom`() { view.show(twoItemQuestion("q_nav_scroll")) + scrolls = 0 option(view, "Minimal").doClick() button(view, "Next").doClick() @@ -594,6 +634,37 @@ class QuestionViewTest : BasePlatformTestCase() { assertFalse(view.isVisible) assertEquals(1, replies.size) assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers) + assertEquals(listOf(emptyList()), replies.single().third) + } + + fun `test plan follow-up sends selected option labels separately`() { + view.show( + Question( + id = "q_plan", + items = listOf( + QuestionItem( + question = "Ready to implement?", + header = "Implement", + options = listOf( + QuestionOption("Start new session", "Implement in a fresh session with a clean context"), + QuestionOption("Continue here", "Implement the plan in this session", mode = "code"), + ), + multiple = false, + custom = true, + ) + ), + ) + ) + + assertLabelsContain(view, "Ready to implement?") + assertLabelsContain(view, "Start new session") + assertLabelsContain(view, "Continue here") + assertLabelsContain(view, "Add your own response") + option(view, "Continue here").doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("Continue here")), replies.single().second.answers) + assertEquals(listOf(listOf("Continue here")), replies.single().third) } fun `test custom editor grows for wrapped input`() { @@ -666,6 +737,18 @@ class QuestionViewTest : BasePlatformTestCase() { assertNull("Empty custom editor should be removed after selecting a normal option", findAll(view).firstOrNull { it.parent != null }) } + fun `test empty custom editor is detached after forcing underlying editor creation`() { + view.show(customSingleQuestion("q_custom_editor_release")) + findAll(view).first { it.actionCommand == "" }.doClick() + val field = findAll(view).first() + val editor = field.getEditor(true) + assertSame(editor, field.getEditor(false)) + + option(view, "Minimal").doClick() + + assertNull(SwingUtilities.getAncestorOfClass(QuestionView::class.java, field)) + } + fun `test focusing retained custom editor reselects custom response`() { view.show(customSingleQuestion("q_custom_focus")) @@ -877,6 +960,13 @@ class QuestionViewTest : BasePlatformTestCase() { private fun text(root: Container, value: String): JBTextArea = findAll(root).first { it.text == value } + private fun card(): BaseQuestionView = findAll(view).distinct().single() + + private fun spacer(card: BaseQuestionView): Component { + val north = (card.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container + return north.components.last() + } + private fun layout(root: Container, width: Int = 400) { root.setSize(width, root.preferredSize.height) layoutTree(root) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt new file mode 100644 index 00000000000..f04fd062a3e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.ui.UiStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class ReadToolViewTest : BasePlatformTestCase() { + + fun `test read tool shows filename`() { + val t = tool().also { it.input = mapOf("filePath" to "README.MD") } + + val view = ReadToolView(t) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Read")) + assertTrue(view.labelText().contains("README.MD")) + } + + fun `test read tool handles windows path`() { + val t = tool().also { it.input = mapOf("filePath" to "C:\\repo\\README.MD") } + + val view = ReadToolView(t) + + assertTrue(view.labelText().contains("README.MD")) + } + + fun `test read file output renders filename hyperlink`() { + val opened = mutableListOf() + val path = "/Users/kirillk/work/kilocode/.kilo/worktrees/agreeable-marlin/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt" + val t = tool().also { + it.output = """ + $path + file + + content + + """.trimIndent() + } + + val view = ReadToolView(t, openFile = { opened.add(it) }) + + assertTrue(view.linkVisible()) + assertEquals("SessionUiLayoutTest.kt", view.linkText()) + assertEquals(path, view.linkHref()) + assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) + assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb) + assertEquals(view.linkFont(), view.bodyFont()) + assertTrue(view.labelText().contains("SessionUiLayoutTest.kt")) + + view.openLink() + + assertEquals(listOf(path), opened) + } + + fun `test read directory output remains plain text`() { + val path = "/Users/kirillk/work/kilocode/packages/kilo-jetbrains" + val t = tool().also { + it.output = """ + $path + directory + + """.trimIndent() + } + + val view = ReadToolView(t) + + assertFalse(view.linkVisible()) + assertNull(view.linkHref()) + assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb) + assertEquals(view.subtitleFont(), view.bodyFont()) + assertTrue(view.labelText().contains(path)) + } + + fun `test read output is secondary non expandable summary`() { + val t = tool().also { it.output = "file contents" } + val view = ReadToolView(t) + + assertFalse(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("file contents", view.bodyText()) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyWrap()) + assertNull(view.bodyEditor()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + + view.toggle() + + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + } + + fun `test view factory routes read kind tools to read tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is SearchToolView) + assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is GlobToolView) + } + + fun `test canRender matches read kind tools only`() { + assertTrue(ReadToolView.canRender(tool())) + assertTrue(ReadToolView.canRender(Tool("p2", "grep", toolKind("grep")))) + assertTrue(ReadToolView.canRender(Tool("p3", "glob", toolKind("glob")))) + assertFalse(ReadToolView.canRender(Tool("p4", "bash", toolKind("bash")))) + } + + private fun tool() = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } +} 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 new file mode 100644 index 00000000000..d9f1ac10f19 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Reasoning +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import java.awt.Container +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class ReasoningViewStressTest : BasePlatformTestCase() { + + fun `test streaming reasoning retains markdown body and disposes editors`() { + val base = EditorFactory.getInstance().allEditors.size + val view = ReasoningView(reasoning("r1", done = false, text = "intro\n\n```kotlin\n")) + val component = view.md.component + val scroll = scrolls(view).first() + val editor = editors(view).single() + val count = panel(view).componentCount + editor.getEditor(true) + + repeat(150) { i -> view.appendDelta("val x$i = $i\n") } + + assertSame(component, view.md.component) + assertSame(scroll, scrolls(view).first()) + assertSame(editor, editors(view).single()) + assertEquals(1, editors(view).size) + assertTrue(htmls(view).size <= 1) + assertEquals(count, panel(view).componentCount) + + view.update(reasoning("r1", done = true, text = view.markdown() + "```")) + assertTrue(view.bodyVisible()) + Disposer.dispose(view) + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun reasoning(id: String, done: Boolean, text: String) = Reasoning(id).also { + it.done = done + it.content.append(text) + } + + private fun panel(view: ReasoningView): JPanel = view.md.component as JPanel + + private fun scrolls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun htmls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun editors(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun descendants(root: Container): List = root.components.flatMap { child -> + listOf(child) + ((child as? Container)?.let(::descendants) ?: emptyList()) + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 f0cf4d20169..e718bdcc8b6 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 @@ -2,7 +2,17 @@ package ai.kilocode.client.session.views 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.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container +import javax.swing.Icon +import javax.swing.JLabel +import javax.swing.JPanel import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -10,13 +20,15 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test completed reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + val base: Any = view assertFalse(view.isExpanded()) + assertTrue(base is SecondarySessionPartView) assertEquals("Reasoning", view.headerText()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) assertTrue(view.hasToggle()) assertFalse(view.bodyVisible()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) } fun `test short completed reasoning is collapsible`() { @@ -30,15 +42,16 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.bodyCreated()) } - fun `test streaming reasoning is collapsed by default`() { + fun `test streaming reasoning is expanded by default`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) assertTrue(view.hasToggle()) + assertTrue(view.bodyVisible()) } fun `test update to done preserves collapsed reasoning`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -46,6 +59,29 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) } + fun `test live reasoning stays expanded when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertTrue(view.bodyCreated()) + } + + fun `test manually expanded finished reasoning stays open on update`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) + + 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 toggle opens and closes reasoning`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -56,7 +92,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test collapsed reasoning stays collapsed on update`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) assertFalse(view.isExpanded()) @@ -69,42 +105,44 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("ab", view.markdown()) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) } - fun `test blank reasoning stays collapsed when delta arrives`() { + fun `test blank streaming reasoning opens when delta arrives`() { val view = ReasoningView(reasoning("p1", done = false, text = "")) + assertFalse(view.isVisible) view.appendDelta("b") assertEquals("b", view.markdown()) + assertTrue(view.isVisible) assertTrue(view.bodyCreated()) - assertFalse(view.bodyVisible()) + assertTrue(view.bodyVisible()) assertTrue(view.hasToggle()) } - fun `test collapsed append keeps eager reasoning body detached`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed append keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) view.appendDelta("b") assertEquals("ab", view.markdown()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertFalse(view.bodyVisible()) } - fun `test collapsed update keeps eager reasoning body detached`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed update keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) - view.update(reasoning("p1", done = false, text = "abc")) + view.update(reasoning("p1", done = true, text = "abc")) assertEquals("abc", view.markdown()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertFalse(view.bodyVisible()) } - fun `test reasoning reuses eager markdown body`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one")) + fun `test reasoning creates lazy markdown body once`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) view.toggle() val component = view.md.component @@ -118,27 +156,37 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test blank reasoning has no toggle`() { val view = ReasoningView(reasoning("p1", done = true, text = "")) + assertFalse(view.isVisible) assertFalse(view.isExpanded()) assertFalse(view.hasToggle()) } - fun `test reasoning markdown uses editor font settings`() { + fun `test reasoning markdown uses ui font with editor-derived size`() { val style = SessionEditorStyle.current() val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + view.toggle() assertSmallItalicSheet(view.md.overrideSheet(), style) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) } - fun `test reasoning header uses smaller editor-derived font`() { + fun `test reasoning header uses smaller ui font with editor-derived size`() { val style = SessionEditorStyle.current() val view = ReasoningView(reasoning("p1", done = true, text = "one")) val font = view.headerFont() - assertEquals(style.editorFamily, font.name) + assertEquals(style.smallEditorFont.name, font.name) assertTrue(font.size < style.editorSize) } + fun `test reasoning header uses brain icon`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val icons = icons(view) + + assertTrue(icons.contains(SessionViewIcons.brain)) + assertFalse(icons.contains(SessionViewIcons.eye)) + } + fun `test applyStyle updates reasoning in place`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) val component = view.md.component @@ -148,16 +196,77 @@ class ReasoningViewTest : BasePlatformTestCase() { assertSame(component, view.md.component) assertSmallItalicSheet(view.md.overrideSheet(), style) - assertEquals("Courier New", view.headerFont().name) + assertEquals(style.smallEditorFont.name, view.headerFont().name) assertTrue(view.headerFont().size < style.editorSize) } fun `test expanded reasoning body is capped to five rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) - view.toggle() + val taller = ReasoningView(reasoning("p2", done = false, text = (1..200).joinToString("\n") { "line $it" })) assertEquals(5, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) + assertEquals(view.preferredSize.height, taller.preferredSize.height) + } + + 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) + view.doLayout() + + view.appendDelta("\nline 21\nline 22") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) + } + + fun `test appended reasoning does not yank user scrolled above tail`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..40).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + UIUtil.dispatchAllInvocationEvents() + val scroll = scroll(view) + scroll.verticalScrollBar.value = 0 + + view.appendDelta("\nline 41\nline 42") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, scroll.verticalScrollBar.value) + } + + fun `test reasoning block uses vertical separator`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + + assertEquals(1, view.border!!.getBorderInsets(view).left) + + view.toggle() + + val insets = view.border!!.getBorderInsets(view) + assertEquals(0, insets.top) + assertEquals(1, insets.left) + assertEquals(0, insets.bottom) + assertEquals(0, insets.right) + assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) + } + + fun `test reasoning toggle uses shared right rail`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val row = view.components.single() as JPanel + val insets = row.border.getBorderInsets(row) + + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.right) + } + + fun `test link opens url callback`() { + val urls = mutableListOf() + val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = { + urls.add(it) + }) + + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) } private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) { @@ -166,7 +275,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } private fun assertSmallItalicSheet(sheet: String, style: SessionEditorStyle) { - assertTrue(sheet.contains(style.editorFamily)) + assertTrue(sheet.contains(style.smallEditorFont.name)) assertFalse(sheet.contains("${style.editorSize}pt")) assertTrue(sheet.contains("font-style: italic")) } @@ -175,4 +284,26 @@ class ReasoningViewTest : BasePlatformTestCase() { it.done = done it.content.append(text) } + + private fun scroll(component: Component): JBScrollPane { + if (component is JBScrollPane) return component + if (component is Container) { + component.components.forEach { child -> + val scroll = runCatching { scroll(child) }.getOrNull() + if (scroll != null) return scroll + } + } + error("scroll not found") + } + + private fun icons(component: Component): List { + val found = mutableListOf() + collect(component, found) + 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/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt new file mode 100644 index 00000000000..e9b090bee9b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -0,0 +1,206 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.awt.Container +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class SearchToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test header renders title pattern and include targets`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "class SearchToolView", "include" to "*.{kt,kts}") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Search")) + assertEquals(listOf("pattern=class SearchToolView", "include=*.{kt,kts}"), view.targetTexts()) + assertTrue(view.targetVisible(0)) + assertTrue(view.targetVisible(1)) + assertFalse(view.targetVisible(2)) + } + + fun `test header includes optional path target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(listOf("/repo/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo path displays relative search target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo root search path is hidden`() { + val exact = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + val dot = SearchToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=TODO", "include=*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=TODO", "include=*.kt"), dot.targetTexts()) + assertTrue(exact.targetVisible(0)) + assertFalse(exact.targetVisible(2)) + assertTrue(dot.targetVisible(0)) + assertFalse(dot.targetVisible(2)) + } + + fun `test outside repo search path stays absolute`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test target labels use plain text for clipping`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "", "include" to "*.kt") + }) + + assertEquals("pattern=", view.targetComponents().first().text) + } + + fun `test target labels use regular font`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + + fun `test search header title target gap uses standard medium gap`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(UiStyle.Gap.md(), (view.centerComponent().layout as BorderLayout).hgap) + } + + fun `test completed search starts collapsed and expands output`() { + val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + } + + fun `test search body is lazy and reused`() { + val view = track(SearchToolView(tool().also { it.output = "src/A.kt" })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + val editor = view.bodyEditor() + assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps search body uncreated`() { + val view = SearchToolView(tool().also { it.output = "src/A.kt" }) + + view.update(tool().also { it.output = "src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("src/B.kt", view.bodyText()) + } + + fun `test long targets stay horizontal and do not force header wider`() { + val view = SearchToolView(tool().also { + it.input = mapOf( + "pattern" to "a".repeat(200), + "include" to "**/*.${"b".repeat(200)}.kt", + ) + }) + val header = view.headerComponent() + header.setSize(Dimension(240, header.preferredSize.height)) + + layout(header) + + assertTrue(view.centerComponent().width <= header.width) + val labels = view.targetComponents().filter { it.isVisible } + assertEquals(labels.first().y, labels.last().y) + labels.forEach { + assertTrue(it.width <= view.centerComponent().width) + } + } + + fun `test view factory routes grep to search tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is SearchToolView) + } + + fun `test should replace when search renderer changes`() { + val search = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + val glob = Tool("p2", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(SearchToolView(search), read)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob, selection = null), search)) + assertFalse(ViewFactory.shouldReplace(SearchToolView(search), search)) + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach { layout(it) } + } + + private fun tool() = Tool("p1", "grep", toolKind("grep")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: SearchToolView): SearchToolView { + views.add(view) + return view + } +} 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 new file mode 100644 index 00000000000..8046cfe3d35 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -0,0 +1,389 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class ShellToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test command only shell renders markdown`() { + val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") })) + + assertTrue(view.hasToggle()) + assertFalse(view.bodyCreated()) + assertEquals("pwd", view.bodyText()) + view.toggle() + + assertEquals("**Command**\n\n```shell-command\npwd\n```", view.markdown()) + assertEquals(listOf("pwd"), view.codeTexts()) + } + + fun `test output only shell renders markdown`() { + val view = track(ShellToolView(tool().also { it.output = "done" })) + + assertEquals("done", view.outputText()) + view.toggle() + + assertEquals("**Output**\n\n```shell-output\ndone\n```", view.markdown()) + assertEquals(listOf("done"), view.codeTexts()) + } + + fun `test command and output render sections in order`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git status", "description" to "Check status") + it.output = "clean" + })) + + assertTrue(view.labelText().contains("Shell")) + assertTrue(view.labelText().contains("Check status")) + assertEquals("git status", view.commandText()) + assertEquals("clean", view.outputText()) + assertEquals("git status\n\nclean", view.bodyText()) + view.toggle() + + assertEquals( + "**Command**\n\n```shell-command\ngit status\n```\n\n**Output**\n\n```shell-output\nclean\n```", + view.markdown(), + ) + assertEquals(listOf("git status", "clean"), view.codeTexts()) + } + + fun `test ansi escapes are preserved in markdown and decoded in output`() { + val view = track(ShellToolView(tool().also { it.output = "\u001B[32mgreen\u001B[0m line" })) + + assertEquals("green line", view.outputText()) + view.toggle() + + assertTrue(view.markdown().contains("\u001B[32mgreen\u001B[0m line")) + assertEquals(listOf("green line"), view.codeTexts()) + assertTrue(view.codeEditors().single().getEditor(true)!!.markupModel.allHighlighters.isNotEmpty()) + } + + fun `test carriage return frames keep last non-empty value`() { + val view = track(ShellToolView(tool().also { + it.output = "progress 1\rprogress 2\rprogress done\nstdout line\n" + })) + + assertEquals("progress done\nstdout line\n", view.outputText()) + view.toggle() + + assertEquals(listOf("progress done\nstdout line"), view.codeTexts()) + } + + fun `test output backspaces clean visible text`() { + val view = track(ShellToolView(tool().also { it.output = "abc\b\bd" })) + + assertEquals("ad", view.outputText()) + view.toggle() + + assertEquals(listOf("ad"), view.codeTexts()) + } + + fun `test clean output delegates terminal reducer`() { + val view = track(ShellToolView(tool().also { + it.output = "\u001B[31mspin 1\u001B[0m\r\u001B[32mspin 2\u001B[0m\nabc\bd\u001B[K" + })) + + assertEquals("spin 2\nabd", view.outputText()) + assertEquals("spin 2\nabd", view.bodyText()) + } + + fun `test output backticks use longer markdown fence`() { + val view = track(ShellToolView(tool().also { it.output = "before\n```\nafter" })) + + view.toggle() + + assertTrue(view.markdown().contains("````shell-output\nbefore\n```\nafter\n````")) + assertEquals(listOf("before\n```\nafter"), view.codeTexts()) + } + + fun `test error section uses shell error text`() { + val view = track(ShellToolView(tool(ToolExecState.ERROR).also { + it.input = mapOf("command" to "fail") + it.error = "boom" + })) + + assertEquals("boom", view.errorText()) + assertTrue(view.labelText().contains("Error")) + view.toggle() + + assertEquals( + "**Command**\n\n```shell-command\nfail\n```\n\n**Error**\n\n```ansi-stderr\nboom\n```", + view.markdown(), + ) + assertEquals(listOf("fail", "boom"), view.codeTexts()) + val error = view.codeEditors().last().getEditor(true)!! + val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey + assertEquals(expected, error.markupModel.allHighlighters.single().textAttributesKey) + } + + fun `test body is created lazily and reused`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.mdComponent() + val cmd = view.codeEditors().first() + val out = view.codeEditors().last() + view.toggle() + view.toggle() + + assertSame(body, view.mdComponent()) + assertSame(cmd, view.codeEditors().first()) + assertSame(out, view.codeEditors().last()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update does not create body`() { + val view = track(ShellToolView(tool(ToolExecState.RUNNING).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + view.update(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/home" + }) + + assertFalse(view.bodyCreated()) + assertEquals("pwd\n\n/home", view.bodyText()) + } + + fun `test update after first expand changes existing markdown body`() { + val view = track(ShellToolView(tool(ToolExecState.RUNNING).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + view.toggle() + view.toggle() + val body = view.mdComponent() + val cmd = view.codeEditors().first() + val out = view.codeEditors().last() + view.update(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/home" + }) + + assertTrue(view.bodyCreated()) + assertFalse(view.bodyVisible()) + assertSame(body, view.mdComponent()) + assertSame(cmd, view.codeEditors().first()) + assertSame(out, view.codeEditors().last()) + assertEquals(listOf("pwd", "/home"), view.codeTexts()) + } + + fun `test applyStyle updates fonts in place`() { + val view = track(ShellToolView(tool().also { it.output = "done" })) + val style = SessionEditorStyle.create(family = "Courier New", size = 25) + view.toggle() + val editor = view.codeEditors().single() + + view.applyStyle(style) + + assertSame(editor, view.codeEditors().single()) + assertEquals(style.editorFont.name, view.commandFont().name) + assertEquals(style.editorSize, view.commandFont().size) + assertEquals(style.transcriptFont.name, view.titleFont().name) + assertTrue(view.titleFont().isBold) + assertEquals(style.transcriptFont.name, view.subtitleFont().name) + assertEquals(style.transcriptFont.size, view.subtitleFont().size) + assertFalse(view.subtitleFont().isBold) + assertEquals(UiStyle.Colors.weak().rgb, view.subtitleForeground().rgb) + assertTrue(view.stateFont().size < style.editorSize) + } + + fun `test selection registers shell markdown editors`() { + val selection = SessionSelection() + val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") }, selection)) + view.toggle() + val editor = view.codeEditors().single().getEditor(true) + editor?.selectionModel?.setSelection(0, 3) + + assertEquals("pwd", selection.selectedText()) + Disposer.dispose(selection) + } + + fun `test shell view uses editor backed markdown code blocks`() { + val style = SessionEditorStyle.current() + val view = track(ShellToolView(tool().also { it.output = "done" })) + view.toggle() + + assertEquals(style.editorFont.name, view.commandFont().name) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertTrue(view.preferredSize.height > 0) + } + + fun `test plain git output receives shell output highlighters`() { + val output = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val display = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val view = track(ShellToolView(tool().also { it.output = output })) + + view.toggle() + val editor = view.codeEditors().single().getEditor(true)!! + + assertTrue(view.markdown().contains("```shell-output\n$output\n```")) + assertEquals(display, view.codeTexts().single()) + assertTrue(editor.markupModel.allHighlighters.size >= 4) + } + + fun `test command receives shell command highlighters`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git log -30 --oneline --decorate") + })) + + view.toggle() + val field = view.codeEditors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertTrue(view.markdown().contains("```shell-command\ngit log -30 --oneline --decorate\n```")) + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-30" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--oneline" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) + } + + fun `test shell labels align with code text and code blocks use bottom border only`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + view.toggle() + val root = view.mdComponent()!! + val ins = root.border?.getBorderInsets(root) + val labels = root.components.filterIsInstance() + val panes = root.components.filterIsInstance() + + assertEquals(0, ins?.left ?: 0) + assertEquals(2, labels.size) + 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(0, label?.right ?: 0) + } + panes.forEach { + val pane = it.border.getBorderInsets(it) + assertEquals(0, pane.top) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.bottom) + assertEquals(0, pane.left) + assertEquals(0, pane.right) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, it.verticalScrollBarPolicy) + assertEquals(root.background.rgb, it.background.rgb) + assertEquals(root.background.rgb, it.viewport.background.rgb) + } + } + + fun `test shell code blocks are editor backed and capped to fifteen lines`() { + 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 editor = view.codeEditors().single() + val nested = editor.getEditor(true)!!.scrollPane + val line = editor.getEditor(true)!!.lineHeight + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(output, editor.text) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, nested.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, nested.horizontalScrollBarPolicy) + assertFalse(editor.getEditor(true)!!.settings.isUseSoftWraps) + assertTrue(pane.preferredSize.height <= line * 15 + chrome) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + + fun `test view factory routes bash and replaces generic views`() { + val bash = tool() + val other = Tool("p1", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.create(bash, openFile = {}) is ShellToolView) + assertTrue(ViewFactory.shouldReplace(ToolView(bash), bash)) + assertTrue(ViewFactory.shouldReplace(ShellToolView(bash), other)) + assertFalse(ViewFactory.shouldReplace(ShellToolView(bash), bash)) + } + + fun `test shell editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(40) { i -> + val view = ShellToolView(tool().also { + it.input = mapOf("command" to "log $i") + it.output = (1..20).joinToString("\n") { line -> "line $i/$line" } + }) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun ShellToolView.codeTexts() = codeEditors().map { it.text } + + private fun tool(state: ToolExecState = ToolExecState.COMPLETED) = Tool("p1", "bash", toolKind("bash")).also { + it.state = state + } + + private fun track(view: ShellToolView): ShellToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index a457b45d9e2..9165c7d3acf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -1,8 +1,23 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.util.ui.JBUI import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.awt.datatransfer.DataFlavor +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.RepaintManager /** * Tests for [TextView]. @@ -63,6 +78,24 @@ class TextViewTest : BasePlatformTestCase() { assertEquals("first second", view.markdown()) } + fun `test appendDelta empty string does not repaint or change markdown`() { + val view = TextView(Text("p1").also { it.content.append("keep") }) + val repaint = TrackingRepaintManager(view) + val old = RepaintManager.currentManager(view) + + try { + RepaintManager.setCurrentManager(repaint) + + view.appendDelta("") + + assertEquals("keep", view.markdown()) + assertEquals(0, repaint.dirty) + assertEquals(0, repaint.invalid) + } finally { + RepaintManager.setCurrentManager(old) + } + } + // ---- contentId ------ fun `test contentId matches Text id`() { @@ -77,12 +110,88 @@ class TextViewTest : BasePlatformTestCase() { assertNotNull(view.md.component) } - fun `test markdown uses editor font settings`() { + fun `test copy toolbar is retained below markdown component`() { + val view = TextView(Text("p1").also { it.content.append(" hello ") }) + + view.setCopyToolbar(true) + + val layout = view.layout as BorderLayout + assertSame(view.md.component, layout.getLayoutComponent(BorderLayout.CENTER)) + val bar = layout.getLayoutComponent(BorderLayout.SOUTH) as MessageToolbar + val buttons = bar.layout as BorderLayout + assertSame(view.copyButton(), buttons.getLayoutComponent(BorderLayout.LINE_START)) + assertTrue(view.hasCopyToolbar()) + } + + fun `test assistant copy button copies current trimmed markdown`() { + val view = TextView(Text("p1").also { it.content.append(" hello ") }) + view.setCopyToolbar(true) + + view.copyButton().doClick() + + assertEquals("hello", clipboard()) + } + + fun `test copy confirmation hides when mouse exits button`() { + val view = TextView(Text("p1").also { it.content.append("hello") }) + view.setCopyToolbar(true) + + view.copyButton().doClick() + view.copyButton().dispatchEvent(MouseEvent( + view.copyButton(), + MouseEvent.MOUSE_EXITED, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + + assertEquals("hello", clipboard()) + } + + fun `test copy toolbar reflects update and delta without replacing components`() { + val view = TextView(Text("p1").also { it.content.append(" first ") }) + view.setCopyToolbar(true) + val comp = view.md.component + val bar = (view.layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH) + + view.update(Text("p1").also { it.content.append(" second ") }) + view.appendDelta(" third ") + view.copyButton().doClick() + + assertSame(comp, view.md.component) + assertSame(bar, (view.layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) + assertEquals("second third", clipboard()) + } + + fun `test text view can copy untrimmed markdown`() { + val view = PromptView(Text("p1").also { it.content.append(" hello ") }) + view.setCopyToolbar(true, trim = false) + + view.copyButton().doClick() + + assertEquals(" hello ", clipboard()) + } + + fun `test blank copy toolbar is hidden until content appears`() { + val view = TextView(Text("p1")) + view.setCopyToolbar(true) + + assertFalse(view.hasCopyToolbar()) + + view.appendDelta("hello") + + assertTrue(view.hasCopyToolbar()) + } + + fun `test markdown uses ui family with editor size`() { val style = SessionEditorStyle.current() val view = TextView(Text("p1")) val sheet = view.md.overrideSheet() - assertTrue(sheet.contains(style.editorFamily)) + assertTrue(sheet.contains(style.transcriptFont.name)) assertTrue(sheet.contains("${style.editorSize}pt")) } @@ -95,8 +204,31 @@ class TextViewTest : BasePlatformTestCase() { val sheet = view.md.overrideSheet() assertSame(component, view.md.component) + assertTrue(sheet.contains(style.transcriptFont.name)) assertTrue(sheet.contains("Courier New")) assertTrue(sheet.contains("23pt")) + assertEquals(style.editorForeground, view.md.foreground) + } + + fun `test prompt view uses editor font and background`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 23) + val view = PromptView(Text("p1")) + + view.applyStyle(style) + + assertEquals(style.editorFont, view.md.font) + assertEquals(style.editorBackground, view.md.background) + assertFalse(view.contentOpaque()) + } + + fun `test prompt view uses input shell padding`() { + val view = PromptView(Text("p1")) + val ins = view.border.getBorderInsets(view) + + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.bottom) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.right) } // ---- markdown is rendered ------ @@ -113,4 +245,191 @@ class TextViewTest : BasePlatformTestCase() { view.appendDelta("**") assertTrue(view.md.html().contains("")) } + + fun `test link opens url callback`() { + val urls = mutableListOf() + val view = TextView(Text("p1"), openUrl = { urls.add(it) }) + + view.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + + fun `test linkifyMentions rewrites tracked token`() { + val out = linkifyMentions( + "read @src/a.kt", + listOf(PromptMention("@src/a.kt", "src/a.kt", 5, 14)), + ) + + assertEquals("read [@src/a.kt](src/a.kt)", out) + } + + fun `test linkifyMentions escapes text and encodes href`() { + val out = linkifyMentions( + "open @[a] file.kt", + listOf(PromptMention("@[a] file.kt", "[a] file.kt", 5, 17)), + ) + + assertEquals("open [@\\[a\\] file.kt]([a]%20file.kt)", out) + } + + fun `test linkifyMentions handles multiple mentions by offset`() { + val out = linkifyMentions( + "read @src/a.kt and @src/b.kt", + listOf( + PromptMention("@src/a.kt", "src/a.kt", 5, 14), + PromptMention("@src/b.kt", "src/b.kt", 19, 28), + ), + ) + + assertEquals("read [@src/a.kt](src/a.kt) and [@src/b.kt](src/b.kt)", out) + } + + fun `test linkifyMentions falls back to literal replacement when offset drifts`() { + val out = linkifyMentions( + "read @src/a.kt", + listOf(PromptMention("@src/a.kt", "src/a.kt", 0, 4)), + ) + + assertEquals("read [@src/a.kt](src/a.kt)", out) + } + + fun `test linkifyMentions fallback does not relink generated markdown`() { + val out = linkifyMentions( + "read @src/a.kt and @src/a.kt", + listOf( + PromptMention("@src/a.kt", "src/a.kt", 5, 14), + PromptMention("@src/a.kt", "src/a.kt", 0, 4), + ), + ) + + assertEquals("read [@src/a.kt](src/a.kt) and [@src/a.kt](src/a.kt)", out) + } + + fun `test linkifyMentions leaves text without mentions unchanged`() { + assertEquals("read @src/a.kt", linkifyMentions("read @src/a.kt", emptyList())) + } + + fun `test promptMentions extracts source backed text files`() { + val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0))) + msg.parts["keep"] = file("keep", "text/plain", "@src/a.kt", "src/a.kt", 0, 9) + msg.parts["image"] = file("image", "image/png", "@src/a.png", "src/a.png", 0, 10) + msg.parts["blank"] = file("blank", "text/plain", "@src/b.kt", "", 0, 9) + msg.parts["plain"] = FileAttachment("plain").also { it.mime = "text/plain" } + + val mentions = promptMentions(msg) + assertEquals(1, mentions.size) + assertEquals("@src/a.kt", mentions.single().token) + assertEquals("src/a.kt", mentions.single().path) + assertEquals(0, mentions.single().start) + assertEquals(9, mentions.single().end) + assertSame(msg.parts["keep"], mentions.single().attachment) + } + + fun `test prompt view renders mention as link`() { + val text = Text("p1").also { it.content.append("read @src/a.kt") } + val view = PromptView(text, mentions = listOf(PromptMention("@src/a.kt", "src/a.kt", 5, 14))) + + assertEquals("read [@src/a.kt](src/a.kt)", view.markdown()) + assertTrue(view.md.html().contains("href=\"src/a.kt\"")) + assertTrue(view.md.html().contains("@src/a.kt")) + } + + fun `test prompt view routes mention links to file and urls to browser`() { + val files = mutableListOf() + val urls = mutableListOf() + val text = Text("p1").also { it.content.append("read @src/a file.kt") } + val view = PromptView( + text, + openFile = { files.add(it) }, + openUrl = { urls.add(it) }, + mentions = listOf(PromptMention("@src/a file.kt", "src/a file.kt", 5, 19)), + ) + + view.simulateLink("src/a%20file.kt") + view.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("src/a file.kt"), files) + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + + fun `test prompt view routes attachment backed mention link to attachment opener`() { + val opened = mutableListOf() + val item = file("f1", "text/plain", "@git-changes", "git-changes", 7, 19).also { + it.url = "data:text/plain;charset=utf-8,diff%20content" + it.filename = "git-changes.txt" + } + val text = Text("p1").also { it.content.append("review @git-changes") } + val view = PromptView( + text, + openFile = { error("should not open file") }, + openAttachment = { opened.add(it) }, + mentions = listOf(PromptMention("@git-changes", "git-changes", 7, 19, item)), + ) + + view.simulateLink("git-changes") + + assertEquals(listOf(item), opened) + } + + fun `test prompt view setMentions refreshes existing prompt`() { + val text = Text("p1").also { it.content.append("read @src/a.kt") } + val view = PromptView(text) + + view.setMentions(listOf(PromptMention("@src/a.kt", "src/a.kt", 5, 14))) + + assertEquals("read [@src/a.kt](src/a.kt)", view.markdown()) + } + + fun `test message view syncs prompt mentions from hidden part`() { + val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0))) + val view = MessageView(msg, openFile = {}) + val text = Text("p1").also { it.content.append("read @src/a.kt") } + msg.parts["p1"] = text + view.upsertPart(text) + + assertEquals("read @src/a.kt", (view.part("p1") as PromptView).markdown()) + + val mention = file("f1", "text/plain", "@src/a.kt", "src/a.kt", 5, 14) + msg.parts["f1"] = mention + view.upsertPart(mention) + + assertNull(view.part("f1")) + assertEquals("read [@src/a.kt](src/a.kt)", (view.part("p1") as PromptView).markdown()) + } + + fun `test prompt view colors links with metadata color`() { + val style = SessionEditorStyle.current() + val color = style.editorScheme.getAttributes(DefaultLanguageHighlighterColors.METADATA)?.foregroundColor + val view = PromptView(Text("p1")) + val before = view.md.linkColor + + view.applyStyle(style) + + assertEquals(color ?: before, view.md.linkColor) + } + + private fun file(id: String, mime: String, token: String, path: String, start: Int, end: Int) = FileAttachment(id).also { + it.mime = mime + it.source = PartSourceDto("file", PartSourceTextDto(token, start.toDouble(), end.toDouble()), path = path) + } + + private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() { + var dirty = 0 + var invalid = 0 + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c === watched) dirty++ + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent === watched) invalid++ + super.addInvalidComponent(invalidComponent) + } + } + + private fun clipboard() = CopyPasteManager.getInstance() + .contents + ?.getTransferData(DataFlavor.stringFlavor) as String } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt new file mode 100644 index 00000000000..f2595428c43 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil + +@Suppress("UnstableApiUsage") +class ToolBodyStressTest : BasePlatformTestCase() { + + fun `test expanded generic tool body editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ToolView(tool(i)) + view.toggle() + view.bodyEditor()?.getEditor(true) + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test expanded shell tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ShellToolView(shell(i)) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test expanded search tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val search = SearchToolView(search(i)) + search.toggle() + search.bodyEditor()?.getEditor(true) + Disposer.dispose(search) + + val glob = GlobToolView(glob(i)) + glob.toggle() + glob.bodyEditor()?.getEditor(true) + Disposer.dispose(glob) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun tool(index: Int) = Tool("p$index", "mystery", toolKind("mystery")).also { + it.state = ToolExecState.COMPLETED + it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } + } + + private fun shell(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("command" to "log $index") + it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } + } + + private fun search(index: Int) = Tool("s$index", "grep", toolKind("grep")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "needle$index", "include" to "*.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt: needle$index" } + } + + private fun glob(index: Int) = Tool("g$index", "glob", toolKind("glob")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "**/*$index.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt" } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 1486718c479..1834157ce6b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -6,14 +6,33 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind 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 ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.awt.Color +import java.awt.image.BufferedImage +import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.border.Border /** * Tests for [ToolView]. */ @Suppress("UnstableApiUsage") class ToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } // ---- state icons ------ @@ -46,33 +65,24 @@ class ToolViewTest : BasePlatformTestCase() { fun `test title shown instead of name when title is set`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Install deps")) assertTrue(view.labelText().contains("Shell")) } fun `test blank title falls back to tool name`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED; it.title = " " } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) } - fun `test read tool shows filename`() { - val t = tool("p1", "read", ToolExecState.COMPLETED).also { it.input = mapOf("filePath" to "README.MD") } - - val view = ToolView(t) - - assertTrue(view.labelText().contains("Read")) - assertTrue(view.labelText().contains("README.MD")) - } - fun `test bash tool shows subtitle command and output`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git remote -v", "description" to "View remotes") it.output = "origin git@example.com:repo.git" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) assertTrue(view.labelText().contains("View remotes")) @@ -82,18 +92,45 @@ class ToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) assertFalse(view.bodyVisible()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) view.toggle() assertTrue(view.bodyVisible()) assertTrue(view.bodyCreated()) } + fun `test bash tool uses secondary chrome`() { + val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + } + + fun `test unknown tool uses secondary chrome`() { + val view = ToolView(tool("p1", "mystery", ToolExecState.COMPLETED)) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + } + + fun `test tool outline is drawn only while expanded`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertEquals(0, paint(view.border).alpha) + view.toggle() + assertEquals(SessionUiStyle.View.Outline.color().rgb, paint(view.border).rgb) + view.toggle() + assertEquals(0, paint(view.border).alpha) + } + fun `test bash toggle collapses and expands`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) view.toggle() @@ -107,7 +144,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals("$ git log\n\none\ntwo\nthree\nfour", view.bodyText()) assertTrue(view.hasToggle()) @@ -116,43 +153,44 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyVisible()) } - fun `test tool reuses eager body after collapse and expand`() { + fun `test tool creates lazy body once after collapse and expand`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) view.toggle() - val font = view.bodyFont() + val body = view.bodyEditor() + assertNotNull(body) view.toggle() view.toggle() - assertSame(font, view.bodyFont()) + assertSame(body, view.bodyEditor()) assertTrue(view.bodyVisible()) } - fun `test collapsed update keeps eager tool body detached`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + fun `test collapsed update keeps lazy tool body uncreated`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.update(tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") it.output = "/home" }) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertEquals("$ pwd\n\n/home", view.bodyText()) } fun `test collapsed update after first expand reuses tool body text`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.toggle() view.toggle() @@ -171,7 +209,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) @@ -185,7 +223,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("path" to "/tmp", "pattern" to "**/*.kt") it.output = "/tmp/A.kt" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Glob")) assertTrue(view.labelText().contains("/tmp")) @@ -197,30 +235,23 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyVisible()) } - fun `test read tool handles windows path`() { - val t = tool("p1", "read", ToolExecState.COMPLETED).also { - it.input = mapOf("filePath" to "C:\\repo\\README.MD") - } - - val view = ToolView(t) - - assertTrue(view.labelText().contains("README.MD")) - } - fun `test bash output uses editor font settings`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + view.toggle() - assertEditorFont(view.bodyFont(), style) + assertCodeFont(view.bodyFont(), style) assertFalse(view.bodyEditable()) assertFalse(view.bodyCaretVisible()) - assertTrue(view.bodyWrap()) - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + assertFalse(view.bodyWrap()) + assertNotNull(view.bodyEditor()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) } fun `test tool header uses editor-derived fonts`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) @@ -228,13 +259,22 @@ class ToolViewTest : BasePlatformTestCase() { assertSmallEditorFont(view.stateFont(), style) } + fun `test tool header title subtitle gap uses standard medium gap`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + + assertEquals(UiStyle.Gap.md(), centerGap(view)) + } + fun `test applyStyle updates tool fonts in place`() { val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) val style = SessionEditorStyle.create(family = "Courier New", size = 25) + view.toggle() + val editor = view.bodyEditor() view.applyStyle(style) - assertEditorFont(view.bodyFont(), style) + assertSame(editor, view.bodyEditor()) + assertCodeFont(view.bodyFont(), style) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) assertSmallEditorFont(view.subtitleFont(), style) @@ -247,7 +287,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals(1, view.controlCount()) } @@ -257,7 +297,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "log") it.output = (1..40).joinToString("\n") { line -> "line $line" } } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() @@ -272,7 +312,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals("$ log\n\n$out", view.bodyText()) @@ -286,7 +326,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals(out, view.bodyText()) @@ -329,13 +369,39 @@ class ToolViewTest : BasePlatformTestCase() { private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = Tool(id, name, toolKind(name)).also { it.state = state; it.title = title } + private fun track(view: ToolView): ToolView { + views.add(view) + return view + } + private fun assertEditorFont(font: java.awt.Font, style: SessionEditorStyle) { - assertEquals(style.editorFamily, font.name) + assertEquals(style.transcriptFont.name, font.name) + assertEquals(style.editorSize, font.size) + } + + private fun assertCodeFont(font: java.awt.Font, style: SessionEditorStyle) { + assertEquals(style.editorFont.name, font.name) assertEquals(style.editorSize, font.size) } private fun assertSmallEditorFont(font: java.awt.Font, style: SessionEditorStyle) { - assertEquals(style.editorFamily, font.name) + assertEquals(style.smallEditorFont.name, font.name) assertTrue(font.size < style.editorSize) } + + private fun centerGap(view: ToolView): Int { + val row = view.components.filterIsInstance().single() + val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + return (center.layout as BorderLayout).hgap + } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 8427cce067e..0ede3a0aa29 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -11,29 +11,34 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import java.awt.image.BufferedImage +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.RepaintManager /** * Tests for [TurnView] and [MessageView]. */ @Suppress("UnstableApiUsage") class TurnViewTest : BasePlatformTestCase() { + private val openFile: (String) -> Unit = {} // ------ TurnView ------ fun `test new TurnView is empty`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) assertTrue(tv.messageIds().isEmpty()) } fun `test addMessage appends and returns view`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) val mv = tv.addMessage(msg("u1", "user")) assertEquals("u1", mv.msg.info.id) assertEquals(listOf("u1"), tv.messageIds()) } fun `test addMessage preserves insertion order`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) tv.addMessage(msg("a2", "assistant")) @@ -41,7 +46,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test messageView returns the view for a given id`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) val mv = tv.messageView("u1") assertNotNull(mv) @@ -49,12 +54,12 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test messageView returns null for unknown id`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) assertNull(tv.messageView("missing")) } fun `test removeMessage removes the view`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) @@ -65,14 +70,14 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removeMessage unknown id is noop`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.removeMessage("nope") assertEquals(listOf("u1"), tv.messageIds()) } fun `test dump produces correct format`() { - val tv = TurnView("u1") + val tv = TurnView("u1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) assertEquals("user#u1, assistant#a1", tv.dump()) @@ -81,22 +86,53 @@ class TurnViewTest : BasePlatformTestCase() { // ------ MessageView ------ fun `test new MessageView is empty`() { - val mv = MessageView(msg("u1", "user")) + val mv = MessageView(msg("u1", "user"), openFile) assertTrue(mv.partIds().isEmpty()) } fun `test MessageView for user message has user role`() { - val mv = MessageView(msg("u1", "user")) + val mv = MessageView(msg("u1", "user"), openFile) assertEquals("user", mv.role) } fun `test MessageView for assistant message has assistant role`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) assertEquals("assistant", mv.role) } + fun `test user message uses prompt shell padding`() { + val mv = MessageView(msg("u1", "user"), openFile) + val ins = mv.border.getBorderInsets(mv) + + assertEquals(0, ins.top) + assertEquals(0, ins.bottom) + assertEquals(0, ins.left) + assertEquals(0, ins.right) + assertFalse(mv.isOpaque) + } + + fun `test user message uses standard outline color`() { + val mv = MessageView(msg("u1", "user"), openFile) + mv.setSize(120, 48) + val image = BufferedImage(120, 48, BufferedImage.TYPE_INT_ARGB) + + mv.paint(image.createGraphics()) + + assertEquals(SessionUiStyle.View.Outline.color().rgb, image.getRGB(60, 0)) + } + + fun `test assistant message remains borderless`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + val ins = mv.border.getBorderInsets(mv) + + assertEquals(0, ins.top) + assertEquals(0, ins.bottom) + assertEquals(0, ins.left) + assertEquals(0, ins.right) + } + fun `test upsertPart adds a new TextView for Text content`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) val text = ai.kilocode.client.session.model.Text("p1") text.content.append("hello") mv.upsertPart(text) @@ -105,8 +141,28 @@ class TurnViewTest : BasePlatformTestCase() { assertTrue(mv.part("p1") is TextView) } + fun `test user text view is transparent`() { + val mv = MessageView(msg("u1", "user"), openFile) + val text = ai.kilocode.client.session.model.Text("p1") + text.content.append("hello") + + mv.upsertPart(text) + + assertFalse((mv.part("p1") as TextView).contentOpaque()) + } + + fun `test assistant text view is transparent`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + val text = ai.kilocode.client.session.model.Text("p1") + text.content.append("hello") + + mv.upsertPart(text) + + assertFalse((mv.part("p1") as TextView).contentOpaque()) + } + fun `test upsertPart updates existing part rather than adding duplicate`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") } mv.upsertPart(t1) @@ -119,7 +175,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removePart removes the renderer`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("x") }) mv.removePart("p1") @@ -128,13 +184,13 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removePart unknown id is noop`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.removePart("none") assertTrue(mv.partIds().isEmpty()) } fun `test appendDelta reaches TextView`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("hello ") }) mv.appendDelta("p1", "world") @@ -143,18 +199,106 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("hello world", view.markdown()) } + fun `test consecutive reasoning parts reuse one view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1"), mv.partIds()) + assertSame(mv.part("r1"), mv.part("r2")) + assertEquals("first second", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test delta for aliased reasoning appends to reused view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + val mv = MessageView(message, openFile) + + assertTrue(mv.appendDelta("r2", " third")) + + assertEquals("first second third", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test reasoning alias maps stay bounded across churn`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + + repeat(100) { i -> + mv.upsertPart(reasoning("r${i}a", "first $i ")) + mv.upsertPart(reasoning("r${i}b", "second $i")) + + assertEquals(listOf("r${i}a"), mv.partIds()) + assertSame(mv.part("r${i}a"), mv.part("r${i}b")) + assertEquals(1, aliasSize(mv)) + assertEquals(1, sourceSize(mv)) + assertEquals(1, mv.componentCount) + + mv.removePart("r${i}b") + mv.removePart("r${i}a") + + assertTrue(mv.partIds().isEmpty()) + assertEquals(0, aliasSize(mv)) + assertEquals(0, sourceSize(mv)) + assertEquals(0, mv.componentCount) + } + } + + fun `test text between reasoning parts keeps separate views`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first") + message.parts["t1"] = text("t1", "middle") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1", "t1", "r2"), mv.partIds()) + assertNotSame(mv.part("r1"), mv.part("r2")) + } + + fun `test blank reasoning part is invisible`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "") + message.parts["t1"] = text("t1", "middle") + + val mv = MessageView(message, openFile) + + assertFalse(mv.part("r1")!!.isVisible) + assertTrue(mv.part("t1")!!.isVisible) + } + fun `test appendDelta for unknown part id is noop`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) // Must not throw mv.appendDelta("unknown", "delta") } + fun `test appendDelta for unknown part id does not repaint message or parent`() { + val parent = JPanel() + val mv = MessageView(msg("a1", "assistant"), openFile) + parent.add(mv) + val repaint = TrackingRepaintManager(setOf(parent, mv)) + val old = RepaintManager.currentManager(parent) + + try { + RepaintManager.setCurrentManager(repaint) + + assertFalse(mv.appendDelta("unknown", "delta")) + + assertTrue(repaint.dirty.isEmpty()) + assertTrue(repaint.invalid.isEmpty()) + } finally { + RepaintManager.setCurrentManager(old) + } + } + fun `test MessageView pre-populates parts from Message on creation`() { val message = msg("a1", "assistant") val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } message.parts["p1"] = text - val mv = MessageView(message) + val mv = MessageView(message, openFile) assertEquals(listOf("p1"), mv.partIds()) assertTrue(mv.part("p1") is TextView) @@ -162,11 +306,11 @@ class TurnViewTest : BasePlatformTestCase() { fun `test assistant card parts use shared compact gap`() { val message = msg("a1", "assistant") - val reasoning = Reasoning("r1") + val reasoning = reasoning("r1", "thinking") val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool - val mv = MessageView(message) + val mv = MessageView(message, openFile) mv.setSize(400, 200) mv.doLayout() @@ -178,7 +322,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test consecutive messages use shared compact gap`() { - val tv = TurnView("u1") + val tv = TurnView("u1", openFile) tv.addMessage(msg("u1", "user").also { msg -> msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } }) @@ -198,4 +342,36 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + + private fun reasoning(id: String, content: String) = Reasoning(id).also { + it.done = false + it.content.append(content) + } + + private fun text(id: String, content: String) = Text(id).also { it.content.append(content) } + + private fun aliasSize(view: MessageView) = mapSize(view, "aliases") + + private fun sourceSize(view: MessageView) = mapSize(view, "sources") + + private fun mapSize(view: MessageView, name: String): Int { + val field = MessageView::class.java.getDeclaredField(name) + field.isAccessible = true + return (field.get(view) as Map<*, *>).size + } + + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { + val dirty = mutableListOf() + val invalid = mutableListOf() + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c in watched) dirty.add(c) + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent in watched) invalid.add(invalidComponent) + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt new file mode 100644 index 00000000000..6fb2cde06f5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -0,0 +1,164 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.swing.Icon +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.border.Border + +@Suppress("UnstableApiUsage") +class AbstractSessionPartViewTest : BasePlatformTestCase() { + + fun `test collapsed by default`() { + val content = JLabel("body") + val view = TestView(content = content) + + assertFalse(view.isExpanded()) + assertNull(content.parent) + } + + fun `test expanded when requested`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true) + + assertTrue(view.isExpanded()) + assertSame(view, content.parent) + } + + fun `test toggle reuses content component`() { + val content = JLabel("body") + val view = TestView(content = content) + + view.syncExpandable(true) + view.toggle() + assertSame(view, content.parent) + view.toggle() + assertNull(content.parent) + view.toggle() + assertSame(view, content.parent) + } + + fun `test toggle uses right and down chevron icons`() { + val view = TestView(content = JLabel("body")) + + assertSame(SessionViewIcons.chevronCollapsed, view.arrowIcon()) + assertSame(SessionViewIcons.chevronRight, view.arrowIcon()) + val closed = view.arrowIcon() + + view.toggle() + + assertSame(SessionViewIcons.chevronExpanded, view.arrowIcon()) + assertSame(SessionViewIcons.chevronDown, view.arrowIcon()) + assertNotSame(closed, view.arrowIcon()) + assertEquals(closed.iconWidth, view.arrowIcon().iconWidth) + assertEquals(closed.iconHeight, view.arrowIcon().iconHeight) + } + + fun `test non expandable hides content`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true) + + view.syncExpandable(false) + + assertFalse(view.isExpanded()) + assertNull(content.parent) + } + + fun `test fixed non expandable ignores expansion`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true, expandable = false) + + assertFalse(view.isExpanded()) + assertFalse(view.arrowVisible()) + assertNull(content.parent) + + view.syncExpandable(true) + view.toggle() + + assertFalse(view.isExpanded()) + assertFalse(view.arrowVisible()) + assertNull(content.parent) + } + + fun `test header hover fill differs from outline colors`() { + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.hoverColor()) + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.brightColor()) + } + + fun `test primary card hover only changes header background`() { + val view = TestView(content = JLabel("body")) + val row = view.component(0) as JPanel + + assertEquals(0, paint(view.border).alpha) + view.expand() + + view.setHovered(true) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + assertLine(view.border) + view.setHovered(false) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + assertLine(view.border) + } + + private class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : + PrimarySessionPartView(JLabel("header"), content, expanded, expandable) { + + override val contentId = "test" + override fun update(content: Content) {} + fun arrowVisible() = arrow.isVisible + fun arrowIcon(): Icon = arrow.icon + } + + private fun TestView.component(index: Int): Component = components[index] + + private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED) + + private fun exit(component: Component) = event(component, MouseEvent.MOUSE_EXITED) + + private fun event(component: Component, id: Int) { + component.dispatchEvent(MouseEvent( + component, + id, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } + + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.color().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } + + private fun assertNotSameColor(left: Color, right: Color) { + assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 5312712fe83..8136916ef49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -24,6 +24,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { fun `test header and description text areas are in the component tree by default`() { edt { val panel = BaseQuestionView() + assertTrue("Root layout should be BorderLayout", panel.layout is BorderLayout) val areas = findAll(panel) assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2) } @@ -89,11 +90,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val top = JLabel("top") panel.setTopPanel(top) - val col = findCol(panel)!! - val comps = col.components.toList() + val north = region(panel, BorderLayout.NORTH) as Container + val comps = north.components.toList() val topIdx = comps.indexOf(top) - // header row is the JPanel containing the header text area - val headerRow = findAll(panel).firstOrNull { it.font.isBold }?.parent as? JPanel + val headerRow = headerRow(panel) val headerIdx = if (headerRow != null) comps.indexOf(headerRow) else comps.indexOfFirst { it is JPanel } assertTrue("top should appear before headerText row", topIdx >= 0 && topIdx < headerIdx) } @@ -131,6 +131,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val body = JLabel("body") panel.setContent(body) assertNotNull("body should be in the tree", find(panel, body)) + assertSame("body should be in root center", body, region(panel, BorderLayout.CENTER)) } } @@ -156,6 +157,18 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } + fun `test setContent adds header spacer in north stack`() { + edt { + val panel = BaseQuestionView() + panel.setContent(JLabel("body")) + + val north = region(panel, BorderLayout.NORTH) as Container + val filler = north.components.last() + assertEquals(UiStyle.Gap.lg(), filler.preferredSize.height) + assertEquals(0, filler.preferredSize.width) + } + } + // ------ setActions ------ fun `test setActions renders one button per action`() { @@ -165,12 +178,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() { BaseQuestionView.Action("a", "Cancel", primary = false) {}, BaseQuestionView.Action("b", "OK", primary = true) {}, )) - val btns = panel.actionButtonsForTest() + val btns = actionButtons(panel) assertEquals(2, btns.size) - assertNotNull(btns["a"]) - assertNotNull(btns["b"]) - assertEquals("Cancel", btns["a"]!!.text) - assertEquals("OK", btns["b"]!!.text) + assertNotNull(btns["Cancel"]) + assertNotNull(btns["OK"]) } } @@ -178,7 +189,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { edt { val panel = BaseQuestionView() panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) - val btn = panel.actionButtonsForTest()["ok"]!! + val btn = actionButton(panel, "OK") assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) } } @@ -187,7 +198,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { edt { val panel = BaseQuestionView() panel.setActions(listOf(BaseQuestionView.Action("cancel", "Cancel", primary = false) {})) - val btn = panel.actionButtonsForTest()["cancel"]!! + val btn = actionButton(panel, "Cancel") val key = btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY) assertTrue("Non-primary should not have default style key", key == null || key == false) } @@ -198,7 +209,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { var clicked = false val panel = BaseQuestionView() panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) { clicked = true })) - panel.actionButtonsForTest()["ok"]!!.doClick() + actionButton(panel, "OK").doClick() assertTrue("handler should have been invoked", clicked) } } @@ -208,9 +219,9 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val panel = BaseQuestionView() panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true, enabled = true) {})) panel.setActionEnabled("ok", false) - assertFalse(panel.actionButtonsForTest()["ok"]!!.isEnabled) + assertFalse(actionButton(panel, "OK").isEnabled) panel.setActionEnabled("ok", true) - assertTrue(panel.actionButtonsForTest()["ok"]!!.isEnabled) + assertTrue(actionButton(panel, "OK").isEnabled) } } @@ -219,7 +230,8 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val panel = BaseQuestionView() panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) panel.setActions(emptyList()) - assertTrue("actionButtonsForTest should be empty", panel.actionButtonsForTest().isEmpty()) + assertTrue("action buttons should be removed", actionButtons(panel).isEmpty()) + assertNull("footer should be removed when empty", region(panel, BorderLayout.SOUTH)) } } @@ -230,40 +242,112 @@ class BaseQuestionViewTest : BasePlatformTestCase() { BaseQuestionView.Action("a", "A", primary = false) {}, BaseQuestionView.Action("b", "B", primary = true) {}, )) - val btns = panel.actionButtonsForTest() - assertEquals(SessionUiStyle.View.surface(), btns["a"]!!.background) - assertEquals(SessionUiStyle.View.surface(), btns["b"]!!.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "A").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "B").background) } } - // ------ ordering ------ + // ------ structure ------ - fun `test content appears after description in col`() { + fun `test header row uses icon west and text stack center`() { edt { val panel = BaseQuestionView() - val body = JLabel("body") - panel.setContent(body) - val col = findCol(panel)!! - val comps = col.components.toList() - val descIdx = comps.indexOfFirst { it is JBTextArea && !(it).font.isBold } - val bodyIdx = comps.indexOf(body) - assertTrue("body should appear after description", descIdx < bodyIdx) + panel.setHeaderIcon(AllIcons.General.Warning) + val header = headerRow(panel)!! + val layout = header.layout as BorderLayout + val west = layout.getLayoutComponent(BorderLayout.WEST) + val center = layout.getLayoutComponent(BorderLayout.CENTER) as Container + assertTrue("icon should be the direct west component", west is JBLabel) + val icon = west as JBLabel + assertEquals("icon should be horizontally centered", JBLabel.CENTER, icon.horizontalAlignment) + assertEquals("icon should be vertically centered", JBLabel.CENTER, icon.verticalAlignment) + assertTrue("center should contain header and description text", findAll(center).size >= 2) } } - fun `test action footer appears after content`() { + fun `test header row has no west icon gap by default`() { edt { val panel = BaseQuestionView() - val body = JLabel("body") - panel.setContent(body) + val header = headerRow(panel)!! + val west = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) + assertNull("header should not reserve icon space when icon is absent", west) + } + } + + fun `test action footer is in south with buttons east`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) + val btn = actionButton(panel, "OK") + val footer = region(panel, BorderLayout.SOUTH) as JPanel + val row = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.EAST) as JPanel + assertNotNull("button should be in footer east row", find(row, btn)) + } + } + + fun `test action footer has top gap matching panel vertical padding`() { + edt { + val panel = BaseQuestionView() + panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) + + val footer = region(panel, BorderLayout.SOUTH) as JPanel + val ins = footer.border.getBorderInsets(footer) + assertEquals(UiStyle.Gap.lg(), ins.top) + } + } + + fun `test card top padding uses next spacing step`() { + edt { + val panel = BaseQuestionView() + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.pad(), ins.top) + assertEquals(UiStyle.Gap.pad(), ins.left) + assertEquals(UiStyle.Gap.lg(), ins.bottom) + assertEquals(UiStyle.Gap.pad(), ins.right) + } + } + + fun `test action left alone attaches footer west`() { + edt { + val panel = BaseQuestionView() + val left = JLabel("left") + panel.setActionLeft(left) + val footer = region(panel, BorderLayout.SOUTH) as JPanel + val west = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as Container + assertNotNull("action left should be in footer west", find(west, left)) + } + } + + fun `test action left component is transparent`() { + edt { + val panel = BaseQuestionView() + val left = JPanel() + panel.setActionLeft(left) + assertFalse("action left should be transparent", left.isOpaque) + } + } + + fun `test footer adds bottom padding gap after side actions`() { + edt { + val panel = BaseQuestionView() + panel.setActionLeft(JLabel("left")) panel.setActions(listOf(BaseQuestionView.Action("ok", "OK", primary = true) {})) - val col = findCol(panel)!! - val comps = col.components.toList() - val bodyIdx = comps.indexOf(body) - val btn = panel.actionButtonsForTest()["ok"]!! - // find the footer panel that contains the button - val footerIdx = comps.indexOfFirst { it is JPanel && find(it, btn) != null } - assertTrue("footer should appear after body", bodyIdx < footerIdx) + + val footer = region(panel, BorderLayout.SOUTH) as JPanel + val west = (footer.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as Container + val filler = west.components.toList().firstOrNull { it.preferredSize.width == UiStyle.Gap.pad() } + assertNotNull("side actions should include trailing gap", filler) + assertEquals(0, filler!!.preferredSize.height) + } + } + + fun `test setActionLeft null removes left-only footer`() { + edt { + val panel = BaseQuestionView() + panel.setActionLeft(JLabel("left")) + panel.setActionLeft(null) + assertNull("footer should be removed when action left is cleared", region(panel, BorderLayout.SOUTH)) } } @@ -300,9 +384,12 @@ class BaseQuestionViewTest : BasePlatformTestCase() { panel.setHeader("Title", "Hint") val style = SessionEditorStyle.current() panel.applyStyle(style) + val areas = findAll(panel) + val header = areas.first { it.text == "Title" } + val desc = areas.first { it.text == "Hint" } - assertEquals("headerText should use headerFont", style.headerFont, panel.headerFont()) - assertEquals("descriptionText should use hintFont", style.hintFont, panel.descriptionFont()) + assertEquals("headerText should use headerFont", style.headerFont, header.font) + assertEquals("descriptionText should use hintFont", style.hintFont, desc.font) } } @@ -312,9 +399,12 @@ class BaseQuestionViewTest : BasePlatformTestCase() { panel.setHeader("Title", "Hint") val style = SessionEditorStyle.create(family = "Courier New", size = 20) panel.applyStyle(style) + val areas = findAll(panel) + val header = areas.first { it.text == "Title" } + val desc = areas.first { it.text == "Hint" } - assertFalse("headerText should not use editor font family", panel.headerFont().name == "Courier New") - assertFalse("descriptionText should not use editor font family", panel.descriptionFont().name == "Courier New") + assertFalse("headerText should not use editor font family", header.font.name == "Courier New") + assertFalse("descriptionText should not use editor font family", desc.font.name == "Courier New") } } @@ -338,11 +428,11 @@ class BaseQuestionViewTest : BasePlatformTestCase() { return result as T } - private fun findCol(panel: BaseQuestionView): JPanel? { - for (child in panel.components) { - if (child is JPanel) return child - } - return null + private fun region(panel: BaseQuestionView, region: String) = (panel.layout as BorderLayout).getLayoutComponent(region) + + private fun headerRow(panel: BaseQuestionView): JPanel? { + val north = region(panel, BorderLayout.NORTH) as? Container ?: return null + return north.components.filterIsInstance().firstOrNull { it.layout is BorderLayout } } private fun find(root: Container, target: JComponent): JComponent? { @@ -368,6 +458,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() { return null } + private fun actionButton(panel: BaseQuestionView, text: String): JButton = actionButtons(panel)[text]!! + + private fun actionButtons(panel: BaseQuestionView): Map = findAll(panel).associateBy { it.text } + private inline fun findAll(root: Container): List = findAllCls(root, T::class.java) private fun findAllCls(root: Container, cls: Class): List { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt index 8882b3b0c47..6b52bef0020 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -4,11 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -409,7 +410,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = findAll(view) assertTrue( "Expected permission warning icon in header", - labels.any { it.icon == AllIcons.General.Warning }, + labels.any { it.icon == SessionViewIcons.warning }, ) } @@ -433,13 +434,13 @@ class PermissionViewTest : BasePlatformTestCase() { fun `test session question buttons use question surface background`() { view.show(permission()) - assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background) - assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.runButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.denyButtonForTest().background) } - // ------ code labels use editor style ------ + // ------ code labels use transcript style ------ - fun `test code label uses editor font family after applyStyle`() { + fun `test code label uses ui font family after applyStyle`() { view.show( Permission( id = "perm_codefont", @@ -455,7 +456,8 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = view.codeLabelsForTest() assertNotNull("Should have at least one code label for command", labels.firstOrNull()) - assertEquals("Code label font family should use editor family", "Courier New", labels[0].font.name) + assertEquals("Code label font family should use transcript family", style.transcriptFont.name, labels[0].font.name) + assertEquals(style.transcriptFont.size, labels[0].font.size) } fun `test permission header uses headerFont not editor font family`() { @@ -492,7 +494,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = view.codeLabelsForTest() assertFalse("Expected code labels", labels.isEmpty()) - assertEquals(SessionUiStyle.View.headerHover(), labels[0].background) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor(), labels[0].background) } private fun permission() = Permission( 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 new file mode 100644 index 00000000000..578988381d2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.session.views.todo + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PrimarySessionPartView +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TodoViewDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.awt.Color +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class TodoWriteViewTest : BasePlatformTestCase() { + + fun `test canRender only completed todowrite`() { + assertTrue(TodoWriteView.canRender(tool("todowrite", ToolExecState.COMPLETED))) + assertFalse(TodoWriteView.canRender(tool("todowrite", ToolExecState.PENDING))) + assertFalse(TodoWriteView.canRender(tool("todowrite", ToolExecState.RUNNING))) + assertFalse(TodoWriteView.canRender(tool("bash", ToolExecState.COMPLETED))) + } + + fun `test renders title subtitle and rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Done", "completed", "high"), + TodoDto("Next", "pending", "medium"), + ) + }) + val base: Any = view + + assertTrue(view.labelText().contains("To-dos")) + assertTrue(base is PrimarySessionPartView) + assertTrue(view.labelText().contains("1/2")) + assertTrue(view.isExpanded()) + assertEquals(2, view.rowCount()) + assertTrue(view.rowChecked(0)) + assertFalse(view.rowChecked(1)) + assertTrue(view.rowText(0).contains("Done")) + assertFalse(view.rowCheckboxOpaque(0)) + assertFalse(view.rowCheckboxOpaque(1)) + } + + fun `test pending rows keep normal foreground`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Done", "completed", "high"), + TodoDto("Next", "pending", "medium"), + ) + }) + val style = SessionEditorStyle.current().copy(editorForeground = Color(1, 2, 3)) + + view.applyStyle(style) + + assertEquals(style.editorForeground, view.rowForeground(1)) + } + + fun `test todo header title subtitle gap uses standard medium gap`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Next", "pending", "medium")) + }) + + assertEquals(UiStyle.Gap.md(), centerGap(view)) + } + + fun `test compact view renders hidden labels and visible rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Done", "completed", "high"), + TodoDto("Next", "pending", "medium"), + TodoDto("Later", "pending", "low"), + ) + it.todoView = TodoViewDto( + mode = "compact", + todos = listOf(TodoDto("Changed", "pending", "high", changed = true)), + hiddenBefore = 1, + hiddenAfter = 1, + changed = 1, + ) + }) + + assertTrue(view.labelText().contains("1/3")) + assertEquals(1, view.rowCount()) + assertTrue(view.rowText(0).contains("Changed")) + assertTrue(view.hiddenText().contains("earlier to-do hidden")) + assertTrue(view.hiddenText().contains("later to-do hidden")) + } + + fun `test update reuses root and updates rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Old", "pending", "medium")) + }) + val comps = view.components.toList() + + view.update(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("New", "completed", "high")) + }) + + assertEquals(comps, view.components.toList()) + assertTrue(view.labelText().contains("1/1")) + assertTrue(view.rowChecked(0)) + assertTrue(view.rowText(0).contains("New")) + } + + private fun centerGap(view: TodoWriteView): Int { + val row = view.components.filterIsInstance().first() + val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + return (center.layout as BorderLayout).hgap + } + + private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt new file mode 100644 index 00000000000..43df4520ac3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.cli.KiloCliParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class KiloCliParserTest { + @Test + fun `tag extracts trimmed tool xml value`() { + val text = """ + + /tmp/example.txt + + file + """.trimIndent() + + assertEquals("/tmp/example.txt", KiloCliParser.tag(text, "path")) + assertEquals("file", KiloCliParser.tag(text, "type")) + } + + @Test + fun `tag returns null for blank or missing value`() { + assertNull(KiloCliParser.tag(" ", "path")) + assertNull(KiloCliParser.tag("file", "path")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt index 68bc1de2a0a..cbc6147e52d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.settings.models.ModelsConfigurable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.SearchableConfigurable @@ -22,6 +23,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { assertEquals("ai.kilocode.jetbrains.settings.profile", UserProfileConfigurable.ID) } + fun `test child models id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.models", ModelsConfigurable.ID) + } + fun `test root implements SearchableConfigurable but not Parent`() { // Root should be SearchableConfigurable so it can be found by ID, // but NOT SearchableConfigurable.Parent to avoid duplicating XML-registered child configurables. @@ -58,6 +63,24 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { } } + fun `test createComponent contains Models link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val links = links(panel as Container) + assertTrue("expected a link labeled 'Models'", links.any { it.text == "Models" }) + } + } + + fun `test profile link appears before models link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val labels = links(panel as Container).map { it.text } + assertTrue("User Profile should appear before Models", labels.indexOf("User Profile") < labels.indexOf("Models")) + } + } + fun `test open invokes select with child found by id`() { // Verify that open() uses the correct ID constant to navigate val cfg = KiloSettingsConfigurable() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt new file mode 100644 index 00000000000..5e2fa618a9b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.models.ModelsConfigurable +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.util.PropertiesComponent +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class KiloSettingsSelectionTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + PropertiesComponent.getInstance(project).unsetValue(KiloSettingsSelection.SELECTED_CONFIGURABLE_KEY) + } finally { + super.tearDown() + } + } + + fun `test falls back to profile when no last settings page exists`() { + assertEquals(UserProfileConfigurable.ID, KiloSettingsSelection.target(project)) + } + + fun `test falls back to profile when last page is not kilo`() { + select("preferences.lookFeel") + + assertEquals(UserProfileConfigurable.ID, KiloSettingsSelection.target(project)) + } + + fun `test keeps last kilo root page`() { + select(KiloSettingsConfigurable.ID) + + assertEquals(KiloSettingsConfigurable.ID, KiloSettingsSelection.target(project)) + } + + fun `test keeps last kilo child page`() { + select(ModelsConfigurable.ID) + + assertEquals(ModelsConfigurable.ID, KiloSettingsSelection.target(project)) + } + + private fun select(id: String) { + PropertiesComponent.getInstance(project).setValue(KiloSettingsSelection.SELECTED_CONFIGURABLE_KEY, id) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt index 36f3e36008e..8860e182326 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.settings -import ai.kilocode.client.settings.profile.QrCode +import ai.kilocode.client.settings.auth.QrCode import java.awt.Color import kotlin.test.Test import kotlin.test.assertEquals diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt index ad553ea603a..37a25b01eca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -161,6 +161,14 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { panel.setSize(800, 600) layout(panel) + val logo = labelsByName(panel, "kilo.profile.logo.loggedIn").single() + val name = labels(panel).first { it.text == "Alice" } + val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel) + val nameLoc = SwingUtilities.convertPoint(name.parent, name.location, panel) + assertNotNull(logo.icon) + assertTrue(logo.icon.iconWidth > 0) + assertTrue(logoLoc.x > nameLoc.x) + val refresh = buttons(panel).first { it.text == "Refresh" } assertFalse(refresh.isContentAreaFilled) val card = refresh.parent @@ -208,6 +216,28 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { } } + fun `test logged out profile shows kilo icon above login content`() { + edt { + panel.update(null, KiloAppStatusDto.READY) + panel.setSize(800, 600) + layout(panel) + + val logo = labelsByName(panel, "kilo.profile.logo.loggedOut").single() + val label = labels(panel).first { it.text == "Not logged in" } + val btn = buttons(panel).first { it.text == "Login with Kilo Code" } + val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel) + val labelLoc = SwingUtilities.convertPoint(label.parent, label.location, panel) + val btnLoc = SwingUtilities.convertPoint(btn.parent, btn.location, panel) + + assertTrue(visible(logo)) + assertNotNull(logo.icon) + assertTrue(logo.icon.iconWidth > 0) + assertTrue(logo.icon.iconHeight > 0) + assertTrue(logoLoc.y < labelLoc.y) + assertTrue(labelLoc.y < btnLoc.y) + } + } + fun `test account update retains name label`() { val alice = ProfileDto(email = "alice@test.com", name = "Alice") val bob = ProfileDto(email = "bob@test.com", name = "Bob") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt new file mode 100644 index 00000000000..4966673eb57 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt @@ -0,0 +1,253 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import java.awt.Container +import javax.swing.AbstractButton +import javax.swing.JLabel +import javax.swing.text.JTextComponent + +class BaseSettingsUiTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var appScope: CoroutineScope + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private var panel: FakePanel? = null + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + appScope = CoroutineScope(SupervisorJob()) + app = KiloAppService(appScope, FakeAppRpcApi()) + workspaces = KiloWorkspaceService(appScope, FakeWorkspaceRpcApi()) + } + + override fun tearDown() { + try { + val view = panel + if (view != null) edt { view.dispose() } + panel = null + scope.cancel() + appScope.cancel() + } finally { + super.tearDown() + } + } + + fun `test modified and reset use baseline`() { + val view = create() + + edt { + view.edit("new") + assertTrue(view.modified()) + view.resetDraft() + assertEquals("old", view.value()) + assertFalse(view.modified()) + } + } + + fun `test pending save target is not modified`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + assertFalse(view.modified()) + view.edit("other") + assertTrue(view.modified()) + } + } + + fun `test failed save keeps draft dirty and shows error`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.fail() + } + flush() + + edt { + assertEquals("new", view.value()) + assertTrue(view.modified()) + assertTrue(text(view.progress).contains("Failed")) + } + } + + fun `test edit clears save error`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.fail() + } + flush() + edt { view.edit("other") } + flush() + + edt { assertFalse(text(view.progress).contains("Failed")) } + } + + fun `test successful save preserves concurrent edit`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.edit("other") + view.succeed("new") + } + flush() + + edt { + assertEquals("other", view.value()) + assertTrue(view.modified()) + } + } + + fun `test failed save after dispose calls failure hook`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.dispose() + view.fail() + } + panel = null + flush() + + assertEquals(1, view.disposedFailures) + } + + fun `test login banner can be shown`() { + val view = create() + + edt { view.banner(true) } + + edt { assertTrue(text(view).contains("Sign in to Kilo Code")) } + } + + fun `test login banner can be disabled`() { + val view = create(login = false) + + edt { view.banner(true) } + + edt { assertFalse(text(view).contains("Sign in to Kilo Code")) } + } + + private fun create(login: Boolean = true): FakePanel { + val view = edt { FakePanel(scope, app, workspaces, login) } + panel = view + return view + } + + private fun flush() = runBlocking { + edt { UIUtil.dispatchAllInvocationEvents() } + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } + + private data class Draft(val value: String) + private data class Change(val value: String) + + private class FakeContent : BaseContentPanel() + + private class FakePanel( + cs: CoroutineScope, + app: KiloAppService, + workspaces: KiloWorkspaceService, + login: Boolean, + ) : BaseSettingsUi(cs, Draft("old"), app, workspaces, loginBanner = login) { + private val callbacks = mutableListOf<(Draft?) -> Unit>() + var disposedFailures = 0 + private set + + init { + startSettings(FakeContent()) + } + + fun edit(value: String) = updateDraft { copy(value = value) } + + fun value(): String = draft.value + + fun succeed(value: String) = callbacks.removeAt(0)(Draft(value)) + + fun fail() = callbacks.removeAt(0)(null) + + fun banner(login: Boolean) = syncLoginBanner(login) { top.hideBanner() } + + override fun change(from: Draft, to: Draft): Change? = if (from == to) null else Change(to.value) + + override fun save(change: Change, done: (Draft?) -> Unit) { + callbacks += done + } + + override fun base(result: Draft): Draft = result + + override fun draft(state: KiloAppStateDto): Draft = draft + + override suspend fun loadWorkspace(root: String) = Unit + + override fun applyWorkspace(result: Unit) = Unit + + override fun syncContent() { + val err = saveError + if (saving) { + showProgress(pendingText()) + return + } + if (err != null) { + showError(err) + return + } + clearProgress() + } + + override fun pendingText(): String = "Saving" + + override fun failedText(): String = "Failed" + + override fun onSaveFailedAfterDispose(change: Change) { + disposedFailures++ + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiWorkspaceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiWorkspaceTest.kt new file mode 100644 index 00000000000..c1a7070266a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiWorkspaceTest.kt @@ -0,0 +1,191 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelSelectionDto +import ai.kilocode.rpc.dto.ModelStateDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +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 + +class BaseSettingsUiWorkspaceTest : BasePlatformTestCase() { + private lateinit var appScope: CoroutineScope + private lateinit var uiScope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private var panel: FakePanel? = null + + override fun setUp() { + super.setUp() + appScope = CoroutineScope(SupervisorJob()) + uiScope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + workspaceRpc = FakeWorkspaceRpcApi() + app = KiloAppService(appScope, rpc) + workspaces = KiloWorkspaceService(appScope, workspaceRpc) + } + + override fun tearDown() { + try { + val view = panel + if (view != null) edt { view.dispose() } + panel = null + uiScope.cancel() + appScope.cancel() + } finally { + super.tearDown() + } + } + + fun `test startup accepts ready app state and loads resolved workspace`() { + rpc.state.value = state("new") + workspaceRpc.directory = "/resolved" + val view = create("/hint") + + flushUntil { edt { view.value() == "new" && view.roots == listOf("/resolved") && view.loaded() } } + + edt { + assertEquals("new", view.value()) + assertEquals("/resolved", view.dir()) + assertTrue(view.loaded()) + assertFalse(view.loading()) + assertFalse(view.loadOnEdt) + } + } + + fun `test non ready app state calls unavailable hook`() { + rpc.state.value = state("ready") + val view = create("/test") + flushUntil { edt { view.value() == "ready" } } + val before = edt { view.unavailable } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + flushUntil { edt { view.unavailable > before } } + + edt { assertFalse(view.loading()) } + } + + fun `test model state updates are delivered on edt`() { + rpc.models = ModelStateDto(favorite = listOf(ModelSelectionDto("kilo", "new"))) + rpc.state.value = state("ready") + val view = create("/test") + + flushUntil { edt { view.favoriteCount == 1 } } + + edt { + assertEquals(1, view.favoriteCount) + assertTrue(view.modelsOnEdt) + } + } + + private fun create(hint: String): FakePanel { + val view = edt { FakePanel(uiScope, app, workspaces, hint) } + panel = view + return view + } + + private fun state(model: String) = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = model), + ) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(20) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private data class Draft(val value: String) + private data class Change(val value: String) + + private class FakeContent : BaseContentPanel() + + private class FakePanel( + cs: CoroutineScope, + app: KiloAppService, + workspaces: KiloWorkspaceService, + hint: String, + ) : BaseSettingsUi( + cs, + Draft("old"), + app, + workspaces, + hint, + ) { + val roots = mutableListOf() + var unavailable = 0 + private set + var favoriteCount = 0 + private set + var loadOnEdt = true + private set + var modelsOnEdt = false + private set + + init { + startSettings(FakeContent()) + } + + fun value(): String = draft.value + + fun dir(): String? = projectDirectory + + fun loading(): Boolean = workspaceLoading + + fun loaded(): Boolean = workspaceLoaded + + override fun change(from: Draft, to: Draft): Change? = if (from == to) null else Change(to.value) + + override fun save(change: Change, done: (Draft?) -> Unit) = done(Draft(change.value)) + + override fun base(result: Draft): Draft = result + + override fun draft(state: KiloAppStateDto): Draft = Draft(state.config?.model ?: "none") + + override suspend fun loadWorkspace(root: String): String { + loadOnEdt = ApplicationManager.getApplication().isDispatchThread + roots += root + return root + } + + override fun applyWorkspace(result: String) = Unit + + override fun unavailable(state: KiloAppStateDto) { + unavailable++ + } + + override fun models(state: ModelStateDto) { + favoriteCount = state.favorite.size + modelsOnEdt = ApplicationManager.getApplication().isDispatchThread + } + + override fun syncContent() = Unit + + override fun pendingText(): String = "Saving" + + override fun failedText(): String = "Failed" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt new file mode 100644 index 00000000000..a5b188afd54 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt @@ -0,0 +1,284 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +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.Container +import javax.swing.AbstractButton +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.text.JTextComponent + +class KiloReadyConfigurableTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var app: KiloAppService + private var cfg: FakeConfigurable? = null + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + app = KiloAppService(scope, rpc) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) + } + + override fun tearDown() { + try { + cfg?.disposeUIResources() + cfg = null + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test disconnected shows unavailable and does not create ready component`() { + val root = edt { create().createComponent() } + flushUntil { rpc.connected } + + edt { + val text = text(root) + assertTrue(text, text.contains(KiloBundle.message("settings.cli.unavailable.title"))) + assertTrue(text, text.contains(KiloBundle.message("settings.cli.unavailable.message"))) + assertEquals(0, cfg?.created) + } + } + + fun `test ready transition creates ready component once`() { + val root = edt { create().createComponent() } + flushUntil { rpc.connected } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { cfg?.created == 1 } } + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + flush() + + edt { + assertEquals(1, cfg?.created) + assertTrue(text(root).contains("Ready content")) + assertFalse(text(root).contains(KiloBundle.message("settings.cli.unavailable.title"))) + } + } + + fun `test actions are safe before ready and delegate after ready`() { + val view = create() + edt { view.createComponent() } + + edt { + assertFalse(view.isModified) + view.apply() + view.reset() + assertEquals(0, view.applied) + assertEquals(0, view.reset) + } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { view.created == 1 } } + + edt { + view.modified = true + assertTrue(view.isModified) + view.apply() + view.reset() + assertEquals(1, view.applied) + assertEquals(1, view.reset) + } + } + + fun `test focus request is delegated after ready`() { + val view = create() + edt { + view.createComponent() + view.focusOn("account") + assertEquals(listOf("account"), view.focuses) + assertNull(view.preferredFocusedComponent) + } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { view.created == 1 } } + + edt { assertSame(view.field, view.preferredFocusedComponent) } + } + + fun `test dispose cancels scope and disposes ready UI on edt`() { + val view = create() + edt { view.createComponent() } + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { view.created == 1 } } + + edt { view.disposeUIResources() } + cfg = null + + assertEquals(1, view.disposed) + assertTrue(view.disposedOnEdt) + } + + fun `test ready settings overlay is hosted by outer shell`() { + val view = create(overlay = true) + val root = edt { view.createComponent() as SettingsPanel } + flushUntil { rpc.connected } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { view.readyPanel != null } } + + edt { + val ready = requireNotNull(view.readyPanel) + ready.showProgress("Authorizing provider") + + assertTrue(text(root).contains("Authorizing provider")) + assertTrue(root.progress.isVisible) + assertFalse(ready.progress.isVisible) + assertTrue(root.overlay.components.any { it === root.progress }) + assertFalse(ready.overlay.components.any { it === root.progress }) + } + + edt { view.disposeUIResources() } + cfg = null + + edt { assertFalse(root.progress.isVisible) } + } + + fun `test no scroll shell hosts ready component directly`() { + val view = create(scroll = false) + val root = edt { view.createComponent() as SettingsOverlayPanel } + flushUntil { rpc.connected } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { view.created == 1 } } + + edt { + assertFalse(root is SettingsPanel) + assertTrue(components(root).filterIsInstance().isEmpty()) + assertSame(view.ready, (root.content.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER)) + assertTrue(text(root).contains("Ready content")) + } + } + + private fun create(overlay: Boolean = false, scroll: Boolean = true): FakeConfigurable { + val view = FakeConfigurable(overlay, scroll) + cfg = view + return view + } + + private fun flush() = runBlocking { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(20) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } + + private class FakeConfigurable( + private val overlay: Boolean = false, + private val scroll: Boolean = true, + ) : KiloReadyConfigurable() { + val field = JPanel() + val focuses = mutableListOf() + var ready: JComponent? = null + private set + var readyPanel: SettingsPanel? = null + private set + var created = 0 + private set + var disposed = 0 + private set + var disposedOnEdt = false + private set + var modified = false + var applied = 0 + private set + var reset = 0 + private set + + override fun getId(): String = "test.ready" + + override fun getDisplayName(): String = "Ready" + + override fun createReadyComponent(cs: CoroutineScope): JComponent { + created++ + if (overlay) { + val panel = SettingsPanel() + panel.setContent(JPanel().apply { add(JLabel("Ready content")) }) + readyPanel = panel + ready = panel + return panel + } + val panel = JPanel().apply { add(JLabel("Ready content")) } + ready = panel + return panel + } + + override fun scrollReadyShell(): Boolean = scroll + + override fun isModifiedReady(): Boolean = modified + + override fun applyReady() { + applied++ + } + + override fun resetReady() { + reset++ + } + + override fun preferredReady(): JComponent? = if (created > 0) field else null + + override fun focusReady(label: String) { + focuses += label + } + + override fun disposeReadyComponent(component: JComponent) { + disposed++ + disposedOnEdt = ApplicationManager.getApplication().isDispatchThread + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt new file mode 100644 index 00000000000..8267e793990 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt @@ -0,0 +1,294 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.ui.UiStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.SeparatorComponent +import com.intellij.ui.components.JBLabel +import java.awt.Color +import java.awt.Container +import java.awt.Rectangle +import java.awt.image.BufferedImage +import javax.swing.AbstractButton +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JScrollPane +import javax.swing.ScrollPaneConstants +import javax.swing.Scrollable +import javax.swing.text.JTextComponent + +class SettingsRowsTest : BasePlatformTestCase() { + + fun `test rows do not insert separators`() { + val rows = SettingsRows() + + rows.row(SettingsRow("One", value = JButton("A"))) + rows.row(SettingsRow("Two", value = JButton("B"))) + + assertEquals(2, rows.componentCount) + assertTrue(components(rows).none { it is SeparatorComponent }) + } + + fun `test keyed update preserves row and value`() { + val rows = SettingsRows() + val value = JButton("A") + val row = rows.row("one", SettingsRow("One", "Before", value)) + + val updated = rows.update("one", "Updated", "After", value) + + assertSame(row, updated) + assertSame(value, components(row).first { it === value }) + assertTrue(text(row).contains("Updated")) + assertTrue(text(row).contains("After")) + } + + fun `test keyed update can clear value`() { + val rows = SettingsRows() + val value = JButton("A") + val row = rows.row("one", SettingsRow("One", value = value)) + + rows.update("one", "One") + + assertFalse(components(row).any { it === value }) + } + + fun `test row value centers vertically`() { + val value = JButton("Choose a model") + val row = SettingsRow( + "Default model", + "This description is intentionally long enough to wrap instead of pushing the value off screen.", + value, + ) + row.setSize(row.preferredSize.width, row.preferredSize.height) + + layout(row) + + assertEquals((value.parent.height - value.height) / 2, value.y) + } + + fun `test row description uses escaped wrapping html`() { + val row = SettingsRow("Mode", "Use & safe models") + + val label = components(row) + .filterIsInstance() + .single { it.text.contains("fast") } + + assertTrue(label.text.startsWith("")) + assertTrue(label.text.contains("<fast>")) + assertTrue(label.text.contains("&")) + } + + fun `test removing keyed row removes only that row`() { + val rows = SettingsRows() + val one = rows.row("one", SettingsRow("One", value = JButton("A"))) + val two = rows.row("two", SettingsRow("Two", value = JButton("B"))) + + assertSame(one, rows.remove("one")) + + assertEquals(1, rows.componentCount) + assertSame(two, rows.getComponent(0)) + } + + fun `test retain keeps requested keyed rows`() { + val rows = SettingsRows() + val one = rows.row("one", SettingsRow("One", value = JButton("A"))) + rows.row("two", SettingsRow("Two", value = JButton("B"))) + + rows.retain(setOf("one")) + + assertEquals(1, rows.componentCount) + assertSame(one, rows.getComponent(0)) + } + + fun `test top banner renders login action`() { + val top = SettingsTop() + + top.showNotLoggedIn {} + + assertTrue(text(top).contains("Sign in to Kilo Code")) + assertTrue(top.isVisible) + } + + fun `test settings panel keeps banner in scroll content and progress in overlay`() { + val panel = SettingsPanel() + + panel.top.showNotLoggedIn {} + panel.showProgress("Loading models...") + + assertTrue(text(panel.content).contains("Sign in to Kilo Code")) + assertFalse(text(panel.overlay).contains("Sign in to Kilo Code")) + assertTrue(panel.overlay.components.any { it === panel.progress }) + assertTrue(text(panel.progress).contains("Loading models...")) + val scroll = components(panel.content).filterIsInstance().single() + assertFalse(components(scroll.viewport.view as JComponent).any { it === panel.progress }) + } + + fun `test settings panel tracks viewport width without horizontal scroll`() { + val panel = SettingsPanel() + + val scroll = components(panel).filterIsInstance().single() + val view = scroll.viewport.view + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, scroll.horizontalScrollBarPolicy) + assertTrue(view is Scrollable) + assertTrue((view as Scrollable).getScrollableTracksViewportWidth()) + } + + fun `test settings progress overlay is centered near top`() { + val panel = SettingsPanel().apply { setSize(400, 300) } + + panel.showProgress("Loading") + panel.doLayout() + + val size = panel.progress.preferredSize + assertEquals( + Rectangle((400 - size.width) / 2, UiStyle.Gap.pad(), size.width, size.height), + panel.progress.bounds, + ) + assertTrue(panel.overlay.contains(panel.progress.x + 1, panel.progress.y + 1)) + assertFalse(panel.overlay.contains(1, 1)) + } + + fun `test settings progress overlay retains label across updates`() { + val panel = SettingsPanel() + + panel.showProgress("Loading") + val label = components(panel.progress).filterIsInstance().single { it.text == "Loading" } + + panel.showProgress("Saving") + + assertSame(label, components(panel.progress).filterIsInstance().single { it.text == "Saving" }) + + panel.clearProgress() + assertFalse(panel.progress.isVisible) + } + + fun `test settings progress overlay retains cancel button across progress updates`() { + val panel = SettingsPanel() + var calls = 0 + + panel.showProgress("Starting", "Cancel") { calls++ } + val label = components(panel.progress).filterIsInstance().single { it.text == "Starting" } + val button = components(panel.progress).filterIsInstance().single { it.text == "Cancel" } + + panel.updateProgress("Waiting") + button.doClick() + + assertSame(label, components(panel.progress).filterIsInstance().single { it.text == "Waiting" }) + assertSame(button, components(panel.progress).filterIsInstance().single { it.text == "Cancel" }) + assertEquals(1, calls) + } + + fun `test settings progress overlay clears cancel action for text only states`() { + val panel = SettingsPanel() + var calls = 0 + + panel.showProgress("Starting", "Cancel") { calls++ } + val button = components(panel.progress).filterIsInstance().single { it.text == "Cancel" } + panel.showProgress("Loading") + + assertFalse(button.isVisible) + button.doClick() + assertEquals(0, calls) + + panel.showProgress("Starting", "Cancel") { calls++ } + panel.showError("Failed") + assertFalse(button.isVisible) + button.doClick() + assertEquals(0, calls) + + panel.showProgress("Starting", "Cancel") { calls++ } + panel.clearProgress() + assertFalse(button.isVisible) + button.doClick() + assertEquals(0, calls) + } + + fun `test settings progress overlay uses information colors`() { + val panel = SettingsPanel() + + panel.showProgress("Loading") + + val label = components(panel.progress).filterIsInstance().single { it.text == "Loading" } + assertEquals(UiStyle.Colors.infoOverlayBackground(), panel.progress.background) + assertEquals(UiStyle.Colors.infoOverlayForeground(), panel.progress.foreground) + assertEquals(UiStyle.Colors.infoOverlayForeground(), label.foreground) + } + + fun `test settings progress overlay paints styled background`() { + val panel = SettingsPanel() + + panel.showProgress("Saving") + panel.progress.setSize(panel.progress.preferredSize) + panel.progress.doLayout() + + assertEquals(UiStyle.Colors.infoOverlayBackground().rgb, paint(panel.progress, UiStyle.Gap.lg(), panel.progress.height / 2).rgb) + assertNotSameColor(UiStyle.Colors.bg(), panel.progress.background) + } + + fun `test settings progress overlay can show error`() { + val panel = SettingsPanel() + + panel.showError("Failed to save model settings") + + val label = components(panel.progress).filterIsInstance().single { it.text == "Failed to save model settings" } + assertTrue(panel.progress.isVisible) + assertEquals(UiStyle.Colors.errorOverlayBackground(), panel.progress.background) + assertEquals(UiStyle.Colors.errorOverlayForeground(), panel.progress.foreground) + assertEquals(UiStyle.Colors.errorOverlayForeground(), label.foreground) + } + + fun `test settings progress overlay switches error back to info`() { + val panel = SettingsPanel() + + panel.showError("Failed") + val label = components(panel.progress).filterIsInstance().single { it.text == "Failed" } + panel.showProgress("Saving") + + assertSame(label, components(panel.progress).filterIsInstance().single { it.text == "Saving" }) + assertEquals(UiStyle.Colors.infoOverlayBackground(), panel.progress.background) + assertEquals(UiStyle.Colors.infoOverlayForeground(), panel.progress.foreground) + assertEquals(UiStyle.Colors.infoOverlayForeground(), label.foreground) + } + + private fun paint(component: JComponent, x: Int, y: Int): Color { + val image = BufferedImage(component.width, component.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + component.paint(g) + } finally { + g.dispose() + } + return Color(image.getRGB(x, y), true) + } + + private fun assertNotSameColor(a: Color, b: Color) { + assertFalse("Expected colors to differ: $a", a.rgb == b.rgb) + } + + private fun layout(component: JComponent) { + component.doLayout() + components(component).filterIsInstance().forEach { it.doLayout() } + } + + private fun text(component: JComponent): String = components(component) + .mapNotNull { + when (it) { + is JLabel -> it.text + is AbstractButton -> it.text + is JTextComponent -> it.text + else -> null + } + } + .joinToString("\n") + + private fun components(component: JComponent): List { + val out = mutableListOf() + fun visit(c: java.awt.Component) { + out += c + if (c is Container) c.components.forEach { visit(it) } + } + visit(component) + return out + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt new file mode 100644 index 00000000000..ac3fcaedd8f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt @@ -0,0 +1,19 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.session.ui.model.ModelPicker +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class ModelSettingPickerTest : BasePlatformTestCase() { + + fun `test picker re-enables after ready state follows disabled state`() { + val picker = ModelSettingPicker() + val items = listOf(ModelPicker.Item("auto", "Auto", "kilo", "Kilo")) + + picker.setItems(emptyList(), null) + picker.isEnabled = false + picker.setItems(items, null) + picker.isEnabled = true + + assertTrue(picker.picker.isEnabled) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt new file mode 100644 index 00000000000..7f1da145943 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt @@ -0,0 +1,152 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ProvidersDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ModelsSettingsStateTest { + + @Test + fun `default model patch set and clear`() { + val from = ModelsDraft(model = null) + val set = ModelsDraft(model = "kilo/gpt-5") + assertEquals("kilo/gpt-5", patch(from, set).values["model"]) + assertNull(patch(set, from).values["model"]) + } + + @Test + fun `subagent clear clears variant`() { + val from = ModelsDraft(subagent = "kilo/gpt-5", variant = "high") + val to = ModelsDraft(subagent = null, variant = null) + val patch = patch(from, to) + assertNull(patch.values["subagent_model"]) + assertNull(patch.values["subagent_variant"]) + } + + @Test + fun `per-mode patch set and clear`() { + val from = ModelsDraft(agents = mapOf("ask" to null)) + val set = ModelsDraft(agents = mapOf("ask" to "openai/gpt")) + assertEquals("openai/gpt", patch(from, set).agents["ask"]?.model) + assertNull(patch(set, from).agents["ask"]?.model) + } + + @Test + fun `draft reads config agent values`() { + val agents = listOf(AgentDto(name = "ask", displayName = "Ask", mode = "ask")) + val config = ConfigDto( + model = "kilo/gpt-5", + smallModel = "kilo/auto-small", + subagentModel = "openai/gpt", + subagentVariant = "high", + agent = mapOf("ask" to AgentConfigDto(model = "kilo/gpt-5")), + ) + val draft = modelsDraft(config, agents) + assertEquals("kilo/gpt-5", draft.model) + assertEquals("kilo/auto-small", draft.small) + assertEquals("openai/gpt", draft.subagent) + assertEquals("high", draft.variant) + assertEquals("kilo/gpt-5", draft.agents["ask"]) + } + + @Test + fun `models status enables after providers load`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = ProvidersDto(emptyList(), emptyList(), emptyMap()), + items = 1, + errors = emptyList(), + saving = false, + ) + + assertEquals(ModelsStatus.READY, status) + } + + @Test + fun `models status allows default settings when agents fail`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = ProvidersDto(emptyList(), emptyList(), emptyMap()), + items = 1, + errors = listOf(LoadErrorDto(resource = "agents", detail = "boom")), + saving = false, + ) + + assertEquals(ModelsStatus.MODES_FAILED, status) + } + + @Test + fun `models status reports provider fetch failure`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = null, + items = 0, + errors = listOf(LoadErrorDto(resource = "providers", detail = "boom")), + saving = false, + ) + + assertEquals(ModelsStatus.LOAD_FAILED, status) + } + + @Test + fun `models status reports missing providers after loading completes`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = null, + items = 0, + errors = emptyList(), + saving = false, + ) + + assertEquals(ModelsStatus.LOAD_FAILED, status) + } + + @Test + fun `models status reports app unavailable`() { + val status = modelsStatus( + ready = false, + loading = false, + providers = null, + items = 0, + errors = emptyList(), + saving = false, + ) + + assertEquals(ModelsStatus.UNAVAILABLE, status) + } + + @Test + fun `login banner shows only when ready and unauthenticated`() { + assertTrue(modelsLoginBannerVisible(ready = true, authenticated = false)) + assertFalse(modelsLoginBannerVisible(ready = true, authenticated = true)) + assertFalse(modelsLoginBannerVisible(ready = false, authenticated = false)) + } + + @Test + fun `saved match requires saved top level values`() { + val draft = ModelsDraft(model = "kilo/gpt-5", small = "kilo/auto-small") + + assertTrue(savedMatches(draft, draft)) + assertFalse(savedMatches(draft.copy(model = "openai/gpt"), draft)) + } + + @Test + fun `saved match compares known pending agent values`() { + val draft = ModelsDraft(agents = mapOf("ask" to "kilo/gpt-5", "code" to null)) + val base = ModelsDraft(agents = mapOf("ask" to "kilo/gpt-5", "code" to null, "plan" to "openai/gpt")) + + assertTrue(savedMatches(base, draft)) + assertFalse(savedMatches(base.copy(agents = base.agents + ("ask" to "openai/gpt")), draft)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt new file mode 100644 index 00000000000..d2decf1199c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt @@ -0,0 +1,385 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.InlineBanner +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.Container +import javax.swing.AbstractButton +import javax.swing.JLabel +import javax.swing.JTextField +import javax.swing.text.JTextComponent + +class ModelsSettingsUiTest : BasePlatformTestCase() { + private lateinit var appScope: CoroutineScope + private lateinit var uiScope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private var ui: ModelsSettingsUi? = null + + override fun setUp() { + super.setUp() + appScope = CoroutineScope(SupervisorJob()) + uiScope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + workspaceRpc = FakeWorkspaceRpcApi() + app = KiloAppService(appScope, rpc) + workspaces = KiloWorkspaceService(appScope, workspaceRpc) + val state = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/old"), + profile = ProfileDto(email = "alice@test.com"), + ) + rpc.state.value = state + app._state.value = state + workspaceRpc.models = ModelsWorkspaceDto(providers = providers()) + edt { ui = ModelsSettingsUi(uiScope, app, workspaces, directory = "/test") } + flushUntil { text(requireUi()).contains("Old") } + } + + override fun tearDown() { + try { + val panel = ui + if (panel != null) edt { panel.dispose() } + ui = null + uiScope.cancel() + appScope.cancel() + } finally { + super.tearDown() + } + } + + fun `test failed apply stays visible while panel open`() { + val panel = requireUi() + rpc.configUpdateError = RuntimeException("save failed") + + edt { + select(panel, "new") + panel.applyDraft() + } + + flushUntil { text(panel).contains("Failed to save model settings") } + edt { + assertTrue(text(panel.progress).contains("Failed to save model settings")) + assertTrue(panel.modified()) + } + } + + fun `test edit clears save error`() { + val panel = requireUi() + rpc.configUpdateError = RuntimeException("save failed") + edt { + select(panel, "new") + panel.applyDraft() + } + flushUntil { text(panel).contains("Failed to save model settings") } + + edt { select(panel, "old") } + + flushUntil { !text(panel).contains("Failed to save model settings") } + } + + fun `test reset during pending save keeps applied selection visible`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + panel.resetDraft() + assertTrue(text(panel.progress).contains("Saving model settings")) + assertFalse(panel.modified()) + assertSelected(panel, "kilo/new") + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { assertSelected(panel, "kilo/new") } + } + + fun `test pickers are disabled during pending save`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + assertTrue(pickers(panel).isNotEmpty()) + assertTrue(pickers(panel).all { !it.isEnabled }) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + } + + fun `test matching app state before save callback keeps pending target`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + } + rpc.state.value = state("kilo/new") + flushUntil { edt { !panel.modified() && pickers(panel).all { !it.isEnabled } } } + edt { + panel.resetDraft() + assertSelected(panel, "kilo/new") + assertTrue(text(panel.progress).contains("Saving model settings")) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { assertSelected(panel, "kilo/new") } + } + + fun `test matching app state after save callback preserves dirty draft`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + select(panel, "old") + } + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + rpc.state.value = state("kilo/new") + flushUntil { edt { panel.modified() } } + + edt { + assertSelected(panel, "kilo/old") + assertTrue(panel.modified()) + } + } + + fun `test stale app state during pending save is ignored`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + } + rpc.state.value = state("kilo/old") + flushUntil { edt { text(panel.progress).contains("Saving model settings") } } + + edt { + assertSelected(panel, "kilo/new") + assertFalse(panel.modified()) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + } + + fun `test pickers stay enabled while models load`() { + edt { + requireUi().dispose() + ui = null + } + uiScope = CoroutineScope(SupervisorJob()) + workspaceRpc.modelsGate = CompletableDeferred() + workspaceRpc.models = ModelsWorkspaceDto(providers = providers()) + + edt { ui = ModelsSettingsUi(uiScope, app, workspaces, directory = "/test") } + val panel = requireUi() + + flushUntil { text(panel.progress).contains("Loading models") } + edt { + assertTrue(pickers(panel).isNotEmpty()) + assertTrue(pickers(panel).all { it.isEnabled }) + } + + workspaceRpc.modelsGate?.complete(Unit) + flushUntil { text(panel).contains("Old") } + } + + fun `test edit during pending save is preserved after completion`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + select(panel, "old") + assertSelected(panel, "kilo/old") + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { + assertSelected(panel, "kilo/old") + assertTrue(panel.modified()) + } + } + + fun `test failed save after dispose shows notification`() { + val notes = mutableListOf() + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( + Notifications.TOPIC, + object : Notifications { + override fun notify(notification: Notification) { + notes.add(notification) + } + }, + ) + project.messageBus.connect(testRootDisposable).subscribe( + Notifications.TOPIC, + object : Notifications { + override fun notify(notification: Notification) { + notes.add(notification) + } + }, + ) + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + rpc.configUpdateError = RuntimeException("save failed") + + edt { + select(panel, "new") + panel.applyDraft() + panel.dispose() + ui = null + } + rpc.configUpdateGate?.complete(Unit) + + flushUntil { + notes.any { it.groupId == "Kilo Code" && it.type == NotificationType.ERROR } + } + assertEquals(1, rpc.configUpdateAttempts) + assertTrue(notes.any { it.title == "Failed to save model settings" }) + } + + fun `test logged out save keeps login banner stable`() { + edt { + requireUi().dispose() + ui = null + } + uiScope = CoroutineScope(SupervisorJob()) + val state = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/old"), + profile = null, + ) + rpc.state.value = state + app._state.value = state + workspaceRpc.models = ModelsWorkspaceDto(providers = providers()) + edt { ui = ModelsSettingsUi(uiScope, app, workspaces, directory = "/test") } + val panel = requireUi() + flushUntil { text(panel).contains("Old") && text(panel).contains("Sign in to Kilo Code") } + val banner = edt { components(panel.top).filterIsInstance().single() } + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + assertTrue(text(panel).contains("Sign in to Kilo Code")) + assertSame(banner, components(panel.top).filterIsInstance().single()) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { + assertTrue(text(panel).contains("Sign in to Kilo Code")) + assertSame(banner, components(panel.top).filterIsInstance().single()) + } + } + + private fun providers(): ProvidersDto = ProvidersDto( + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf( + "old" to ModelDto(id = "old", name = "Old"), + "new" to ModelDto(id = "new", name = "New"), + ), + ), + ), + connected = emptyList(), + defaults = emptyMap(), + ) + + private fun state(model: String): KiloAppStateDto = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = model), + profile = ProfileDto(email = "alice@test.com"), + ) + + private fun select(panel: ModelsSettingsUi, id: String) { + val picker = pickers(panel).first() + picker.onSelect(ModelPicker.Item(id, id.replaceFirstChar { it.titlecase() }, "kilo", "Kilo")) + } + + private fun assertSelected(panel: ModelsSettingsUi, key: String) { + assertEquals(key, pickers(panel).first().selectionKeyForTest()) + } + + private fun pickers(panel: ModelsSettingsUi): List = components(panel).filterIsInstance() + + private fun requireUi(): ModelsSettingsUi = requireNotNull(ui) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(20) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + is JTextField -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt new file mode 100644 index 00000000000..6dcd6a35ef4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -0,0 +1,867 @@ +package ai.kilocode.client.settings.providers + +import ai.kilocode.client.app.KiloProviderService +import ai.kilocode.client.testing.FakeProviderRpcApi +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.CustomProviderConfigDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderAuthMethodDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderMetadataDto +import ai.kilocode.rpc.dto.ProviderOAuthReadyDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import ai.kilocode.rpc.dto.ProviderSettingsProviderDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.replaceService +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.awt.BorderLayout +import java.awt.Container +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.image.BufferedImage +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JScrollPane +import javax.swing.KeyStroke +import javax.swing.JTextField +import javax.swing.UIManager + +@Suppress("UNCHECKED_CAST") +class ProvidersSettingsUiTest : BasePlatformTestCase() { + private var scope: CoroutineScope? = null + private var ui: ProvidersSettingsUi? = null + + override fun tearDown() { + try { + val panel = ui + if (panel != null) edt { panel.dispose() } + ui = null + scope?.cancel() + scope = null + } finally { + super.tearDown() + } + } + + fun `test catalog provider without auth methods is connectable`() { + val content = content() + + edt { + content.update( + ProviderSettingsDto( + providers = listOf(provider("models-dev-provider", "Models Dev Provider")), + ), + ) + } + + edt { + assertEquals(listOf(ProviderListAction.CONNECT), rows(content).single().actions) + assertFalse(rows(content).single().actions.contains(ProviderListAction.DISCONNECT)) + } + } + + fun `test provider with api and oauth methods exposes both actions`() { + val content = content() + + edt { + content.update( + ProviderSettingsDto( + providers = listOf(provider("cloudflare-ai-gateway", "Cloudflare AI Gateway")), + auth = mapOf( + "cloudflare-ai-gateway" to listOf( + ProviderAuthMethodDto("api", "API key"), + ProviderAuthMethodDto("oauth", "OAuth"), + ), + ), + ), + ) + } + + edt { assertEquals(listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), rows(content).single().actions) } + } + + fun `test content uses direct list without scroll or search`() { + val content = content() + edt { + assertEquals(1, components(content).filterIsInstance>().size) + assertTrue(components(content).filterIsInstance().isEmpty()) + assertTrue(components(content).filterIsInstance().isEmpty()) + assertTrue(components(content).filterIsInstance().none { it.text == "Refresh" }) + } + } + + fun `test toolbar and search are outside scrollable provider content`() { + installProvider(ProviderSettingsDto()) + val panel = edt { createUi() } + + edt { + val layout = panel.content.layout as BorderLayout + val header = layout.getLayoutComponent(BorderLayout.NORTH) + val scroll = layout.getLayoutComponent(BorderLayout.CENTER) + + assertNotNull(header) + assertTrue(scroll is JScrollPane) + assertEquals(1, components(header).filterIsInstance().size) + assertTrue(components(scroll).filterIsInstance().isEmpty()) + assertFalse(components(content(panel)).contains(header)) + assertTrue(components(content(panel)).filterIsInstance().isEmpty()) + assertEquals(1, components(content(panel)).filterIsInstance>().size) + } + } + + fun `test configured custom provider exposes only disconnect`() { + val content = content() + + edt { + content.update( + ProviderSettingsDto( + providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")), + config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = "@ai-sdk/openai-compatible")), + auth = mapOf("local-openai" to listOf(ProviderAuthMethodDto("api", "API key"))), + ), + ) + } + + edt { assertEquals(listOf(ProviderListAction.DISCONNECT), rows(content).single().actions) } + } + + fun `test popular rows use vscode order including kilo`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf( + provider("openrouter", "OpenRouter", priority = 5), + provider("kilo", "Kilo", priority = 0), + provider("google", "Google", priority = 4), + provider("anthropic", "Anthropic", priority = 1), + provider("vercel", "Vercel", priority = 6), + provider("openai", "OpenAI", priority = 3), + provider("deepseek", "DeepSeek", priority = 2), + ), + ), + "", + ) + + assertEquals(listOf("kilo", "anthropic", "deepseek", "openai", "google", "openrouter", "vercel"), rows.map { it.key }) + assertEquals("Popular providers", providerListSectionTitle(rows, 0)) + } + + fun `test popular rows use fallback order without metadata`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf( + provider("unknown", "Unknown"), + provider("openai", "OpenAI"), + provider("anthropic", "Anthropic"), + ), + ), + "", + ) + + assertEquals(listOf("anthropic", "openai", "unknown"), rows.map { it.key }) + assertEquals("Popular providers", providerListSectionTitle(rows, 0)) + assertEquals("All providers", providerListSectionTitle(rows, 2)) + } + + fun `test connected providers appear first and are not duplicated in popular section`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("anthropic", "Anthropic", priority = 1), provider("openai", "OpenAI", priority = 3)), + connected = listOf("anthropic"), + ), + "", + ) + + assertEquals(listOf("anthropic", "openai"), rows.map { it.key }) + assertEquals("Connected providers", providerListSectionTitle(rows, 0)) + assertEquals("Popular providers", providerListSectionTitle(rows, 1)) + assertEquals(listOf(ProviderListAction.DISCONNECT), rows[0].actions) + assertTrue(rows[0].connected) + } + + fun `test source custom catalog providers remain visible while configured custom providers are connected`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf( + provider("anthropic", "Anthropic", source = "custom", priority = 1), + provider("available-custom", "Available Custom", source = "custom"), + provider("local-openai", "Local OpenAI", source = "custom"), + ), + config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = "@ai-sdk/openai-compatible")), + ), + "", + ) + + assertEquals(listOf("local-openai", "anthropic", "available-custom"), rows.map { it.key }) + assertEquals("Connected providers", providerListSectionTitle(rows, 0)) + assertEquals("Popular providers", providerListSectionTitle(rows, 1)) + assertEquals("All providers", providerListSectionTitle(rows, 2)) + assertEquals(listOf(ProviderListAction.DISCONNECT), rows[0].actions) + } + + fun `test unconfigured openai compatible template provider is hidden`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("openai-compatible", "OpenAI Compatible", source = "custom")), + ), + "", + ) + + assertTrue(rows.isEmpty()) + } + + fun `test connected kilo gateway has no provider settings actions`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("kilo", "Kilo Gateway")), + connected = listOf("kilo"), + ), + "", + ) + + assertEquals(listOf("kilo"), rows.map { it.key }) + assertEquals("Connected providers", providerListSectionTitle(rows, 0)) + assertTrue(rows.single().actions.isEmpty()) + } + + fun `test disabled popular provider appears in all providers with enable`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("anthropic", "Anthropic", priority = 1), provider("openai", "OpenAI", priority = 3)), + disabled = listOf("anthropic"), + ), + "", + ) + + assertEquals(listOf("openai", "anthropic"), rows.map { it.key }) + assertEquals("All providers", providerListSectionTitle(rows, 1)) + assertEquals(listOf(ProviderListAction.ENABLE), rows[1].actions) + } + + fun `test non popular providers appear in all providers alphabetically`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf( + provider("zeta", "Zeta"), + provider("alpha", "Alpha"), + provider("openai", "OpenAI", priority = 3), + ), + ), + "", + ) + + assertEquals(listOf("openai", "alpha", "zeta"), rows.map { it.key }) + assertEquals("All providers", providerListSectionTitle(rows, 1)) + } + + fun `test filtering by provider name updates rows and sections`() { + val content = content() + edt { + content.update( + ProviderSettingsDto( + providers = listOf( + provider("openai", "OpenAI", priority = 3), + provider("anthropic", "Anthropic", priority = 1), + provider("alpha", "Alpha Labs"), + ), + ), + ) + + content.filter("open") + + val rows = rows(content) + assertEquals(listOf("openai"), rows.map { it.key }) + assertEquals("Popular providers", providerListSectionTitle(rows, 0)) + } + } + + fun `test filtering does not match provider id`() { + val rows = providerListRows( + ProviderSettingsDto( + providers = listOf(provider("openai-compatible", "Local")), + ), + "openai", + ) + + assertTrue(rows.isEmpty()) + } + + fun `test renderer hit test maps actions`() { + edt { + val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val bounds = Rectangle(0, 0, 320, 48) + val areas = ProviderListRenderer.actionBounds(list, bounds, row, selected = true) + + assertEquals(ProviderListAction.CONNECT, ProviderListRenderer.actionAt(list, bounds, center(areas.getValue(ProviderListAction.CONNECT)), row, selected = true)) + assertEquals(ProviderListAction.OAUTH, ProviderListRenderer.actionAt(list, bounds, center(areas.getValue(ProviderListAction.OAUTH)), row, selected = true)) + assertNull(ProviderListRenderer.actionAt(list, bounds, Point(4, 4), row, selected = true)) + assertTrue(ProviderListRenderer.actionBounds(list, bounds, row, selected = false).isEmpty()) + } + } + + fun `test renderer keeps connected disconnect action visible when unselected`() { + edt { + val row = ProviderListRow(provider("openai", "OpenAI"), "Connected providers", listOf(ProviderListAction.DISCONNECT), connected = true) + val list = JBList(listOf(row)) + val bounds = Rectangle(0, 0, 320, 48) + val area = ProviderListRenderer.actionBounds(list, bounds, row, selected = false).getValue(ProviderListAction.DISCONNECT) + + assertEquals(ProviderListAction.DISCONNECT, ProviderListRenderer.actionAt(list, bounds, center(area), row, selected = false)) + } + } + + fun `test renderer ignores disabled env disconnect action`() { + edt { + val row = ProviderListRow(provider("env", "Env", source = "env"), "All providers", listOf(ProviderListAction.DISCONNECT)) + val list = JBList(listOf(row)) + val bounds = Rectangle(0, 0, 320, 48) + val area = ProviderListRenderer.actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.DISCONNECT) + + assertNull(ProviderListRenderer.actionAt(list, bounds, center(area), row, selected = true)) + } + } + + fun `test renderer exposes action labels`() { + edt { + val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertEquals(listOf("OAuth", "Connect"), renderer.actionTexts()) + } + } + + fun `test renderer lays out action labels with visible bounds`() { + edt { + val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + renderer.setSize(320, renderer.preferredSize.height) + renderer.doLayout() + components(renderer).filterIsInstance().forEach { it.doLayout() } + + val label = components(renderer).filterIsInstance().single { it.text == "Connect" } + assertTrue(label.isShowing || label.isVisible) + assertTrue(label.width > 0) + assertTrue(label.height > 0) + } + } + + fun `test renderer hides unselected unconnected action labels`() { + edt { + val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + + assertTrue(renderer.actionTexts().isEmpty()) + } + } + + fun `test disabled provider rows hide action labels and hit targets`() { + edt { + val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), disabled = true) + val list = JBList(listOf(row)) + val bounds = Rectangle(0, 0, 320, 48) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertTrue(ProviderListRenderer.visibleActions(row, selected = true).isEmpty()) + assertTrue(ProviderListRenderer.actionBounds(list, bounds, row, selected = true).isEmpty()) + assertNull(ProviderListRenderer.actionAt(list, bounds, Point(300, 24), row, selected = true)) + assertTrue(renderer.actionTexts().isEmpty()) + } + } + + fun `test renderer uses standard button foreground for actions`() { + edt { + val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + val fg = UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground() + val labels = components(renderer).filterIsInstance().filter { it.text in listOf("OAuth", "Connect") } + assertEquals(listOf(fg, fg), labels.map { it.foreground }) + } + } + + fun `test renderer exposes provider icon and vscode note`() { + edt { + val row = ProviderListRow( + provider( + "openai", + "OpenAI", + metadata = ProviderMetadataDto( + noteKey = "settings.providers.note.openai", + icon = "openai", + ), + ), + "Popular providers", + listOf(ProviderListAction.CONNECT), + ) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertTrue(renderer.providerIconVisible()) + assertEquals(Dimension(JBUI.scale(20), JBUI.scale(20)), renderer.providerIconSize()) + assertEquals("GPT and Codex models with API key or ChatGPT login", renderer.descriptionText()) + assertTrue(renderer.preferredSize.height > JBUI.scale(44)) + } + } + + fun `test renderer prefers provider description from cli`() { + edt { + val row = ProviderListRow( + provider( + "openai", + "OpenAI", + description = "Build with OpenAI models", + metadata = ProviderMetadataDto(noteKey = "settings.providers.note.openai"), + ), + "Popular providers", + listOf(ProviderListAction.CONNECT), + ) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertEquals("Build with OpenAI models", renderer.descriptionText()) + } + } + + fun `test renderer does not invent provider description without metadata`() { + edt { + val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertEquals("", renderer.descriptionText()) + } + } + + fun `test action bounds are vertically centered`() { + edt { + val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val bounds = Rectangle(0, 10, 320, 80) + val area = ProviderListRenderer.actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.CONNECT) + + assertTrue(kotlin.math.abs((bounds.y + bounds.height / 2) - (area.y + area.height / 2)) <= 1) + assertTrue(bounds.contains(area)) + } + } + + fun `test renderer action labels paint with non button border`() { + edt { + val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + renderer.setSize(320, 64) + renderer.doLayout() + + val image = BufferedImage(320, 64, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + renderer.paint(g) + } finally { + g.dispose() + } + } + } + + fun `test provider reload clears loading overlay after state loads`() { + val rpc = installProvider(providerState(provider("openai", "OpenAI"))) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.isNotEmpty() && edt { rows(panel).map { it.key } == listOf("openai") && !text(panel).contains("Loading providers") } } + + edt { + assertEquals(listOf("openai"), rows(panel).map { it.key }) + assertFalse(text(panel).contains("Loading providers")) + } + } + + fun `test provider oauth shows starting progress and disables actions while authorizing`() { + val ready = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf("github-copilot" to listOf(ProviderAuthMethodDto("oauth", "OAuth"))), + ), + ) + rpc.authorizesReady.add(ready) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 && edt { text(panel).contains("Starting OAuth for GitHub Copilot") } } + + edt { + assertTrue(text(panel).contains("Cancel")) + val cancel = components(panel).filterIsInstance().single { it.text == "Cancel" && it.isVisible } + assertEquals(UiStyle.Components.actionForeground(true), cancel.foreground) + assertEquals(UiStyle.Components.actionBackground(), cancel.background) + assertEquals(requireNotNull(UiStyle.Components.actionBorder()).getBorderInsets(cancel), requireNotNull(cancel.border).getBorderInsets(cancel)) + assertTrue(rows(panel).single().disabled) + assertTrue(ProviderListRenderer.visibleActions(rows(panel).single(), selected = true).isEmpty()) + panel.reload() + } + + flushUntil { rpc.stateCalls.size == 1 } + assertFalse(ready.isCompleted) + } + + fun `test provider oauth waiting countdown can be cancelled without refresh`() { + val callback = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf("github-copilot" to listOf(ProviderAuthMethodDto("oauth", "OAuth"))), + ), + ) + rpc.ready = ProviderOAuthReadyDto(method = "auto") + rpc.callbacksReady.add(callback) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.callbacks.size == 1 && edt { text(panel).contains("Waiting for authorization... (1:30)") } } + edt { components(panel).filterIsInstance().single { it.text == "Cancel" && it.isVisible }.doClick() } + + flushUntil { edt { !text(panel).contains("Waiting for authorization") && rows(panel).single().disabled.not() } } + callback.complete(ai.kilocode.rpc.dto.ProviderActionResultDto(providerState(provider("stale", "Stale")))) + flushUntil { callback.isCompleted } + + edt { + assertEquals(1, rpc.stateCalls.size) + assertEquals(listOf("github-copilot"), rows(panel).map { it.key }) + assertFalse(text(panel).contains("Cancel")) + } + } + + fun `test provider oauth prefers headless method original index`() { + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("openai", "OpenAI")), + auth = mapOf( + "openai" to listOf( + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus"), + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus (headless)"), + ), + ), + ), + ) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("openai") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 } + + assertEquals("1", rpc.authorizes.single().method) + } + + fun `test provider oauth falls back to first oauth method original index`() { + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf( + "github-copilot" to listOf( + ProviderAuthMethodDto("oauth", "OAuth"), + ), + ), + ), + ) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 } + + assertEquals("0", rpc.authorizes.single().method) + } + + fun `test provider oauth auto response shows device auth panel`() { + val callback = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("openai", "OpenAI")), + auth = mapOf( + "openai" to listOf( + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus"), + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus (headless)"), + ), + ), + ), + ) + rpc.ready = ProviderOAuthReadyDto( + method = "auto", + url = "https://auth.openai.com/device", + instructions = "Enter code: ABCD-EFGH", + ) + rpc.callbacksReady.add(callback) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("openai") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.callbacks.size == 1 && edt { text(panel).contains("Waiting for authorization... (1:30)") } } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Starting OAuth for OpenAI")) + assertTrue(t, t.contains("Open this URL")) + assertTrue(t, t.contains("A B C D - E F G H")) + assertTrue(t, t.contains("Open Browser")) + assertTrue(t, t.contains("Cancel")) + assertEquals("https://auth.openai.com/device", fieldsByName(panel, "kilo.provider.oauth.url").single().text) + val qr = components(panel).filterIsInstance().single { it.name == "kilo.provider.oauth.qr" } + assertNotNull(qr.icon) + } + + edt { components(panel).filterIsInstance().single { it.text == "Cancel" && it.isVisible }.doClick() } + flushUntil { edt { rpc.callbacks.size == 1 && rows(panel).single().disabled.not() } } + callback.complete(ai.kilocode.rpc.dto.ProviderActionResultDto(providerState(provider("stale", "Stale")))) + } + + fun `test provider oauth cancel before authorize completion skips callback`() { + val ready = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf("github-copilot" to listOf(ProviderAuthMethodDto("oauth", "OAuth"))), + ), + ) + rpc.authorizesReady.add(ready) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 && edt { text(panel).contains("Cancel") } } + edt { components(panel).filterIsInstance().single { it.text == "Cancel" && it.isVisible }.doClick() } + ready.complete(ProviderOAuthReadyDto(method = "auto")) + flushUntil { ready.isCompleted } + + edt { + assertTrue(rpc.callbacks.isEmpty()) + assertEquals(1, rpc.stateCalls.size) + assertEquals(listOf("github-copilot"), rows(panel).map { it.key }) + } + } + + fun `test provider oauth timeout clears progress without error`() { + val ready = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf("github-copilot" to listOf(ProviderAuthMethodDto("oauth", "OAuth"))), + ), + ) + rpc.authorizesReady.add(ready) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 && edt { text(panel).contains("Starting OAuth for GitHub Copilot") } } + val timeout = runBlocking { + try { + withTimeout(1) { delay(Long.MAX_VALUE) } + error("timeout expected") + } catch (e: TimeoutCancellationException) { + e + } + } + ready.completeExceptionally(timeout) + + flushUntil { edt { rows(panel).single().disabled.not() && !text(panel).contains("Starting OAuth") } } + + edt { + val visible = text(panel) + assertFalse(visible.contains("TimeoutCancellationException")) + assertFalse(visible.contains("OAuth timed out")) + assertFalse(visible.contains("Cancel")) + assertTrue(rpc.callbacks.isEmpty()) + } + } + + fun `test provider action failure returns error state`() = runBlocking { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val rpc = FakeProviderRpcApi() + rpc.state = providerState(provider("openai", "OpenAI")) + rpc.disconnectError = IllegalStateException("Kilo backend is not ready") + val service = KiloProviderService(cs, rpc) + + val result = withContext(kotlinx.coroutines.Dispatchers.Default) { + service.disconnect(ProviderDisconnectDto("/test", "openai")) + } + + assertEquals("Kilo backend is not ready", result.error) + assertEquals(listOf("openai"), result.state.providers.map { it.id }) + assertEquals(listOf("/test"), rpc.stateCalls) + } + + fun `test reload is ignored while existing reload is pending`() { + val first = CompletableDeferred() + val rpc = installProvider(ProviderSettingsDto()) + rpc.states.add(first) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 } + edt { panel.reload() } + flushUntil { rpc.stateCalls.size == 1 } + first.complete(providerState(provider("old", "Old"))) + flushUntil { first.isCompleted } + + edt { assertEquals(listOf("old"), rows(panel).map { it.key }) } + } + + fun `test dispose ignores pending reload completion`() { + val state = CompletableDeferred() + val rpc = installProvider(ProviderSettingsDto()) + rpc.states.add(state) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 } + edt { + panel.dispose() + ui = null + } + state.complete(providerState(provider("openai", "OpenAI"))) + flushUntil { state.isCompleted } + + edt { assertTrue(rows(panel).isEmpty()) } + } + + private fun content() = edt { ProvidersContent({}, {}, {}, {}) } + + private fun content(panel: ProvidersSettingsUi) = components(panel).filterIsInstance().single() + + private fun createUi(): ProvidersSettingsUi { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val panel = ProvidersSettingsUi(cs, "/test") + ui = panel + return panel + } + + private fun installProvider(state: ProviderSettingsDto): FakeProviderRpcApi { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + val rpc = FakeProviderRpcApi() + rpc.state = state + ApplicationManager.getApplication().replaceService( + KiloProviderService::class.java, + KiloProviderService(cs, rpc), + testRootDisposable, + ) + return rpc + } + + private fun providerState(vararg providers: ProviderSettingsProviderDto) = ProviderSettingsDto(providers = providers.toList()) + + private fun provider( + id: String, + name: String, + description: String? = null, + source: String? = null, + metadata: ProviderMetadataDto? = null, + priority: Int? = null, + ) = ProviderSettingsProviderDto( + id = id, + name = name, + description = description, + source = source, + metadata = metadata ?: priority?.let { ProviderMetadataDto(priority = it) }, + models = mapOf("model" to ModelDto("model", "Model")), + ) + + private fun rows(component: JComponent): List { + val model = list(component).model + return (0 until model.size).map { model.getElementAt(it) } + } + + private fun list(component: JComponent) = components(component).filterIsInstance>().single() + + private fun fieldsByName(root: Container, name: String): List = components(root).filterIsInstance().filter { it.name == name } + + private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) + + private fun triggerPrimary(component: JComponent) { + val list = list(component) + val action = list.getActionForKeyStroke(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0)) + action.actionPerformed(ActionEvent(list, ActionEvent.ACTION_PERFORMED, "enter")) + } + + private fun components(component: java.awt.Component): List { + val out = mutableListOf() + fun visit(c: java.awt.Component) { + out += c + if (c is Container) c.components.forEach { visit(it) } + } + visit(component) + return out + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is JButton -> comp.text?.let { out.add(it) } + is JBLabel -> comp.text?.let { out.add(it) } + is JTextField -> comp.text?.let { out.add(it) } + is SimpleColoredComponent -> comp.toString().takeIf { it.isNotBlank() }?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(20) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/test/CopyProviderSink.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/test/CopyProviderSink.kt new file mode 100644 index 00000000000..578bf0226d1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/test/CopyProviderSink.kt @@ -0,0 +1,29 @@ +package ai.kilocode.client.test + +import com.intellij.ide.CopyProvider +import com.intellij.openapi.actionSystem.DataKey +import com.intellij.openapi.actionSystem.DataMap +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DataSnapshotProvider +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.UiDataProvider + +@Suppress("UnstableApiUsage") +open class CopyProviderSink : DataSink { + var copy: CopyProvider? = null + + override fun set(key: DataKey, data: T?) { + if (key == PlatformDataKeys.COPY_PROVIDER) copy = data as? CopyProvider + } + + override fun setNull(key: DataKey) {} + + override fun lazyNull(key: DataKey) {} + + override fun lazyValue(key: DataKey, data: (DataMap) -> T?) {} + + override fun uiDataSnapshot(provider: UiDataProvider) = provider.uiDataSnapshot(this) + override fun dataSnapshot(provider: DataSnapshotProvider) = provider.dataSnapshot(this) + override fun uiDataSnapshot(provider: DataProvider) {} +} 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 a91623f165b..35d1c8c2123 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -1,6 +1,9 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto @@ -11,6 +14,7 @@ import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,11 +34,20 @@ class FakeAppRpcApi : KiloAppRpcApi { val selections = mutableListOf() val cleared = mutableListOf() val variants = mutableListOf() + val configPatches = mutableListOf() + var configUpdateAttempts = 0 + private set + var configUpdateGate: CompletableDeferred? = null + var configUpdateError: Exception? = null var connected = false private set var retries = 0 private set + var restarts = 0 + private set + var reinstalls = 0 + private set override suspend fun connect() { assertNotEdt("connect") @@ -58,10 +71,12 @@ class FakeAppRpcApi : KiloAppRpcApi { override suspend fun restart() { assertNotEdt("restart") + restarts += 1 } override suspend fun reinstall() { assertNotEdt("reinstall") + reinstalls += 1 } override suspend fun modelState(): ModelStateDto { @@ -106,10 +121,37 @@ class FakeAppRpcApi : KiloAppRpcApi { return models } + override suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto { + assertNotEdt("updateConfig") + configUpdateAttempts += 1 + configUpdateGate?.await() + configUpdateError?.let { throw it } + configPatches.add(patch) + val current = state.value + val next = current.copy(config = applyPatch(current.config ?: ConfigDto(), patch)) + state.value = next + return next + } + + private fun applyPatch(config: ConfigDto, patch: ConfigPatchDto): ConfigDto { + val values = patch.values + val agents = patch.agents.entries.fold(config.agent) { acc, (name, item) -> + acc + (name to (acc[name] ?: AgentConfigDto()).copy(model = item.model)) + } + return config.copy( + model = if (values.containsKey("model")) values["model"] else config.model, + smallModel = if (values.containsKey("small_model")) values["small_model"] else config.smallModel, + subagentModel = if (values.containsKey("subagent_model")) values["subagent_model"] else config.subagentModel, + subagentVariant = if (values.containsKey("subagent_variant")) values["subagent_variant"] else config.subagentVariant, + agent = agents, + ) + } + var fakeProfile: ProfileDto? = null var fakeDeviceAuth = DeviceAuthDto(code = "TEST-1234", verificationUrl = "https://auth.kilo.ai/device") val orgProfiles = mutableMapOf() val orgSelections = mutableListOf() + val telemetry = mutableListOf() /** When set, [completeLogin] will await this deferred before returning. */ var completeGate: CompletableDeferred? = null @@ -180,4 +222,9 @@ class FakeAppRpcApi : KiloAppRpcApi { if (orgProfiles.containsKey(organizationId)) fakeProfile = orgProfiles[organizationId] return fakeProfile } + + override suspend fun captureTelemetry(capture: TelemetryCaptureDto) { + assertNotEdt("captureTelemetry") + telemetry.add(capture) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMigrationRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMigrationRpcApi.kt new file mode 100644 index 00000000000..27048492eeb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeMigrationRpcApi.kt @@ -0,0 +1,78 @@ +package ai.kilocode.client.testing + +import ai.kilocode.rpc.KiloMigrationRpcApi +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * Fake [KiloMigrationRpcApi] for testing. + * + * Supply [statusResult] and [detectResult] before calling check. + * Push events via [events] for migration flows. + * All suspend methods assert they are NOT called on the EDT. + */ +class FakeMigrationRpcApi : KiloMigrationRpcApi { + + var statusResult: LegacyMigrationStatusDto? = null + var detectResult: LegacyMigrationDetectionDto = emptyDetection() + val events = MutableSharedFlow(extraBufferCapacity = 64) + + val statusCalls = mutableListOf() + val detectCalls = mutableListOf() + val migrateCalls = mutableListOf() + val skipCalls = mutableListOf() + val finalizeCalls = mutableListOf() + val cleanupCalls = mutableListOf() + + override suspend fun status(): LegacyMigrationStatusDto? { + assertNotEdt("status") + statusCalls.add(Unit) + return statusResult + } + + override suspend fun detect(): LegacyMigrationDetectionDto { + assertNotEdt("detect") + detectCalls.add(Unit) + return detectResult + } + + override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow { + assertNotEdt("migrate") + migrateCalls.add(selections) + return events + } + + override suspend fun skip() { + assertNotEdt("skip") + skipCalls.add(Unit) + } + + override suspend fun finalize(status: LegacyMigrationStatusDto) { + assertNotEdt("finalize") + finalizeCalls.add(status) + } + + override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto { + assertNotEdt("cleanup") + cleanupCalls.add(targets) + return LegacyCleanupReportDto(emptyList(), emptyList()) + } + + companion object { + fun emptyDetection() = LegacyMigrationDetectionDto( + providers = emptyList(), + mcpServers = emptyList(), + customModes = emptyList(), + sessions = emptyList(), + defaultModel = null, + settings = null, + hasData = false, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeProviderRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeProviderRpcApi.kt new file mode 100644 index 00000000000..0b0d0ba66c3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeProviderRpcApi.kt @@ -0,0 +1,82 @@ +package ai.kilocode.client.testing + +import ai.kilocode.rpc.KiloProviderRpcApi +import ai.kilocode.rpc.dto.CustomModelFetchDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.ProviderActionResultDto +import ai.kilocode.rpc.dto.ProviderConnectDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto +import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto +import ai.kilocode.rpc.dto.ProviderOAuthReadyDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import kotlinx.coroutines.CompletableDeferred + +class FakeProviderRpcApi : KiloProviderRpcApi { + var state = ProviderSettingsDto() + val states = ArrayDeque>() + val stateCalls = mutableListOf() + val connects = mutableListOf() + val disconnects = mutableListOf() + val enables = mutableListOf() + val custom = mutableListOf() + val authorizes = mutableListOf() + val callbacks = mutableListOf() + val authorizesReady = ArrayDeque>() + val callbacksReady = ArrayDeque>() + var ready = ProviderOAuthReadyDto() + var disconnectError: Exception? = null + + override suspend fun state(directory: String): ProviderSettingsDto { + assertNotEdt("provider.state") + stateCalls.add(directory) + if (states.isNotEmpty()) return states.removeFirst().await() + return state + } + + override suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto { + assertNotEdt("provider.connect") + connects.add(input) + return ProviderActionResultDto(state) + } + + override suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto { + assertNotEdt("provider.authorize") + authorizes.add(input) + if (authorizesReady.isNotEmpty()) return authorizesReady.removeFirst().await() + return ready + } + + override suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto { + assertNotEdt("provider.callback") + callbacks.add(input) + if (callbacksReady.isNotEmpty()) return callbacksReady.removeFirst().await() + return ProviderActionResultDto(state) + } + + override suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto { + assertNotEdt("provider.disconnect") + disconnects.add(input) + disconnectError?.let { throw it } + return ProviderActionResultDto(state) + } + + override suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto { + assertNotEdt("provider.enable") + enables.add(input) + return ProviderActionResultDto(state) + } + + override suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto { + assertNotEdt("provider.saveCustom") + custom.add(input) + return ProviderActionResultDto(state) + } + + override suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto { + assertNotEdt("provider.fetchCustomModels") + return CustomModelFetchResultDto() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 3e239e091db..a7053469f27 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -46,6 +47,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() var historyGate: CompletableDeferred? = null + var historyCalls = 0 + private set /** Recent sessions returned by [recent]. */ val recent = mutableListOf() @@ -77,7 +80,14 @@ class FakeSessionRpcApi : KiloSessionRpcApi { // --- Call tracking --- + val enhancements = mutableListOf>() + var enhanced = "Enhanced prompt" + var enhanceGate: CompletableDeferred? = null + var enhanceThrows: Exception? = null + var commandThrows: Exception? = null val prompts = mutableListOf>() + val commands = mutableListOf() + val attachmentParts = mutableListOf() val aborts = mutableListOf>() val compacts = mutableListOf>() val configs = mutableListOf>() @@ -97,6 +107,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { private set data class CloudCall(val directory: String, val cursor: String?, val limit: Int, val gitUrl: String?) + data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?) + data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto) // --- Implementation --- @@ -173,11 +185,25 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return fallback } + override suspend fun enhancePrompt(directory: String, text: String): String { + assertNotEdt("enhancePrompt") + enhancements.add(directory to text) + enhanceGate?.await() + enhanceThrows?.let { throw it } + return enhanced + } + override suspend fun prompt(id: String, directory: String, prompt: PromptDto) { assertNotEdt("prompt") prompts.add(Triple(id, directory, prompt)) } + override suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) { + assertNotEdt("command") + commandThrows?.let { throw it } + commands.add(CommandCall(id, directory, command, arguments, prompt)) + } + override suspend fun abort(id: String, directory: String) { assertNotEdt("abort") aborts.add(id to directory) @@ -190,10 +216,25 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun messages(id: String, directory: String): List { assertNotEdt("messages") + historyCalls++ historyGate?.await() return history.toList() } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { + assertNotEdt("attachmentPart") + attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey)) + historyGate?.await() + return history + .firstOrNull { it.info.id == messageId } + ?.parts + ?.firstOrNull { + if (it.type != "file") return@firstOrNull false + if (!attachmentKey.isNullOrBlank()) key(it.id, it.filename.orEmpty(), it.url.orEmpty()) == attachmentKey + else it.id == partId + } + } + override suspend fun events(id: String, directory: String): Flow { assertNotEdt("events") return eventFlow?.invoke(id, directory) ?: events @@ -233,4 +274,10 @@ class FakeSessionRpcApi : KiloSessionRpcApi { assertNotEdt("pendingQuestions") return pendingQuestionList.toList() } + + private fun key(part: String, name: String, url: String): String { + val value = listOf(part, name, url).joinToString("\u0000") + val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return bytes.take(16).joinToString("") { "%02x".format(it) } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index c218008a5b1..4d6c6aecf9e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -1,8 +1,13 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +25,29 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) var reloads = 0 private set + var models = ModelsWorkspaceDto() + var modelsGate: CompletableDeferred? = null + var fileMatches = emptyList() + var fileResolver: ((String) -> List)? = null + var searchResult = FileSearchResultDto() + var search: ((String) -> FileSearchResultDto)? = null + var gitChanges: String? = null + var openResult = true + var localConfigPath = "/test/.kilo/kilo.jsonc" + var globalConfigPath = "/config/kilo.jsonc" + var localConfigDisplayPath = localConfigPath + var globalConfigDisplayPath = globalConfigPath + var localConfigExists = true + var globalConfigExists = true + val fileCalls = mutableListOf>() + val searchQueries = mutableListOf() + val opened = mutableListOf() + val localConfigs = mutableListOf() + var globalConfigs = 0 + var localConfigPathCalls = 0 + private set + var globalConfigPathCalls = 0 + private set override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -35,4 +63,57 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { assertNotEdt("reload") reloads += 1 } + + override suspend fun models(directory: String): ModelsWorkspaceDto { + assertNotEdt("models") + modelsGate?.await() + return models + } + + override suspend fun files(directory: String, path: String): List { + assertNotEdt("files") + fileCalls.add(directory to path) + return fileResolver?.invoke(path) ?: fileMatches + } + + override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { + assertNotEdt("searchFiles") + searchQueries.add(query) + return search?.invoke(query) ?: searchResult + } + + override suspend fun gitChanges(directory: String): String? { + assertNotEdt("gitChanges") + return gitChanges + } + + override suspend fun openFile(path: String): Boolean { + assertNotEdt("openFile") + opened.add(path) + return openResult + } + + override suspend fun localConfigTarget(directory: String): ConfigTargetDto { + assertNotEdt("localConfigTarget") + localConfigPathCalls += 1 + return ConfigTargetDto(localConfigPath, localConfigDisplayPath, localConfigExists) + } + + override suspend fun globalConfigTarget(): ConfigTargetDto { + assertNotEdt("globalConfigTarget") + globalConfigPathCalls += 1 + return ConfigTargetDto(globalConfigPath, globalConfigDisplayPath, globalConfigExists) + } + + override suspend fun openLocalConfig(directory: String): Boolean { + assertNotEdt("openLocalConfig") + localConfigs.add(directory) + return openResult + } + + override suspend fun openGlobalConfig(): Boolean { + assertNotEdt("openGlobalConfig") + globalConfigs += 1 + return openResult + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestCoroutines.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestCoroutines.kt new file mode 100644 index 00000000000..03c735585e1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestCoroutines.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.testing + +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch + +class TestCoroutines { + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val job = SupervisorJob() + + val scope = CoroutineScope(job + dispatcher) + + fun drain(pump: () -> Unit) { + repeat(5) { + await(scope.launch {}, pump) + pump() + } + } + + fun close(pump: () -> Unit) { + job.cancel() + try { + await(job, pump) + } finally { + dispatcher.close() + } + } + + private fun await(job: kotlinx.coroutines.Job, pump: () -> Unit) { + val end = System.nanoTime() + 5_000_000_000L + while (!job.isCompleted) { + check(System.nanoTime() < end) { "Timed out draining test coroutines" } + pump() + Thread.yield() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestUiTimers.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestUiTimers.kt new file mode 100644 index 00000000000..13c6d7ede54 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/TestUiTimers.kt @@ -0,0 +1,81 @@ +package ai.kilocode.client.testing + +import ai.kilocode.client.util.UiTimer +import ai.kilocode.client.util.UiTimerSource +import com.intellij.openapi.application.ApplicationManager + +class TestUiTimers : UiTimerSource { + private val timers = linkedSetOf() + private var time = 0L + + override fun now(): Long = time + + override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer { + return TestTimer(ms.coerceAtLeast(0), repeats, action) + } + + fun advanceBy(ms: Long) { + edt { } + time += ms.coerceAtLeast(0) + runDue() + } + + fun runDue() { + while (true) { + val due = timers.filter { it.running && it.due <= time } + if (due.isEmpty()) return + due.forEach { it.fire() } + } + } + + private fun edt(action: () -> Unit) { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) { + action() + return + } + app.invokeAndWait(action) + } + + private inner class TestTimer( + private val ms: Int, + private val repeats: Boolean, + private val action: () -> Unit, + ) : UiTimer { + var running = false + private set + var due = Long.MAX_VALUE + private set + + override fun start() { + if (running) return + running = true + due = time + ms + timers.add(this) + } + + override fun stop() { + running = false + due = Long.MAX_VALUE + timers.remove(this) + } + + override fun restart() { + running = true + due = time + ms + timers.add(this) + } + + override fun isRunning(): Boolean = running + + fun fire() { + if (!running || due > time) return + if (repeats) { + due = time + ms.coerceAtLeast(1) + } else { + stop() + } + edt(action) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt new file mode 100644 index 00000000000..bd497a2c495 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JLayeredPane + +@Suppress("UnstableApiUsage") +class LayeredOverlayPanelTest : BasePlatformTestCase() { + + fun `test root owns content overlay and blocker layers`() { + val root = LayeredOverlayPanel() + + assertEquals(3, root.componentCount) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertSame(root.blocker, root.components.first { it === root.blocker }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker)) + } + + fun `test root layout fills all immediate children`() { + val root = LayeredOverlayPanel().apply { setSize(320, 180) } + + root.doLayout() + + assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds) + } + + fun `test root preferred size is max of content and overlay`() { + val root = LayeredOverlayPanel().apply { + content.preferredSize = Dimension(300, 120) + overlay.preferredSize = Dimension(180, 220) + } + + assertEquals(Dimension(300, 220), root.preferredSize) + } + + fun `test addOverlay applies callback bounds and delegates child layout`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val child = Probe() + + root.addOverlay(child) { _, item -> + Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) + } + root.doLayout() + + assertEquals(Rectangle(12, 34, 80, 24), child.bounds) + assertTrue(child.laid) + } + + fun `test overlay contains only visible overlay children`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val child = Probe() + + root.addOverlay(child) { _, item -> Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + assertTrue(root.overlay.contains(20, 40)) + assertFalse(root.overlay.contains(4, 4)) + + child.isVisible = false + assertFalse(root.overlay.contains(20, 40)) + } + + fun `test modal content is centered inside blocker`() { + val root = LayeredOverlayPanel().apply { setSize(200, 100) } + val child = Probe() + + root.setModalContent(child) + root.doLayout() + + assertTrue(root.blocker.isVisible) + assertEquals(1, root.blocker.componentCount) + assertEquals(Rectangle(60, 38, 80, 24), child.bounds) + } + + fun `test clearing modal content hides and removes blocker children`() { + val root = LayeredOverlayPanel().apply { setSize(200, 100) } + root.setModalContent(Probe()) + root.doLayout() + + root.setModalContent(null) + + assertFalse(root.blocker.isVisible) + assertEquals(0, root.blocker.componentCount) + } + + fun `test blocker contains reflects visibility`() { + val root = LayeredOverlayPanel().apply { setSize(200, 100) } + root.doLayout() + + root.setBlocked(false) + assertFalse(root.blocker.contains(50, 50)) + + root.setBlocked(true) + assertTrue(root.blocker.contains(50, 50)) + } + + private class Probe : BorderLayoutPanel() { + var laid = false + + init { + preferredSize = Dimension(80, 24) + } + + override fun doLayout() { + laid = true + super.doLayout() + } + } +} 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 73269d10499..b3702301c9c 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 @@ -29,7 +29,7 @@ class UiStyleTest : BasePlatformTestCase() { fun `test hover blends from panel toward border`() { val panel = Color(0, 0, 0) val border = UiStyle.Colors.contrast(panel, SessionUiStyle.View.BORDER_DELTA) - val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_ALPHA) + val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_FILL_ALPHA) assertTrue(hover.red > panel.red) assertTrue(hover.red < border.red) @@ -39,9 +39,9 @@ class UiStyleTest : BasePlatformTestCase() { fun `test session layout constants provide shared geometry`() { assertTrue(JBUI.scale(SessionUiStyle.SessionLayout.GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.GAP) > 0) + 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) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt index e12db964182..22397d6b66c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/AlignTest.kt @@ -372,6 +372,34 @@ class AlignTest : BasePlatformTestCase() { assertBounds(80, 0, 40, 100, child) } + fun `test layout measures preferred height after width probe`() { + val child = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(20, if (width == 100) 12 else 60) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val wrap = child.align(HAlign.TRACK, VAlign.TOP) + + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + + assertBounds(0, 0, 100, 12, child) + } + + fun `test layout measures preferred width after height probe`() { + val child = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(if (height == 80) 17 else 70, 20) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val wrap = child.align(HAlign.LEFT, VAlign.TRACK) + + wrap.setBounds(0, 0, 100, 80) + wrap.doLayout() + + assertBounds(0, 0, 17, 80, child) + } + // ------ helpers ------ private infix fun Int.x(h: Int) = Dimension(this, h) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt new file mode 100644 index 00000000000..887556bfd34 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt @@ -0,0 +1,462 @@ +package ai.kilocode.client.ui.layout + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.Component +import java.awt.Dimension + +@Suppress("UnstableApiUsage") +class StackTest : BasePlatformTestCase() { + + fun `test vertical stack is non-opaque`() { + assertFalse(Stack.vertical().isOpaque) + } + + fun `test horizontal stack is non-opaque`() { + assertFalse(Stack.horizontal().isOpaque) + } + + fun `test next adds direct children in order and returns stack`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical() + + assertSame(stack, stack.next(a)) + stack.next(b) + + assertEquals(2, stack.componentCount) + assertSame(a, stack.getComponent(0)) + assertSame(b, stack.getComponent(1)) + } + + fun `test vertical stacks children with default gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 8, 100, 7, b) + } + + fun `test vertical skips invisible child and its gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7).apply { isVisible = false } + val c = child(pref = 30 x 9) + val stack = Stack.vertical(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 8, 100, 9, c) + } + + fun `test vertical explicit gap overrides default next gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical(gap = 3).apply { + next(a) + gap(11) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 16, 100, 7, b) + } + + fun `test vertical explicit gap is ignored across invisible child`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7).apply { isVisible = false } + val c = child(pref = 30 x 9) + val stack = Stack.vertical(gap = 3).apply { + next(a) + gap(11) + next(b) + gap(13) + next(c) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 8, 100, 9, c) + } + + fun `test vertical trailing gap is ignored`() { + val a = child(pref = 10 x 5) + val stack = Stack.vertical().apply { + next(a) + gap(11) + } + + assertEquals(10 x 5, stack.preferredSize) + } + + fun `test removeAll clears explicit gaps`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical().apply { + next(a) + gap(11) + removeAll() + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 7, b) + } + + fun `test vertical filler contributes fixed height and tracks width`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.vertical().apply { + next(a) + fill(11) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + val filler = stack.getComponent(1) + assertEquals(20 x 23, stack.preferredSize) + assertBounds(0, 0, 100, 5, a) + assertBounds(0, 5, 100, 11, filler) + assertBounds(0, 16, 100, 7, b) + } + + fun `test vertical fills width ignoring child width constraints`() { + val a = child(min = 30 x 4, pref = 40 x 5, max = 50 x 6) + val stack = Stack.vertical().apply { next(a) } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 5, a) + } + + fun `test vertical bounds child preferred height`() { + val a = child(min = 10 x 8, pref = 20 x 3, max = 30 x 12) + val b = child(min = 10 x 2, pref = 20 x 20, max = 30 x 7) + val stack = Stack.vertical().apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 100, 8, a) + assertBounds(0, 8, 100, 7, b) + } + + fun `test vertical respects insets`() { + val a = child(pref = 10 x 5) + val stack = Stack.vertical().apply { + border = JBUI.Borders.empty(2, 3, 4, 5) + next(a) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(3, 2, 92, 5, a) + } + + fun `test horizontal stacks children with default gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.horizontal(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 20, 50, b) + } + + fun `test horizontal skips invisible child and its gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7).apply { isVisible = false } + val c = child(pref = 30 x 9) + val stack = Stack.horizontal(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 30, 50, c) + } + + fun `test horizontal explicit gap overrides default next gap`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.horizontal(gap = 3).apply { + next(a) + gap(11) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(21, 0, 20, 50, b) + } + + fun `test horizontal explicit gap is ignored across invisible child`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7).apply { isVisible = false } + val c = child(pref = 30 x 9) + val stack = Stack.horizontal(gap = 3).apply { + next(a) + gap(11) + next(b) + gap(13) + next(c) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 30, 50, c) + } + + fun `test horizontal trailing gap is ignored`() { + val a = child(pref = 10 x 5) + val stack = Stack.horizontal().apply { + next(a) + gap(11) + } + + assertEquals(10 x 5, stack.preferredSize) + } + + fun `test horizontal filler contributes fixed width and tracks height`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.horizontal().apply { + next(a) + fill(11) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + val filler = stack.getComponent(1) + assertEquals(41 x 7, stack.preferredSize) + assertBounds(0, 0, 10, 50, a) + assertBounds(10, 0, 11, 50, filler) + assertBounds(21, 0, 20, 50, b) + } + + fun `test horizontal fills height ignoring child height constraints`() { + val a = child(min = 4 x 10, pref = 5 x 20, max = 6 x 30) + val stack = Stack.horizontal().apply { next(a) } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 5, 50, a) + } + + fun `test horizontal bounds child preferred width`() { + val a = child(min = 8 x 10, pref = 3 x 20, max = 12 x 30) + val b = child(min = 2 x 10, pref = 20 x 20, max = 7 x 30) + val stack = Stack.horizontal().apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + assertBounds(0, 0, 8, 50, a) + assertBounds(8, 0, 7, 50, b) + } + + fun `test horizontal respects insets`() { + val a = child(pref = 10 x 5) + val stack = Stack.horizontal().apply { + border = JBUI.Borders.empty(2, 3, 4, 5) + next(a) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(3, 2, 10, 44, a) + } + + fun `test fit horizontal preserves preferred widths when there is space`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 20, 50, b) + } + + fun `test fit horizontal allocates tight space from the left`() { + val a = child(pref = 20 x 5) + val b = child(pref = 20 x 7) + val c = child(pref = 20 x 9) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 45, 50) + stack.doLayout() + + assertBounds(0, 0, 20, 50, a) + assertBounds(23, 0, 20, 50, b) + assertBounds(45, 0, 0, 50, c) + } + + fun `test vertical measures preferred height after width probe`() { + val a = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(20, if (width == 100) 12 else 60) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val stack = Stack.vertical().apply { next(a) } + + stack.setBounds(0, 0, 100, 80) + stack.doLayout() + + assertBounds(0, 0, 100, 12, a) + } + + fun `test horizontal measures preferred width after height probe`() { + val a = object : JBLabel("x") { + override fun getMinimumSize() = Dimension(0, 0) + override fun getPreferredSize() = Dimension(if (height == 80) 17 else 70, 20) + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) + } + val stack = Stack.horizontal().apply { next(a) } + + stack.setBounds(0, 0, 100, 80) + stack.doLayout() + + assertBounds(0, 0, 17, 80, a) + } + + fun `test vertical preferred size sums height and maxes width`() { + val a = child(min = 5 x 2, pref = 10 x 4, max = 20 x 8) + val b = child(min = 6 x 3, pref = 30 x 5, max = 25 x 9) + val stack = Stack.vertical(gap = 7).apply { + border = JBUI.Borders.empty(1, 2, 3, 4) + next(a) + next(b) + } + + val size = stack.preferredSize + + assertEquals(25 + 2 + 4, size.width) + assertEquals(4 + 7 + 5 + 1 + 3, size.height) + } + + fun `test horizontal preferred size sums width and maxes height`() { + val a = child(min = 5 x 2, pref = 10 x 4, max = 20 x 8) + val b = child(min = 6 x 3, pref = 30 x 5, max = 25 x 9) + val stack = Stack.horizontal(gap = 7).apply { + border = JBUI.Borders.empty(1, 2, 3, 4) + next(a) + next(b) + } + + val size = stack.preferredSize + + assertEquals(10 + 7 + 25 + 2 + 4, size.width) + assertEquals(5 + 1 + 3, size.height) + } + + fun `test minimum size uses child minimum sizes`() { + val a = child(min = 5 x 2, pref = 10 x 4) + val b = child(min = 6 x 3, pref = 30 x 5) + val vertical = Stack.vertical(gap = 7).apply { + next(a) + next(b) + } + val c = child(min = 5 x 2, pref = 10 x 4) + val d = child(min = 6 x 3, pref = 30 x 5) + val horizontal = Stack.horizontal(gap = 7).apply { + next(c) + next(d) + } + + assertEquals(6 x 12, vertical.minimumSize) + assertEquals(18 x 3, horizontal.minimumSize) + } + + fun `test maximum size uses effective maximum sizes`() { + val a = child(min = 5 x 6, pref = 10 x 7, max = 1 x 2) + val b = child(min = 6 x 3, pref = 30 x 5, max = 20 x 9) + val vertical = Stack.vertical(gap = 7).apply { + next(a) + next(b) + } + val c = child(min = 5 x 6, pref = 10 x 7, max = 1 x 2) + val d = child(min = 6 x 3, pref = 30 x 5, max = 20 x 9) + val horizontal = Stack.horizontal(gap = 7).apply { + next(c) + next(d) + } + + assertEquals(20 x 22, vertical.maximumSize) + assertEquals(32 x 9, horizontal.maximumSize) + } + + private infix fun Int.x(h: Int) = Dimension(this, h) + + private fun child( + min: Dimension = Dimension(0, 0), + pref: Dimension, + max: Dimension = Dimension(Int.MAX_VALUE, Int.MAX_VALUE), + ) = object : JBLabel("x") { + override fun getMinimumSize() = min + override fun getPreferredSize() = pref + override fun getMaximumSize() = max + } + + private fun assertBounds(x: Int, y: Int, w: Int, h: Int, c: Component) { + val b = c.bounds + assertEquals("x", x, b.x) + assertEquals("y", y, b.y) + assertEquals("width", w, b.width) + assertEquals("height", h, b.height) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt new file mode 100644 index 00000000000..c7766d764b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdTerminalTest : BasePlatformTestCase() { + fun `test split preserves trailing empty segment`() { + assertEquals(listOf("one", "two", ""), MdTerminal.split("one\ntwo\n", '\n')) + } + + fun `test reduce collapses carriage frames and backspaces`() { + assertEquals("done\nab", MdTerminal.reduce("step 1\rstep 2\rdone\nabc\b", keepSgr = false)) + } + + fun `test reduce keeps only sgr escapes when requested`() { + val text = "\u001B[32mgreen\u001B[0m\u001B[K" + + assertEquals("\u001B[32mgreen\u001B[0m", MdTerminal.reduce(text, keepSgr = true)) + assertEquals("green", MdTerminal.reduce(text, keepSgr = false)) + } + + fun `test strip removes ansi escapes`() { + assertEquals("green", MdTerminal.strip("\u001B[32mgreen\u001B[0m")) + assertTrue(MdTerminal.hasAnsi("\u001B[32mgreen\u001B[0m")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt new file mode 100644 index 00000000000..2ecfdd04f1d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdViewFactoryTest : BasePlatformTestCase() { + fun `test create returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.create(), MdViewHybrid::class.java) + } + + fun `test hybrid returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.hybrid(), MdViewHybrid::class.java) + } + + fun `test html returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.html(), MdViewHybrid::class.java) + } + + fun `test create applies supplied session style`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + val view = MdViewFactory.create(style) + + assertEquals(style.transcriptFont.name, view.font.name) + assertEquals(22, view.font.size) + assertEquals("Courier New", view.codeFont) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt new file mode 100644 index 00000000000..e9d40248cf6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt @@ -0,0 +1,180 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import javax.swing.Box +import javax.swing.JPanel + +/** + * Stress + leak coverage for the hybrid markdown renderer. + * + * These tests drive many updates through the public [MdView] API and inspect the real + * Swing component tree to prove that: + * - retained component instances survive heavy streaming, + * - the component tree stays bounded (no per-update growth), + * - editors created for code blocks are released (no leak) after churn + clear. + */ +@Suppress("UnstableApiUsage") +class MdViewHybridStressTest : BasePlatformTestCase() { + private lateinit var view: MdView + private var disposed = false + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + + fun `test streaming a large mixed document token by token stays consistent`() { + val doc = buildString { + append("# Heading\n\n") + append("Intro paragraph with **bold** text.\n\n") + append("- one\n- two\n- three\n\n") + append("```kotlin\nval x = 1\n```\n\n") + append("middle prose paragraph\n\n") + append("```java\nclass A {}\n```\n\n") + append("closing prose") + } + + for (token in doc.chunked(3)) view.append(token) + + assertEquals(doc, view.markdown()) + assertEquals(3, htmls().size) + assertEquals(2, scrolls().size) + assertEquals(4, struts().size) // blocks - 1 + assertEquals(9, panel().componentCount) // 5 blocks + 4 struts = 2*5 - 1 + + val html = view.html() + assertTrue(html.contains("

    ")) + assertTrue(html.contains("
      ")) + assertTrue(html.contains("class A")) + assertFalse(html.contains(" view.append(" more$i") } + + assertSame(intro, htmls().first()) + assertSame(tail, htmls().last()) + assertSame(editor, editors().single()) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertTrue(view.markdown().contains("more99")) + } + + fun `test repeated same structure set reuses single editor and stays bounded`() { + repeat(150) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + } + val editor = editors().single() + + repeat(50) { i -> view.set("```kotlin\nval y = $i\n```") } + + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, panel().componentCount) + assertEquals("val y = 49", editor.text) + } + + fun `test structural churn releases every editor after clear`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + view.set("```java\nclass A$i {}\n```") + editors().single().getEditor(true) + view.set("plain prose $i") + } + + view.clear() + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().isEmpty()) + assertEquals(0, panel().componentCount) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test streaming code body reuses one editor and keeps html in sync`() { + view.append("```java\n") + val pane = scrolls().single() + val editor = editors().single() + + val body = StringBuilder() + repeat(100) { i -> + val line = "void m$i() {}\n" + body.append(line) + view.append(line) + } + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(body.toString().trimEnd('\n'), editor.text) + assertTrue(view.html().contains("void m0()")) + assertTrue(view.html().contains("void m99()")) + + view.append("```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + } + + fun `test style changes during streaming do not rebuild components`() { + view.append("intro\n\n```kotlin\nval x = 1\n```\n\n") + val intro = htmls().first() + val editor = editors().single() + editor.getEditor(true) + val styled = SessionEditorStyle.create(family = "Courier New", size = 18) + val current = SessionEditorStyle.current() + + repeat(50) { i -> + view.append("line $i ") + view.applyStyle(if (i % 2 == 0) styled else current) + if (i % 5 == 0) view.resetStyles() + } + + assertSame(intro, htmls().first()) + assertSame(editor, editors().single()) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertTrue(view.markdown().contains("line 49")) + } + + private fun panel(): JPanel = view.component as JPanel + + private fun scrolls(): List = panel().components.filterIsInstance() + + private fun htmls(): List = panel().components.filterIsInstance() + + private fun struts(): List = panel().components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 new file mode 100644 index 00000000000..7a0a46884c5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -0,0 +1,877 @@ +package ai.kilocode.client.ui.md + +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.test.CopyProviderSink +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Font +import javax.swing.Box +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import java.awt.datatransfer.DataFlavor + +@Suppress("UnstableApiUsage") +class MdViewHybridTest : BasePlatformTestCase() { + private lateinit var view: MdView + private var disposed = false + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test root has no renderer owned left inset`() { + val ins = view.component.border?.getBorderInsets(view.component) + + assertEquals(0, ins?.left ?: 0) + assertEquals(0, ins?.right ?: 0) + } + + fun `test append renders accumulated source`() { + view.append("hello ") + view.append("**world**") + assertEquals("hello **world**", view.markdown()) + assertTrue(view.html().contains("")) + } + + fun `test fenced code block shows horizontal scrollbar as needed`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val editor = editors().single().getEditor(true)!! + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + assertTrue(pane.isWheelScrollingEnabled) + assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) + assertTrue(pane.horizontalScrollBar.isOpaque) + assertFalse(pane.isOverlappingScrollBar) + assertEquals(0, pane.verticalScrollBar.preferredSize.width) + } + + fun `test transparent markdown keeps code block background opaque`() { + view.opaque = false + + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val editor = editors().single().getEditor(true)!! + val bg = view.preBg + + assertFalse(view.component.isOpaque) + assertTrue(pane.isOpaque) + assertTrue(pane.viewport.isOpaque) + assertTrue(editor.scrollPane.isOpaque) + assertTrue(editor.scrollPane.viewport.isOpaque) + assertEquals(bg.rgb, pane.background.rgb) + assertEquals(bg.rgb, pane.viewport.background.rgb) + assertEquals(bg.rgb, editor.backgroundColor.rgb) + assertEquals(bg.rgb, editor.scrollPane.background.rgb) + assertEquals(bg.rgb, editor.scrollPane.viewport.background.rgb) + } + + fun `test fenced code block preserves multiline editor text and height`() { + view.set("```kotlin\nval one = 1\nval two = 2\nval three = 3\n```") + val pane = scrolls().single() + val editor = editors().single() + val line = editor.getFontMetrics(editor.font).height + val ins = pane.insets + val pad = pane.viewportBorder.getBorderInsets(pane) + val bar = pane.horizontalScrollBar.preferredSize.height + + assertEquals("val one = 1\nval two = 2\nval three = 3", editor.text) + assertEquals(editor.preferredSize.height + ins.top + ins.bottom + pad.top + pad.bottom + bar, pane.preferredSize.height) + assertTrue(pane.preferredSize.height >= line * 3) + } + + fun `test fenced code block has symmetric 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.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(pad.top, pad.bottom) + assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) + } + + fun `test short code block keeps symmetric 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.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(pad.top, pad.bottom) + } + + fun `test fenced code block height is not capped`() { + val code = (1..24).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + val line = editor.getFontMetrics(editor.font).height + + assertTrue(pane.preferredSize.height >= line * 24) + } + + fun `test custom code block options cap editor height and enable vertical scrolling`() { + Disposer.dispose(view) + view = MdViewFactory.hybrid( + code = MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Horizontal, + maxLines = 3, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + val code = (1..12).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + val ins = pane.border.getBorderInsets(pane) + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, ins.top) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, ins.bottom) + assertEquals(0, ins.left) + assertEquals(0, ins.right) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + + fun `test fenced code block lays out to full editor height`() { + val code = (1..30).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + + layout(width = 420) + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertTrue("scroll pane should use its full preferred height", pane.height >= pane.preferredSize.height) + assertTrue("editor should not be clipped vertically", editor.height >= editor.preferredSize.height) + } + + fun `test streaming fenced code block grows to full height`() { + view.append("```java\nclass Example {\n") + val initial = scrolls().single().preferredSize.height + + view.append((1..20).joinToString("\n", postfix = "\n") { "void method$it() {}" }) + view.append("}\n```") + val pane = scrolls().single() + val editor = editors().single() + layout(width = 420) + + assertTrue("code block should grow after streamed lines", pane.preferredSize.height > initial) + assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height) + } + + fun `test consecutive prose blocks coalesce into one html pane`() { + view.set("# Title\n\npara one\n\n- a\n- b") + + val pane = htmls().single() + assertTrue(pane.text.contains("

      ")) + assertTrue(pane.text.contains("

      ")) + assertTrue(pane.text.contains("

        ")) + assertTrue(pane.text.contains("
      • ")) + } + + fun `test code block separates surrounding prose runs`() { + view.set("intro\n\n```kotlin\nval x = 1\n```\n\noutro") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, scrolls().size) + assertTrue(html[0].text.contains("intro")) + assertTrue(html[1].text.contains("outro")) + } + + fun `test indented code block separates prose and renders as editor`() { + view.set("before\n\n code line\n\nafter") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, editors().size) + assertTrue(html[0].text.contains("before")) + assertEquals("code line", editors().single().text) + assertTrue(html[1].text.contains("after")) + } + + fun `test coalesced prose has no inter block struts`() { + view.set("first\n\nsecond\n\n- third") + + assertEquals(1, htmls().size) + assertTrue(struts().isEmpty()) + } + + fun `test thematic break after code block is filtered`() { + view.set("```kotlin\nval x = 1\n```\n\n---\n\n# Next") + + val pane = htmls().single() + + assertEquals(1, scrolls().size) + assertTrue(pane.text.contains("

        ")) + assertFalse(pane.text.contains("")) + assertFalse(pane.text.contains("= initial) + } + + fun `test streaming code body append reuses editor and stays correct`() { + view.append("```java\nclass A {\n") + val pane = scrolls().single() + val editor = editors().single() + + view.append(" void run() {\n") + view.append(" }\n") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("class A {\n void run() {\n }", editor.text) + assertEquals("```java\nclass A {\n void run() {\n }\n", view.markdown()) + assertTrue(view.html().contains("class A")) + + view.append("}\n```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("class A {\n void run() {\n }\n}", editor.text) + assertEquals("```java\nclass A {\n void run() {\n }\n}\n```", view.markdown()) + } + + fun `test streaming code body append keeps html in sync`() { + view.append("```java\n") + view.append("if (a < b) {\n") + + assertTrue(view.html().contains("a < b")) + assertEquals("if (a < b) {", editors().single().text) + } + + fun `test streaming partial fence opener renders code block without raw marker`() { + view.append("`") + + val pane = scrolls().single() + val editor = editors().single() + + assertEquals("", editor.text) + assertFalse(view.html().contains("`")) + + view.append("`") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("", editor.text) + assertFalse(view.html().contains("``")) + } + + fun `test streaming language prefix stays out of code text`() { + view.append("```") + view.append("p") + view.append("ython\nprint(1)\n") + + assertEquals("print(1)", editors().single().text) + assertFalse(view.html().contains("```")) + assertFalse(view.html().contains("python")) + } + + fun `test streaming partial closing fence stays out of code text`() { + view.append("```python\nprint(1)\n") + val pane = scrolls().single() + val editor = editors().single() + + view.append("`") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("print(1)", editor.text) + + view.append("`") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("print(1)", editor.text) + } + + fun `test completed closing fence preserves code block and renders following markdown`() { + view.append("```python\nprint(1)\n``") + val pane = scrolls().single() + val editor = editors().single() + + view.append("`\n\nafter") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("print(1)", editor.text) + assertEquals(1, scrolls().size) + assertEquals(1, htmls().size) + assertTrue(view.html().contains("after")) + } + + fun `test appending new block preserves existing code block component`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val editor = editors().single() + + view.append("\n\nhello") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, htmls().size) + } + + fun `test incompatible suffix replacement preserves prefix block`() { + view.set("before\n\n```kotlin\nval value = 1\n```") + val prefix = htmls().single() + val editor = editors().single().getEditor(true)!! + + view.set("before\n\nafter") + drainEdt() + + assertSame(prefix, htmls().first()) + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + + fun `test java code block with blank lines fits vertically`() { + val code = """ + import java.util.List; + import java.util.stream.Collectors; + + public class StreamsExample { + public static void main(String[] args) { + List names = List.of("Alice", "Bob", "Charlie", "David", "Anna"); + + List result = names.stream() + .filter(name -> name.startsWith("A")) + .map(String::toUpperCase) + .collect(Collectors.toList()); + + System.out.println(result); + } + } + """.trimIndent() + view.set("```java\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + + layout(width = 420) + + val line = editor.getFontMetrics(editor.font).height + val rows = editor.text.lineSequence().count() + assertTrue("java editor should reserve every document line", editor.preferredSize.height >= line * rows) + assertTrue("java editor should not be clipped vertically", editor.height >= editor.preferredSize.height) + assertTrue("java code block should not clip vertically", pane.height >= pane.preferredSize.height) + } + + fun `test fenced code block resolves existing language aliases`() { + view.set("```javascript\nconst value = 1\n```") + + assertSame(type("js"), editors().single().fileType) + } + + fun `test shell code fence resolves shell file type`() { + view.set("```shell\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + } + + fun `test shell command code fence renders terminal semantic highlighters`() { + view.set("```shell-command\ngit log -30 --oneline --decorate\n```") + val field = editors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertSame(PlainTextFileType.INSTANCE, field.fileType) + assertEquals("git log -30 --oneline --decorate", field.text) + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-30" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--oneline" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, scrolls().single().horizontalScrollBarPolicy) + } + + fun `test streaming shell command fence preserves terminal editor component`() { + view.append("```shell-command\ngit") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + + view.append(" status --short\n") + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals("git status --short", field.text) + assertTrue(editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + }.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + } + + fun `test shell script aliases resolve shell file type`() { + view.set("```shell script\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + + view.set("```zsh\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + } + + fun `test fenced code block ignores whitespace metadata`() { + view.set("```json title=\"sample.json\"\n{\"value\":1}\n```") + + assertSame(type("json"), editors().single().fileType) + } + + fun `test fenced code block resolves new aliases when available`() { + view.set("```yaml\nvalue: 1\n```") + + assertSame(type("yaml"), editors().single().fileType) + + view.set("```golang\nfmt.Println(1)\n```") + + assertSame(type("go"), editors().single().fileType) + + view.set("```pwsh\nWrite-Host hi\n```") + + assertSame(type("ps1"), editors().single().fileType) + } + + fun `test unknown fenced code language uses plain text`() { + view.set("```definitely-not-a-language\nvalue\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + } + + fun `test code block without language uses plain text`() { + view.set("```\nvalue\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + } + + fun `test ansi stdout aliases render terminal plain text`() { + listOf("ansi", "ansi-stdout", "terminal-output").forEach { lang -> + view.set("```$lang\n\u001B[32mgreen\u001B[0m\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + assertEquals("green", editors().single().text) + assertTrue(editors().single().getEditor(true)!!.markupModel.allHighlighters.isNotEmpty()) + } + } + + fun `test shell output renders plain text with semantic highlighters`() { + val output = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val display = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + + view.set("```shell-output\n$output\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertSame(PlainTextFileType.INSTANCE, field.fileType) + assertEquals("```shell-output\n$output\n```", view.markdown()) + assertEquals(display, field.text) + assertTrue(spans.contains("475ab514" to DefaultLanguageHighlighterColors.NUMBER)) + assertTrue(spans.contains("(HEAD -> main, origin/main, origin/HEAD)" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("1 insertion(+)" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("1 deletion(-)" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertTrue(spans.contains("++++++++++" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("----" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + } + + fun `test ansi stderr aliases render terminal plain text`() { + listOf("ansi-stderr", "terminal-error", "shell-error").forEach { lang -> + view.set("```$lang\nboom\n```") + val editor = editors().single().getEditor(true)!! + val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + assertEquals("boom", editors().single().text) + assertEquals(expected, editor.markupModel.allHighlighters.single().textAttributesKey) + } + } + + fun `test terminal block updates retained editor without soft wraps`() { + view.set("```ansi-stdout\none\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + + view.set("```ansi-stdout\ntwo\n```") + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals("two", field.text) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + } + + fun `test terminal and source plain text blocks are incompatible`() { + view.set("```text\none\n```") + val source = editors().single().getEditor(true)!! + + view.set("```ansi-stdout\none\n```") + drainEdt() + + assertTrue(source.isDisposed) + assertEquals("one", editors().single().text) + } + + fun `test ansi and shell output blocks are incompatible`() { + view.set("```ansi-stdout\none\n```") + val ansi = editors().single().getEditor(true)!! + + view.set("```shell-output\none\n```") + drainEdt() + + assertTrue(ansi.isDisposed) + assertEquals("one", editors().single().text) + } + + fun `test fenced code block width is bounded and boxed`() { + view.set("```kotlin\n${"x".repeat(500)}\n```") + val pane = scrolls().single() + val editor = editors().single() + val ins = pane.border.getBorderInsets(pane) + + assertEquals(0, pane.preferredSize.width) + assertTrue(editor.preferredSize.width > pane.preferredSize.width) + assertTrue(pane.maximumSize.width > 1000) + assertTrue(ins.top > 0) + assertTrue(ins.left > 0) + assertEquals(pane.background, pane.viewport.background) + } + + fun `test clear resets source and components`() { + view.set("```\ncode\n```") + view.clear() + + assertEquals("", view.markdown()) + assertTrue(scrolls().isEmpty()) + } + + fun `test rerender disposes previous code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + view.set("plain text") + drainEdt() + + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + + fun `test clear disposes code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + view.clear() + drainEdt() + + assertTrue(editor.isDisposed) + } + + fun `test dispose disposes code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + Disposer.dispose(view) + disposed = true + drainEdt() + + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + + fun `test applyStyle updates current and future blocks`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 21) + + view.applyStyle(style) + view.set("hello") + + assertFalse(view.font.name == "Courier New") + assertEquals(21, view.font.size) + assertTrue(view.overrideSheet().contains(style.transcriptFont.name)) + assertTrue(view.overrideSheet().contains("Courier New")) + assertTrue(view.overrideSheet().contains("21pt")) + } + + fun `test applyStyle updates retained html block`() { + view.set("hello") + val pane = htmls().single() + val style = SessionEditorStyle.create(family = "Courier New", size = 21) + + view.applyStyle(style) + + assertSame(pane, htmls().single()) + assertTrue(view.overrideSheet().contains(style.transcriptFont.name)) + assertTrue(view.overrideSheet().contains("Courier New")) + assertTrue(view.overrideSheet().contains("21pt")) + } + + fun `test applyStyle reapplies same style to retained html block`() { + view.set("hello") + val pane = htmls().single() + pane.background = Color.RED + val style = SessionEditorStyle.current() + + view.applyStyle(style) + + assertSame(pane, htmls().single()) + assertEquals(view.background, pane.background) + assertTrue(pane.text.contains("hello")) + } + + fun `test applyStyle updates retained code editor scheme and background`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + val style = customStyle() + + view.applyStyle(style) + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals( + Color(0xDD, 0xEE, 0xFF).rgb, + editor.colorsScheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK).foregroundColor.rgb, + ) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.backgroundColor.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.viewport.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.viewport.background.rgb) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + } + + fun `test resetStyles keeps content rendered`() { + view.set("hello **world**") + view.font = view.font.deriveFont(25f) + + view.resetStyles() + + assertEquals("hello **world**", view.markdown()) + assertTrue(view.html().contains("")) + } + + fun `test link listener receives simulated link`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + + view.simulateLink("https://example.com") + + assertEquals("https://example.com", received.single().href) + } + + fun `test markdown root and code child expose selection copy provider`() { + Disposer.dispose(view) + disposed = true + val selection = SessionSelection() + val local = MdViewFactory.hybrid(selection = selection) + try { + local.set("```text\nalpha code\n```") + val field = (local.component as JPanel).components + .filterIsInstance() + .mapNotNull { it.viewport.view as? EditorTextField } + .single() + field.getEditor(true)!!.selectionModel.setSelection(0, 5) + + val root = CopyProviderSink() + (local.component as UiDataProvider).uiDataSnapshot(root) + val child = CopyProviderSink() + (field as UiDataProvider).uiDataSnapshot(child) + child.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertNotNull(root.copy) + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + Disposer.dispose(local) + selection.dispose() + } + } + + private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() + + private fun htmls(): List = (view.component as JPanel).components.filterIsInstance() + + private fun struts(): List = (view.component as JPanel).components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun type(ext: String): FileType { + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type + } + + private fun layout(width: Int) { + val host = JPanel(BorderLayout()) + host.add(view.component, BorderLayout.CENTER) + host.setSize(width, view.component.preferredSize.height) + host.doLayout() + view.component.doLayout() + scrolls().forEach { it.doLayout() } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } + + private fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt index 5eaac3e666f..3b8c743fdd3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt @@ -1,17 +1,21 @@ package ai.kilocode.client.ui.md +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") class MdViewLoggingTest : BasePlatformTestCase() { fun `test invalid font family does not throw while building override sheet`() { - val view = MdView.html() + val view = MdViewFactory.html() + try { + view.codeFont = "broken'font" + view.set("`x`") - view.codeFont = "broken'font" - view.set("`x`") - - assertTrue(view.overrideSheet().contains("broken\\'font")) - assertTrue(view.html().contains("")) + assertTrue(view.overrideSheet().contains("broken\\'font")) + assertTrue(view.html().contains("")) + } finally { + Disposer.dispose(view) + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 27c6e901c4c..39109bc8bbb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -1,11 +1,20 @@ package ai.kilocode.client.ui.md +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font /** - * Tests for [MdView] created via [MdView.html]. + * Tests for the fallback HTML [MdView]. * * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that * JBHtmlPane initialisation works correctly. @@ -17,7 +26,15 @@ class MdViewTest : BasePlatformTestCase() { override fun setUp() { super.setUp() - view = MdView.html() + view = MdViewFactory.html() + } + + override fun tearDown() { + try { + if (this::view.isInitialized) Disposer.dispose(view) + } finally { + super.tearDown() + } } // ---- set ---- @@ -211,10 +228,52 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("Done.")) } - // ---- style overrides (empty by default) ---- + // ---- style overrides ---- + + fun `test override sheet includes session style defaults`() { + val style = SessionEditorStyle.current() + + assertTrue(view.overrideSheet().contains(style.editorFamily)) + assertTrue(view.overrideSheet().contains("${style.editorSize}pt")) + } + + fun `test override sheet includes markdown role color rules`() { + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("h1, h2, h3, h4, h5, h6")) + assertTrue(sheet.contains("strong, b")) + assertTrue(sheet.contains("em, i")) + assertTrue(sheet.contains("ul, ol")) + assertTrue(sheet.contains("li { color:")) + assertTrue(sheet.contains("blockquote")) + assertTrue(sheet.contains("th, td")) + assertTrue(sheet.contains("th { color:")) + assertTrue(sheet.contains("hr {")) + assertTrue(sheet.contains("pre code")) + } + + fun `test override sheet separates table and code block borders`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + val sheet = view.overrideSheet() + val pre = sheet.substringAfter("pre {").substringBefore("} pre code") + val cells = sheet.substringAfter("th, td {").substringBefore("}") - fun `test no overrides produces empty override sheet`() { - assertEquals("", view.overrideSheet()) + assertTrue(cells.contains("#123456")) + assertFalse(pre.contains("#123456")) + assertTrue(pre.contains("border-color:")) + } + + fun `test applyStyle derives markdown colors from editor scheme`() { + val style = customStyle() + + view.applyStyle(style) + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("a { color: #778899")) + assertTrue(sheet.contains("code { background: #112233; color: #aabbcc")) + assertTrue(sheet.contains("pre { background: #445566; color: #ddeeff; border-color: #223344")) + assertTrue(sheet.contains("blockquote { border-left-color: #223344; color: #334455")) + assertTrue(sheet.contains("th, td { border-color: #223344")) } // ---- style overrides appear in override sheet when set ---- @@ -243,7 +302,7 @@ class MdViewTest : BasePlatformTestCase() { view.set("```\ncode\n```") val sheet = view.overrideSheet() assertTrue(sheet.contains("#0a0b0c")) - assertTrue(sheet.contains("div.code-block")) + assertTrue(sheet.contains("pre {")) assertTrue(sheet.contains("#d0e0f0")) } @@ -303,6 +362,17 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("hello")) } + fun `test applying same style reapplies component background`() { + view.set("hello") + view.component.background = Color.RED + val style = SessionEditorStyle.current() + + view.applyStyle(style) + + assertEquals(view.background, view.component.background) + assertTrue(view.html().contains("hello")) + } + fun `test style change without content does not crash`() { view.foreground = Color.RED view.linkColor = Color.BLUE @@ -310,16 +380,16 @@ class MdViewTest : BasePlatformTestCase() { assertEquals("", view.markdown()) } - // ---- default codeFont uses editor font placeholder ---- + // ---- default codeFont uses session style ---- + + fun `test default codeFont is session editor font`() { + val style = SessionEditorStyle.current() - fun `test default codeFont is editor font placeholder`() { - // When no codeFont override is set, the getter returns the editor font placeholder - assertTrue(view.codeFont.contains("_Editor")) + assertEquals(style.editorFamily, view.codeFont) } - fun `test default override sheet is empty before any set`() { - // Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane - assertEquals("", view.overrideSheet()) + fun `test default override sheet includes session style before any set`() { + assertTrue(view.overrideSheet().contains(SessionEditorStyle.current().editorFamily)) } // ---- background sets component background ---- @@ -385,7 +455,7 @@ class MdViewTest : BasePlatformTestCase() { fun `test resetStyles clears foreground override`() { view.foreground = Color.RED view.resetStyles() - assertEquals("", view.overrideSheet()) + assertFalse(view.overrideSheet().contains("#ff0000")) } fun `test resetStyles clears all overrides`() { @@ -400,7 +470,9 @@ class MdViewTest : BasePlatformTestCase() { view.tableBorder = Color.YELLOW view.font = Font("Arial", Font.PLAIN, 18) view.resetStyles() - assertEquals("", view.overrideSheet()) + val sheet = view.overrideSheet() + assertFalse(sheet.contains("Arial")) + assertTrue(sheet.contains(SessionEditorStyle.current().editorFamily)) } fun `test resetStyles restores opaque to true`() { @@ -416,4 +488,30 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("")) } + private fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_COMMENT, + TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) + } + } diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index d5066fb4c41..21af8c36d3f 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,4 +1,5 @@ kotlin.stdlib.default.dependency=false +kilo.jetbrains.version=7.0.1-rc.12 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 267d49a8159..b255dc98c7b 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -10,6 +10,7 @@ openapi-generator = "7.21.0" detekt = "1.23.8" commonmark = "0.28.0" zxing = "3.5.3" +changelog = "2.5.0" [libraries] commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } @@ -31,3 +32,4 @@ kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-jvm-plugin" } openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } +changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 69dd267bd0c..77ce75e322d 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.3.8", + "version": "7.3.54", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-jetbrains/script/build-version.sh b/packages/kilo-jetbrains/script/build-version.sh new file mode 100755 index 00000000000..ff344202405 --- /dev/null +++ b/packages/kilo-jetbrains/script/build-version.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 < [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. + +Version: + x.y.z or x.y.z-rc.n, with an optional leading v. + +Options: + --skip-signing Build an unsigned ZIP without requiring JetBrains signing secrets. + --skip-verification Skip JetBrains signature and plugin verification. + --skip-clean Reuse Gradle outputs instead of running ./gradlew clean first. + -h, --help Show this help. + +Examples: + $0 7.0.1 + $0 v7.0.1-rc.1 + $0 7.0.1-rc.1 --skip-signing --skip-verification + $0 7.0.1 --skip-clean --skip-signing --skip-verification +EOF +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi + +raw="" +skip_verification=0 +sign=1 +clean=1 + +for arg in "$@"; do + case "$arg" in + --skip-verification) + skip_verification=1 + ;; + --skip-signing) + sign=0 + ;; + --skip-clean) + clean=0 + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -n "$raw" ]]; then + echo "Unexpected argument: $arg" >&2 + usage + exit 1 + fi + raw="$arg" + ;; + esac +done + +if [[ -z "$raw" ]]; then + echo "Missing required version." >&2 + usage + exit 1 +fi + +version="${raw#v}" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Unsupported version '$raw'. Expected x.y.z or x.y.z-rc.n, for example 7.0.1 or 7.0.1-rc.1." >&2 + exit 1 +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" +encrypted_key="${secrets}/private_encrypted.pem" +pass="${secrets}/JETBRAINS_PRIVATE_KEY_PASSWORD" + +if [[ ! -d "$plugin" ]]; then + echo "Expected JetBrains plugin package at $plugin" >&2 + 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 + echo "Missing required secret file: $file" >&2 + echo "Pass --skip-signing to build an unsigned ZIP without signing secrets." >&2 + exit 1 + fi + chmod go-rwx "$file" 2>/dev/null || true + done + + if [[ -f "$encrypted_key" ]]; then + chmod go-rwx "$encrypted_key" 2>/dev/null || true + fi + + export JETBRAINS_CERTIFICATE_CHAIN_FILE="$chain" + export JETBRAINS_PRIVATE_KEY_FILE="$key" + export JETBRAINS_PRIVATE_KEY_PASSWORD="$(<"$pass")" +fi + +cd "$plugin" + +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 + ./gradlew signPlugin -Pproduction=true -Pkilo.version="$version" -Pkilo.channel=eap +fi + +if [[ "$skip_verification" == "1" ]]; then + printf '\nSkipping JetBrains plugin verification.\n' +else + if [[ "$sign" == "1" ]]; then + ./gradlew verifyPluginSignature -Pproduction=true -Pkilo.version="$version" -Pkilo.channel=eap + fi + ./gradlew verifyPlugin -Pproduction=true -Pkilo.version="$version" -Pkilo.channel=eap +fi + +if [[ "$sign" == "1" ]]; then + printf '\nSigned JetBrains plugin ZIP:\n' + ls -lh build/distributions/*-signed.zip +else + printf '\nUnsigned JetBrains plugin ZIP:\n' + ls -lh build/distributions/*.zip +fi diff --git a/packages/kilo-jetbrains/script/dev/part-update.sh b/packages/kilo-jetbrains/script/dev/part-update.sh new file mode 100755 index 00000000000..0392cd7d0c2 --- /dev/null +++ b/packages/kilo-jetbrains/script/dev/part-update.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env sh +set -eu + +usage() { + printf 'Usage: %s \n' "$0" >&2 + printf ' %s \n' "$0" >&2 +} + +if [ "$#" -ne 2 ]; then + usage + exit 2 +fi + +target=$1 +sid=$2 +mode=$target + +script=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +plugin=$(CDPATH= cd -- "$script/../.." && pwd) + +resolve() { + case "$target" in + client|frontend) + for file in \ + "$plugin/.intellijPlatform/sandbox/kilo.jetbrains/kilo-frontend/kilo-dev.log" \ + "$plugin/build/idea-sandbox/system/log/idea.log" \ + "$plugin/build/idea-sandbox/client/system/log/idea.log" \ + "$plugin/build/idea-sandbox/frontend/system/log/idea.log" + do + if [ -r "$file" ]; then + printf '%s\n' "$file" + return 0 + fi + done + ;; + backend) + for file in \ + "$plugin/.intellijPlatform/sandbox/kilo.jetbrains/kilo-backend/kilo-dev.log" \ + "$plugin/build/idea-sandbox/backend/system/log/idea.log" \ + "$plugin/build/idea-sandbox-backend/system/log/idea.log" \ + "$plugin/build/idea-sandbox/system/log/idea.log" + do + if [ -r "$file" ]; then + printf '%s\n' "$file" + return 0 + fi + done + ;; + *) + if [ -r "$target" ]; then + printf '%s\n' "$target" + return 0 + fi + ;; + esac + + return 1 +} + +in=$(resolve || true) + +if [ -z "$in" ]; then + printf 'Could not find readable log for target: %s\n' "$target" >&2 + printf 'Tried JetBrains sandbox logs under: %s/.intellijPlatform and %s/build\n' "$plugin" "$plugin" >&2 + exit 1 +fi + +if [ ! -r "$in" ]; then + printf 'Input not readable: %s\n' "$in" >&2 + exit 1 +fi + +awk -v want="$sid" -v mode="$mode" ' +function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s +} + +function value(s, key, pattern) { + pattern = key "=[^[:space:]]+" + if (match(s, pattern)) return substr(s, RSTART + length(key) + 1, RLENGTH - length(key) - 1) + return "" +} + +function quoted(s, key, pattern) { + pattern = key "=\"[^\"]*\"" + if (match(s, pattern)) return substr(s, RSTART + length(key) + 2, RLENGTH - length(key) - 3) + return "" +} + +function logger(line, start, rest, stop) { + start = index(line, " - #") + if (start == 0) return "" + rest = substr(line, start + 4) + stop = index(rest, " - ") + if (stop == 0) return "" + return substr(rest, 1, stop - 1) +} + +function message(line, start, rest, stop) { + start = index(line, " - #") + if (start == 0) return "" + rest = substr(line, start + 4) + stop = index(rest, " - ") + if (stop == 0) return "" + return substr(rest, stop + 3) +} + +function emit(msg, pid, text) { + if (value(msg, "sid") != want) return + if (value(msg, "evt") != "message.part.delta") return + if (value(msg, "field") != "text") return + + pid = value(msg, "pid") + text = quoted(msg, "preview") + if (pid == "" || text == "") return + + print "pid=" pid " text=\"" text "\"" +} + +{ + sub(/\r$/, "") + cls = logger($0) + if ((mode == "client" || mode == "frontend") && cls !~ /KiloSessionService$/) next + if (mode == "backend" && cls !~ /KiloBackendChatManager$/) next + + msg = trim(message($0)) + if (msg == "") next + if (msg ~ /pass=(true|false)/) next + + emit(msg) +} +' "$in" diff --git a/packages/kilo-jetbrains/script/test-ci.ts b/packages/kilo-jetbrains/script/test-ci.ts index 954db86d2ed..dd1eabe0e01 100644 --- a/packages/kilo-jetbrains/script/test-ci.ts +++ b/packages/kilo-jetbrains/script/test-ci.ts @@ -3,24 +3,40 @@ /** * CI test runner for the JetBrains plugin. * - * Runs ./gradlew test --continue so all modules run even when some fail, + * Runs ./gradlew clean test --continue --no-build-cache --stacktrace so all modules run even when some fail, * then collects per-module JUnit XML results into .artifacts/unit/junit.xml * so mikepenz/action-junit-report can find them at the standard path. + * The generated OpenAPI client can otherwise restore stale compile outputs + * when the spec changes without a clean build directory. * - * Always exits 0 — test failures are surfaced as JUnit report annotations, - * not as CI job failures. The suite runs on both Linux and Windows but - * IntelliJ Swing/coroutine tests are inherently flaky on Windows, so failing - * the job on test failures would be noisy. + * Exits with Gradle's exit code on Linux/macOS so test failures fail the + * repo-wide `bun turbo test:ci` run. On Windows, exits 0 regardless — IntelliJ + * Swing/coroutine tests are inherently flaky on Windows and failing the job + * there would be noisy; failures remain visible via JUnit report annotations. */ -import { $ } from "bun" import { join } from "node:path" import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs" const root = join(import.meta.dir, "..") -const gradlew = process.platform === "win32" ? "./gradlew.bat" : "./gradlew" +const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew" +const args = ["clean", "test", "--continue", "--no-build-cache", "--stacktrace"] +const cmd = process.platform === "win32" ? ["cmd.exe", "/c", gradlew, ...args] : [gradlew, ...args] +const fallback = 45 * 60 * 1000 +const parsed = Number(process.env.KILO_JETBRAINS_TEST_TIMEOUT_MS ?? fallback) +const timeout = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback -const result = await $`${gradlew} test --continue`.cwd(root).nothrow() +const proc = Bun.spawn(cmd, { + cwd: root, + stdout: "inherit", + stderr: "inherit", +}) +const timer = setTimeout(() => { + console.error(`[jetbrains-test] Gradle timed out after ${Math.round(timeout / 1000)}s`) + proc.kill() +}, timeout) +const code = await proc.exited +clearTimeout(timer) const modules = [".", "shared", "frontend", "backend"] const suites: string[] = [] @@ -43,6 +59,7 @@ mkdirSync(join(root, ".artifacts", "unit"), { recursive: true }) writeFileSync(out, `\n\n${suites.join("\n")}\n\n`) console.log(`[jetbrains-test] collected ${suites.length} suite(s) -> ${out}`) -if (result.exitCode !== 0) { - console.log(`[jetbrains-test] Gradle exited ${result.exitCode} — failures visible in JUnit report`) +if (code !== 0) { + console.log(`[jetbrains-test] Gradle exited ${code} — failures visible in JUnit report`) + if (process.platform !== "win32") process.exit(code) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/KiloPlugin.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/KiloPlugin.kt new file mode 100644 index 00000000000..5dfd3f5ee62 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/KiloPlugin.kt @@ -0,0 +1,17 @@ +package ai.kilocode + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.extensions.PluginDescriptor +import com.intellij.openapi.extensions.PluginId + +object KiloPlugin { + const val ID = "ai.kilocode.jetbrains" + + val id: PluginId = PluginId.getId(ID) + + fun descriptor(): PluginDescriptor? = PluginManagerCore.getPlugin(id) + + fun version() = descriptor()?.version + + fun isRc() = version()?.contains("-rc.", ignoreCase = true) == true +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt new file mode 100644 index 00000000000..417c80037a8 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt @@ -0,0 +1,18 @@ +package ai.kilocode.cli + +import java.util.concurrent.ConcurrentHashMap + +object KiloCliParser { + private val tags = ConcurrentHashMap() + + fun tag(text: String, name: String): String? = + tags.computeIfAbsent(name) { + val tag = Regex.escape(it) + Regex("<$tag>\\s*([\\s\\S]*?)\\s*") + } + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 64e5966999e..5283789459c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -21,6 +21,7 @@ object ChatLogSummary { is ChatEventDto.PartRemoved -> event.sessionID is ChatEventDto.TurnOpen -> event.sessionID is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.SessionCreated -> event.sessionID is ChatEventDto.Error -> event.sessionID is ChatEventDto.MessageRemoved -> event.sessionID is ChatEventDto.PermissionAsked -> event.sessionID @@ -49,14 +50,23 @@ object ChatLogSummary { fun prompt(prompt: PromptDto): String { val out = mutableListOf() - val text = prompt.parts.joinToString("\n") { it.text } + val text = prompt.parts.mapNotNull { it.text }.joinToString("\n") + val files = prompt.parts.filter { it.type == "file" } out += "kind=prompt" out += "parts=${prompt.parts.size}" out += "chars=${text.length}" + if (files.isNotEmpty()) out += "attachments=${files.size}" + files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" } + .takeIf { it > 0 } + ?.let { out += "media=$it" } prompt.parts.map { it.type } .distinct() .takeIf { it.isNotEmpty() } ?.let { out += "types=${it.joinToString(",")}" } + files.mapNotNull { it.mime ?: it.type } + .distinct() + .takeIf { it.isNotEmpty() } + ?.let { out += "attachmentTypes=${it.joinToString(",")}" } prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" } model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" } prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" } @@ -133,6 +143,12 @@ object ChatLogSummary { "reason=${event.reason}", ) + is ChatEventDto.SessionCreated -> join( + sid(event.sessionID), + "evt=session.created", + "title=${event.info.title.length}", + ) + is ChatEventDto.Error -> join( sid(event.sessionID), "evt=session.error", diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt index d76dc9e9df5..12763663cf3 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt @@ -1,5 +1,7 @@ package ai.kilocode.log +import ai.kilocode.KiloPlugin +import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager import com.intellij.openapi.diagnostic.Logger import java.io.PrintWriter @@ -22,9 +24,8 @@ import java.util.logging.LogRecord * which writes to the standard IDE log file. * * In sandbox mode (i.e. when running via `./gradlew runIde`, detected via the `idea.plugin.in.sandbox.mode` - * system property), output is additionally written to a `kilo-dev.log` file inside the sandbox log directory. - * This makes it easy to tail frontend and backend logs side-by-side during development without opening the - * IDE's own log viewer. + * system property), output is written only to a `kilo-dev.log` file inside the IDE log directory. RC plugin builds + * write to both IntelliJ's log and `kilo-dev.log`. * * Usage: * ```kotlin @@ -46,13 +47,32 @@ interface KiloLog { fun error(msg: String, t: Throwable? = null) companion object { - private val sandbox = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean() - fun create(cls: Class<*>): KiloLog { + if (sandbox()) return FileLog(cls) val intellij = IntellijLog(cls) - if (!sandbox) return intellij + if (!runCatching { KiloPlugin.isRc() }.getOrDefault(false)) return intellij return CompositeLog(intellij, FileLog(cls)) } + + fun sandbox(): Boolean = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean() + + fun payload(log: KiloLog? = null): Map = buildMap { + put("platform", "jetbrains") + put("client", "jetbrains") + put("feature", "jetbrains-plugin") + runCatching { + val info = ApplicationInfo.getInstance() + put("editorName", info.fullApplicationName) + put("jetbrainsBuild", info.build.asString()) + }.onFailure { log?.info("Could not read ApplicationInfo for environment payload: ${it.message}") } + runCatching { + val version = KiloPlugin.version() + if (version != null) { + put("pluginVersion", version) + put("appVersion", version) + } + }.onFailure { log?.info("Could not read plugin version for environment payload: ${it.message}") } + } } } @@ -80,9 +100,11 @@ internal class FileLog(cls: Class<*>) : KiloLog { private val root: java.util.logging.Logger by lazy { val logger = java.util.logging.Logger.getLogger("ai.kilocode") + val payload = KiloLog.payload().entries.joinToString(" ") { "${it.key}=${it.value}" } logger.addHandler(handler) logger.useParentHandlers = false logger.level = level + logger.log(Level.INFO, "environment payload: $payload") logger } 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 df83a017ac7..fa1bb85079b 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.DeviceAuthDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto @@ -8,6 +9,7 @@ import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -61,6 +63,9 @@ interface KiloAppRpcApi : RemoteApi { /** Persist a per-model reasoning variant selection. */ suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto + /** Patch global CLI config values. */ + suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto + /** Refresh the user profile and return the latest data, or null if not logged in. */ suspend fun refreshProfile(): ProfileDto? @@ -85,4 +90,7 @@ interface KiloAppRpcApi : RemoteApi { * Returns the updated profile, or null if not logged in. */ suspend fun setOrganization(organizationId: String?): ProfileDto? + + /** Fire-and-forget behavior telemetry routed through the CLI server. */ + suspend fun captureTelemetry(capture: TelemetryCaptureDto) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMigrationRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMigrationRpcApi.kt new file mode 100644 index 00000000000..f06863ce214 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloMigrationRpcApi.kt @@ -0,0 +1,47 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.LegacyCleanupReportDto +import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto +import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto +import ai.kilocode.rpc.dto.LegacyMigrationEventDto +import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto +import ai.kilocode.rpc.dto.LegacyMigrationStatusDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +/** + * App-level RPC API for legacy migration operations. + * + * All operations are app-scoped. The backend implementation delegates to + * [ai.kilocode.backend.app.KiloBackendMigrationManager] using the active CLI connection. + */ +@Rpc +interface KiloMigrationRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloMigrationRpcApi = + RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + + /** Return the persisted migration status, or null if not yet set. */ + suspend fun status(): LegacyMigrationStatusDto? + + /** Detect legacy data and return a summary of what can be migrated. */ + suspend fun detect(): LegacyMigrationDetectionDto + + /** Run migration for the given selections, streaming progress events. */ + suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow + + /** Mark migration as skipped. */ + suspend fun skip() + + /** Mark migration as completed or completed with errors. */ + suspend fun finalize(status: LegacyMigrationStatusDto) + + /** Clean up legacy data after migration. */ + suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProviderRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProviderRpcApi.kt new file mode 100644 index 00000000000..e06d9ed8e90 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProviderRpcApi.kt @@ -0,0 +1,37 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.CustomModelFetchDto +import ai.kilocode.rpc.dto.CustomModelFetchResultDto +import ai.kilocode.rpc.dto.CustomProviderSaveDto +import ai.kilocode.rpc.dto.ProviderActionResultDto +import ai.kilocode.rpc.dto.ProviderConnectDto +import ai.kilocode.rpc.dto.ProviderDisconnectDto +import ai.kilocode.rpc.dto.ProviderEnableDto +import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto +import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto +import ai.kilocode.rpc.dto.ProviderOAuthReadyDto +import ai.kilocode.rpc.dto.ProviderSettingsDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor + +@Rpc +interface KiloProviderRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloProviderRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } + + suspend fun state(directory: String): ProviderSettingsDto + suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto + suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto + suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto + suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto + suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto + suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto + suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 25c8a63c3ab..feb68cd5c4e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -71,9 +72,15 @@ interface KiloSessionRpcApi : RemoteApi { // ------ chat ------ + /** Rewrite a draft prompt using the configured small model. */ + suspend fun enhancePrompt(directory: String, text: String): String + /** Send a prompt to a session (fire-and-forget). */ suspend fun prompt(id: String, directory: String, prompt: PromptDto) + /** Run a configured slash command/workflow in a session. */ + suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) + /** Abort ongoing processing for a session. */ suspend fun abort(id: String, directory: String) @@ -83,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Load message history for a session. */ suspend fun messages(id: String, directory: String): List + /** Load one attachment part from a session without returning full history to the frontend. */ + suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? + /** Subscribe to streaming chat events for a specific session. */ suspend fun events(id: String, directory: String): Flow diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f0f84295067..3c8c0d00da0 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,6 +1,10 @@ package ai.kilocode.rpc +import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -36,4 +40,31 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Trigger a full reload of workspace data. */ suspend fun reload(directory: String) + + /** Fetch only the providers and agents needed by Models settings. */ + suspend fun models(directory: String): ModelsWorkspaceDto + + /** Resolve [path] to matching files, scoped primarily to [directory]. */ + suspend fun files(directory: String, path: String): List + + /** Fuzzy file/folder search via the backend IDE index. */ + suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto + + /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ + suspend fun gitChanges(directory: String): String? + + /** Open an absolute backend file path in the IDE. */ + suspend fun openFile(path: String): Boolean + + /** Resolve the editable local config target. */ + suspend fun localConfigTarget(directory: String): ConfigTargetDto + + /** Resolve the editable global config target. */ + suspend fun globalConfigTarget(): ConfigTargetDto + + /** Open or create the local config file in the IDE. */ + suspend fun openLocalConfig(directory: String): Boolean + + /** Open or create the global config file in the IDE. */ + suspend fun openGlobalConfig(): Boolean } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index e333599e2b3..623915fc529 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -67,9 +67,34 @@ data class PartDto( val output: String? = null, val error: String? = null, val time: PartTimeDto? = null, + val todos: List = emptyList(), + val todoView: TodoViewDto? = null, val reason: String? = null, val cost: Double? = null, val tokens: TokensDto? = null, + val mime: String? = null, + val url: String? = null, + val filename: String? = null, + val synthetic: Boolean? = null, + val source: PartSourceDto? = null, +) + +@Serializable +data class PartSourceDto( + val type: String, + val text: PartSourceTextDto, + val path: String? = null, + val clientName: String? = null, + val uri: String? = null, + val name: String? = null, + val kind: Int? = null, +) + +@Serializable +data class PartSourceTextDto( + val value: String, + val start: Double, + val end: Double, ) @Serializable @@ -94,7 +119,11 @@ data class PromptDto( @Serializable data class PromptPartDto( val type: String, - val text: String, + val text: String? = null, + val mime: String? = null, + val url: String? = null, + val filename: String? = null, + val source: PartSourceDto? = null, ) // --- Streaming Events --- @@ -147,6 +176,13 @@ sealed class ChatEventDto { val reason: String, ) : ChatEventDto() + @Serializable + @SerialName("session.created") + data class SessionCreated( + val sessionID: String, + val info: SessionDto, + ) : ChatEventDto() + @Serializable @SerialName("session.error") data class Error( @@ -291,6 +327,7 @@ data class QuestionRequestDto( val sessionID: String, val questions: List, val tool: ToolRefDto? = null, + val blocking: Boolean = false, ) @Serializable @@ -300,12 +337,17 @@ data class QuestionInfoDto( val options: List = emptyList(), val multiple: Boolean = false, val custom: Boolean = true, + val questionKey: String? = null, + val headerKey: String? = null, ) @Serializable data class QuestionOptionDto( val label: String, val description: String, + val labelKey: String? = null, + val descriptionKey: String? = null, + val mode: String? = null, ) @Serializable @@ -320,6 +362,16 @@ data class TodoDto( val content: String, val status: String, val priority: String, + val changed: Boolean = false, +) + +@Serializable +data class TodoViewDto( + val mode: String = "full", + val todos: List = emptyList(), + val hiddenBefore: Int = 0, + val hiddenAfter: Int = 0, + val changed: Int = 0, ) // --- Diff DTO --- diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt new file mode 100644 index 00000000000..713b019f741 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt @@ -0,0 +1,10 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ConfigTargetDto( + val path: String, + val displayPath: String, + val exists: Boolean, +) 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 ba91d277175..bf4ee1491ea 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 @@ -7,6 +7,7 @@ enum class KiloAppStatusDto { DISCONNECTED, CONNECTING, LOADING, + MIGRATION_REQUIRED, READY, ERROR, } @@ -48,9 +49,23 @@ data class AgentConfigDto( @Serializable data class ConfigDto( val model: String? = null, + val smallModel: String? = null, + val subagentModel: String? = null, + val subagentVariant: String? = null, val agent: Map = emptyMap(), ) +@Serializable +data class ConfigPatchDto( + val values: Map = emptyMap(), + val agents: Map = emptyMap(), +) + +@Serializable +data class AgentConfigPatchDto( + val model: String? = null, +) + @Serializable data class ProfileOrganizationDto( val id: String, @@ -88,4 +103,5 @@ data class KiloAppStateDto( val warnings: List = emptyList(), val config: ConfigDto? = null, val profile: ProfileDto? = null, + val migration: LegacyMigrationDetectionDto? = null, ) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index 8b162af5d14..bd65cc6cc27 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -29,3 +29,10 @@ data class KiloWorkspaceStateDto( val error: String? = null, val errors: List = emptyList(), ) + +@Serializable +data class ModelsWorkspaceDto( + val providers: ProvidersDto? = null, + val agents: AgentsDto? = null, + val errors: List = emptyList(), +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MigrationDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MigrationDto.kt new file mode 100644 index 00000000000..9ebaf7f6d11 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/MigrationDto.kt @@ -0,0 +1,234 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +@Serializable +enum class LegacyMigrationStatusDto { + @SerialName("completed") completed, + @SerialName("completed_with_errors") completed_with_errors, + @SerialName("skipped") skipped, +} + +// --------------------------------------------------------------------------- +// Detection DTOs +// --------------------------------------------------------------------------- + +@Serializable +data class MigrationProviderInfoDto( + val profileName: String, + val provider: String, + val model: String?, + val hasApiKey: Boolean, + val supported: Boolean, + val newProviderName: String?, +) + +@Serializable +data class MigrationMcpServerInfoDto( + val name: String, + val type: String, + val disabled: Boolean?, +) + +@Serializable +data class MigrationCustomModeInfoDto( + val name: String, + val slug: String, + val nativeSlug: String? = null, +) + +@Serializable +data class MigrationSessionInfoDto( + val id: String, + val title: String, + val directory: String, + val time: Long, +) + +@Serializable +data class MigrationDefaultModelInfoDto( + val provider: String, + val model: String, +) + +@Serializable +data class LegacyAutocompleteSettingsDto( + val enableAutoTrigger: Boolean?, + val enableSmartInlineTaskKeybinding: Boolean?, + val enableChatAutocomplete: Boolean?, +) + +@Serializable +data class LegacySettingsDto( + val autoApprovalEnabled: Boolean?, + val allowedCommands: List?, + val deniedCommands: List?, + val alwaysAllowReadOnly: Boolean?, + val alwaysAllowReadOnlyOutsideWorkspace: Boolean?, + val alwaysAllowWrite: Boolean?, + val alwaysAllowExecute: Boolean?, + val alwaysAllowMcp: Boolean?, + val alwaysAllowModeSwitch: Boolean?, + val alwaysAllowSubtasks: Boolean?, + val language: String?, + val autocomplete: LegacyAutocompleteSettingsDto?, +) + +@Serializable +data class LegacyMigrationDetectionDto( + val providers: List, + val mcpServers: List, + val customModes: List, + val sessions: List, + val defaultModel: MigrationDefaultModelInfoDto?, + val settings: LegacySettingsDto?, + val hasData: Boolean, +) + +// --------------------------------------------------------------------------- +// Selection DTOs +// --------------------------------------------------------------------------- + +@Serializable +data class MigrationAutoApprovalSelectionsDto( + val commandRules: Boolean, + val readPermission: Boolean, + val writePermission: Boolean, + val executePermission: Boolean, + val mcpPermission: Boolean, + val taskPermission: Boolean, +) + +@Serializable +data class MigrationSettingsSelectionsDto( + val autoApproval: MigrationAutoApprovalSelectionsDto, + val language: Boolean, + val autocomplete: Boolean, +) + +@Serializable +data class MigrationSessionSelectionDto( + val id: String, +) + +@Serializable +data class LegacyMigrationSelectionsDto( + val providers: List, + val mcpServers: List, + val customModes: List, + val sessions: List, + val defaultModel: Boolean, + val settings: MigrationSettingsSelectionsDto, + val keepLegacySettingsFile: Boolean = true, +) + +// --------------------------------------------------------------------------- +// Result / Progress DTOs +// --------------------------------------------------------------------------- + +@Serializable +enum class MigrationItemCategoryDto { + @SerialName("provider") provider, + @SerialName("mcpServer") mcpServer, + @SerialName("customMode") customMode, + @SerialName("session") session, + @SerialName("defaultModel") defaultModel, + @SerialName("settings") settings, +} + +@Serializable +enum class MigrationItemStatusDto { + @SerialName("success") success, + @SerialName("warning") warning, + @SerialName("error") error, +} + +@Serializable +data class LegacyMigrationResultItemDto( + val item: String, + val category: MigrationItemCategoryDto, + val status: MigrationItemStatusDto, + val message: String? = null, +) + +@Serializable +enum class MigrationItemProgressStatusDto { + @SerialName("migrating") migrating, + @SerialName("success") success, + @SerialName("warning") warning, + @SerialName("error") error, +} + +@Serializable +enum class MigrationSessionPhaseDto { + @SerialName("preparing") preparing, + @SerialName("storing") storing, + @SerialName("skipped") skipped, + @SerialName("done") done, + @SerialName("summary") summary, + @SerialName("error") error, +} + +@Serializable +data class LegacyMigrationItemProgressDto( + val item: String, + val status: MigrationItemProgressStatusDto, + val message: String? = null, +) + +@Serializable +data class LegacyMigrationSessionProgressDto( + val session: MigrationSessionInfoDto?, + val index: Int, + val total: Int, + val phase: MigrationSessionPhaseDto, + val error: String? = null, +) + +// --------------------------------------------------------------------------- +// Cleanup DTOs +// --------------------------------------------------------------------------- + +@Serializable +data class LegacyCleanupTargetsDto( + val providerProfiles: Boolean = false, + val mcpSettings: Boolean = false, + val customModes: Boolean = false, + val globalState: Boolean = false, + val taskHistory: Boolean = false, + val legacySettingsFile: Boolean = false, +) + +@Serializable +data class LegacyCleanupReportDto( + val cleaned: List, + val errors: List, +) + +// --------------------------------------------------------------------------- +// Migration event sealed class (streamed from migrate()) +// --------------------------------------------------------------------------- + +@Serializable +sealed class LegacyMigrationEventDto { + @Serializable + @SerialName("item") + data class Item(val progress: LegacyMigrationItemProgressDto) : LegacyMigrationEventDto() + + @Serializable + @SerialName("session") + data class Session(val progress: LegacyMigrationSessionProgressDto) : LegacyMigrationEventDto() + + @Serializable + @SerialName("complete") + data class Complete(val items: List) : LegacyMigrationEventDto() + + @Serializable + @SerialName("error") + data class Error(val message: String) : LegacyMigrationEventDto() +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt index 6045f5c130f..c17933c6594 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt @@ -11,10 +11,12 @@ data class ModelDto( val temperature: Boolean = false, val toolCall: Boolean = false, val free: Boolean = false, + val byok: Boolean = false, val status: String? = null, val recommendedIndex: Double? = null, val variants: List = emptyList(), val limit: ModelLimitDto? = null, + val mayTrainOnYourPrompts: Boolean = false, ) @Serializable diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt new file mode 100644 index 00000000000..e45eb8d4f7d --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderSettingsDto.kt @@ -0,0 +1,156 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ProviderSettingsDto( + val providers: List = emptyList(), + val connected: List = emptyList(), + val defaults: Map = emptyMap(), + val auth: Map> = emptyMap(), + val config: Map = emptyMap(), + val disabled: List = emptyList(), + val enabled: List = emptyList(), + val disabledScopes: Map> = emptyMap(), + val enabledScopes: Map> = emptyMap(), + val errors: List = emptyList(), +) + +@Serializable +data class ProviderSettingsProviderDto( + val id: String, + val name: String, + val description: String? = null, + val source: String? = null, + val key: String? = null, + val metadata: ProviderMetadataDto? = null, + val models: Map = emptyMap(), +) { + val custom: Boolean get() = source == "custom" || id in setOf("openai-compatible") +} + +@Serializable +data class ProviderMetadataDto( + val noteKey: String? = null, + val icon: String? = null, + val priority: Int? = null, +) + +@Serializable +data class ProviderAuthMethodDto( + val type: String, + val label: String, + val prompts: List = emptyList(), +) + +@Serializable +data class ProviderAuthPromptDto( + val key: String, + val label: String, + val type: String = "text", + val options: List = emptyList(), + val whenKey: String? = null, + val whenOp: String? = null, + val whenValue: String? = null, +) + +@Serializable +data class ProviderAuthOptionDto( + val label: String, + val value: String = label, +) + +@Serializable +data class ProviderConnectDto( + val directory: String, + val providerId: String, + val key: String, + val metadata: Map = emptyMap(), +) + +@Serializable +data class ProviderOAuthAuthorizeDto( + val directory: String, + val providerId: String, + val method: String, + val inputs: Map = emptyMap(), +) + +@Serializable +data class ProviderOAuthCallbackDto( + val directory: String, + val providerId: String, + val method: String, + val code: String? = null, +) + +@Serializable +data class ProviderOAuthReadyDto( + val url: String? = null, + val method: String = "auto", + val instructions: String? = null, + val error: String? = null, +) + +@Serializable +data class ProviderDisconnectDto( + val directory: String, + val providerId: String, +) + +@Serializable +data class ProviderEnableDto( + val directory: String, + val providerId: String, +) + +@Serializable +data class ProviderActionResultDto( + val state: ProviderSettingsDto, + val profileCleared: Boolean = false, + val error: String? = null, +) + +@Serializable +data class CustomProviderSaveDto( + val directory: String, + val id: String, + val name: String, + val baseUrl: String, + val apiKey: String? = null, + val envVar: String? = null, + val headers: Map = emptyMap(), + val models: List = emptyList(), +) + +@Serializable +data class CustomModelDto( + val id: String, + val name: String = id, + val reasoning: Boolean = false, +) + +@Serializable +data class CustomModelFetchDto( + val baseUrl: String, + val apiKey: String? = null, + val headers: Map = emptyMap(), +) + +@Serializable +data class CustomModelFetchResultDto( + val models: List = emptyList(), + val error: String? = null, +) + +@Serializable +data class CustomProviderConfigDto( + val id: String, + val name: String? = null, + val npm: String? = null, + val env: List = emptyList(), + val options: Map = emptyMap(), + val headers: Map = emptyMap(), + val models: Map = emptyMap(), + val scope: String = "global", +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt index e5559393af5..6bdd4f9805c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt @@ -5,6 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class SkillDto( val name: String, - val description: String, + val description: String? = null, val location: String, ) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/TelemetryDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/TelemetryDto.kt new file mode 100644 index 00000000000..7bfffc64555 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/TelemetryDto.kt @@ -0,0 +1,9 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class TelemetryCaptureDto( + val event: String, + val properties: Map = emptyMap(), +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt new file mode 100644 index 00000000000..8364b1b1918 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt @@ -0,0 +1,17 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class WorkspaceFileDto( + val path: String, + val name: String, + val directory: Boolean = false, +) + +@Serializable +data class FileSearchResultDto( + val indexing: Boolean = false, + val files: List = emptyList(), + val git: Boolean = false, +) diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json new file mode 100644 index 00000000000..2bf4ea60d11 --- /dev/null +++ b/packages/kilo-sandbox/package.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/sandbox", + "version": "7.3.52", + "type": "module", + "license": "MIT", + "private": true, + "description": "OS-neutral sandbox profiles and launch preparation for Kilo Code", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "bun test --timeout 30000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "peerDependencies": {} +} diff --git a/packages/kilo-sandbox/src/backend.ts b/packages/kilo-sandbox/src/backend.ts new file mode 100644 index 00000000000..74c8564235d --- /dev/null +++ b/packages/kilo-sandbox/src/backend.ts @@ -0,0 +1,103 @@ +import { Effect, PlatformError, Scope } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { current } from "./context" +import type { Profile } from "./profile" +import { assertProcessNetwork, networkEnvironment } from "./network" +import { seatbelt } from "./seatbelt" + +export interface Launch { + readonly command: string + readonly args: ReadonlyArray + readonly cwd?: string | undefined + readonly environment?: Readonly> | undefined + readonly shell?: boolean | string | undefined +} + +export interface Support { + readonly available: boolean + readonly reason?: string | undefined +} + +export interface Backend { + readonly support: Support + readonly prepare: (profile: Profile, launch: Launch) => Effect.Effect +} + +function unavailable(reason: string): Backend { + return { + support: { available: false, reason }, + prepare: (_profile, launch) => Effect.succeed(launch), + } +} + +function select(): Backend { + switch (process.platform) { + case "darwin": + return seatbelt + case "linux": + return unavailable("The Linux sandbox backend is not available") + case "win32": + return unavailable("The Windows sandbox backend is not available") + default: + return unavailable("No sandbox backend is available for this operating system") + } +} + +const backend = select() + +function environment(profile: Profile, launch: Launch) { + const source = { ...launch.environment, ...profile.environment.set } + const denied = new Set(profile.environment.deny) + const entries = Object.entries(source).filter( + (entry): entry is [string, string] => entry[1] !== undefined && !denied.has(entry[0]), + ) + return networkEnvironment(profile, Object.fromEntries(entries)) +} + +export function prepare(launch: Launch) { + return Effect.gen(function* () { + const profile = yield* current + if (!profile) return launch + const next = { ...launch, environment: environment(profile, launch) } + yield* assertProcessNetwork(profile, launch.command) + if (!backend.support.available) return next + return yield* backend.prepare(profile, next) + }) +} + +function unsupported(command: string) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "Sandbox", + method: "prepareCommand", + pathOrDescriptor: command, + description: backend.support.reason ?? "The process sandbox backend is unavailable", + }) +} + +export function prepareCommand( + command: ChildProcess.StandardCommand, + cwd: string | undefined, + env: Readonly> | undefined, +) { + return Effect.gen(function* () { + if (!(yield* current)) return command + if (!backend.support.available) return yield* Effect.fail(unsupported(command.command)) + const launch = yield* prepare({ + command: command.command, + args: command.args, + cwd, + environment: env, + shell: command.options.shell, + }) + return ChildProcess.make(launch.command, launch.args, { + ...command.options, + cwd: launch.cwd, + env: launch.environment, + extendEnv: false, + shell: false, + }) + }) +} + +export const backendSupport = backend.support diff --git a/packages/kilo-sandbox/src/context.ts b/packages/kilo-sandbox/src/context.ts new file mode 100644 index 00000000000..9cb589ca68c --- /dev/null +++ b/packages/kilo-sandbox/src/context.ts @@ -0,0 +1,71 @@ +import { Context, Effect, PlatformError } from "effect" +import { canonicalize, canonicalizeEntry, matches, normalize } from "./path" +import type { Profile } from "./profile" + +export const CurrentProfile = Context.Reference("@kilocode/sandbox/CurrentProfile", { + defaultValue: () => undefined, +}) + +export const current: Effect.Effect = Effect.gen(function* () { + return yield* CurrentProfile +}) + +export const enabled: Effect.Effect = Effect.map(current, (profile) => profile !== undefined) + +export function run( + profile: Profile, + effect: Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* normalize(profile) + return yield* effect.pipe(Effect.provideService(CurrentProfile, value)) + }) +} + +function denied(path: string, method: string) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor: path, + description: "Sandbox denied write access", + }) +} + +function assertTarget( + path: string, + method: string, + resolve: (path: string) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const profile = yield* current + if (!profile) return + const target = yield* resolve(path) + const names = + process.platform === "win32" + ? profile.filesystem.denyNames.map((name) => name.toLowerCase()) + : profile.filesystem.denyNames + const parts = target.split(/[\\/]/).map((part) => (process.platform === "win32" ? part.toLowerCase() : part)) + if ( + profile.filesystem.denyWrite.some((rule) => matches(rule, target)) || + parts.some((part) => names.includes(part)) + ) { + yield* Effect.fail(denied(path, method)) + } + if (!profile.filesystem.allowWrite.some((rule) => matches(rule, target))) { + yield* Effect.fail(denied(path, method)) + } + }) +} + +export function assertPath(path: string, method: string): Effect.Effect { + return assertTarget(path, method, canonicalize) +} + +export function assertEntry(path: string, method: string): Effect.Effect { + return assertTarget(path, method, canonicalizeEntry) +} + +export function assertWrite(path: string): Effect.Effect { + return assertPath(path, "assertWrite") +} diff --git a/packages/kilo-sandbox/src/filesystem.ts b/packages/kilo-sandbox/src/filesystem.ts new file mode 100644 index 00000000000..1850b177f42 --- /dev/null +++ b/packages/kilo-sandbox/src/filesystem.ts @@ -0,0 +1,67 @@ +import { tmpdir } from "node:os" +import { Effect, FileSystem, Layer, Sink } from "effect" +import { assertEntry, assertPath, current } from "./context" + +interface TempOptions { + readonly directory?: string | undefined + readonly prefix?: string | undefined + readonly suffix?: string | undefined +} + +function temp( + method: string, + options: TempOptions | undefined, + create: (options?: TempOptions) => Effect.Effect, +) { + return Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* create(options) + const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir() + yield* assertPath(directory, method) + return yield* create( + options?.directory === undefined && profile.filesystem.temporaryDirectory + ? { ...options, directory: profile.filesystem.temporaryDirectory } + : options, + ) + }) +} + +export function decorateFileSystem(fs: FileSystem.FileSystem): FileSystem.FileSystem { + return FileSystem.FileSystem.of({ + ...fs, + chmod: (path, mode) => assertPath(path, "chmod").pipe(Effect.andThen(fs.chmod(path, mode))), + chown: (path, uid, gid) => assertPath(path, "chown").pipe(Effect.andThen(fs.chown(path, uid, gid))), + copy: (from, to, options) => assertPath(to, "copy").pipe(Effect.andThen(fs.copy(from, to, options))), + copyFile: (from, to) => assertPath(to, "copyFile").pipe(Effect.andThen(fs.copyFile(from, to))), + link: (from, to) => + assertPath(from, "link").pipe(Effect.andThen(assertPath(to, "link")), Effect.andThen(fs.link(from, to))), + makeDirectory: (path, options) => + assertPath(path, "makeDirectory").pipe(Effect.andThen(fs.makeDirectory(path, options))), + makeTempDirectory: (options) => temp("makeTempDirectory", options, fs.makeTempDirectory), + makeTempDirectoryScoped: (options) => temp("makeTempDirectoryScoped", options, fs.makeTempDirectoryScoped), + makeTempFile: (options) => temp("makeTempFile", options, fs.makeTempFile), + makeTempFileScoped: (options) => temp("makeTempFileScoped", options, fs.makeTempFileScoped), + open: (path, options) => { + if ((options?.flag ?? "r") === "r") return fs.open(path, options) + return assertPath(path, "open").pipe(Effect.andThen(fs.open(path, options))) + }, + remove: (path, options) => assertEntry(path, "remove").pipe(Effect.andThen(fs.remove(path, options))), + rename: (from, to) => + assertEntry(from, "rename").pipe(Effect.andThen(assertEntry(to, "rename")), Effect.andThen(fs.rename(from, to))), + sink: (path, options) => Sink.unwrap(Effect.map(assertPath(path, "sink"), () => fs.sink(path, options))), + symlink: (from, to) => assertPath(to, "symlink").pipe(Effect.andThen(fs.symlink(from, to))), + truncate: (path, length) => assertPath(path, "truncate").pipe(Effect.andThen(fs.truncate(path, length))), + utimes: (path, atime, mtime) => assertPath(path, "utimes").pipe(Effect.andThen(fs.utimes(path, atime, mtime))), + writeFile: (path, data, options) => + assertPath(path, "writeFile").pipe(Effect.andThen(fs.writeFile(path, data, options))), + writeFileString: (path, data, options) => + assertPath(path, "writeFileString").pipe(Effect.andThen(fs.writeFileString(path, data, options))), + }) +} + +export const layer: Layer.Layer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + return decorateFileSystem(yield* FileSystem.FileSystem) + }), +) diff --git a/packages/kilo-sandbox/src/index.ts b/packages/kilo-sandbox/src/index.ts new file mode 100644 index 00000000000..1484f494430 --- /dev/null +++ b/packages/kilo-sandbox/src/index.ts @@ -0,0 +1,5 @@ +export type { Profile } from "./profile" +export { assertWrite, enabled, run } from "./context" +export { decorateFileSystem } from "./filesystem" +export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network" +export { prepareCommand } from "./backend" diff --git a/packages/kilo-sandbox/src/network.ts b/packages/kilo-sandbox/src/network.ts new file mode 100644 index 00000000000..5cd29117f31 --- /dev/null +++ b/packages/kilo-sandbox/src/network.ts @@ -0,0 +1,89 @@ +import { Effect, Layer, PlatformError } from "effect" +import { HttpClient, HttpClientError, type HttpClientRequest } from "effect/unstable/http" +import { current } from "./context" +import type { Profile } from "./profile" + +const proxies = new Set([ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +]) + +function target(value: string) { + if (!URL.canParse(value)) return value + const url = new URL(value) + return url.origin +} + +function denied(value: string, method: string) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "Sandbox", + method, + pathOrDescriptor: target(value), + description: "Sandbox denied outbound network access", + }) +} + +function unsupported(value: string, method: string) { + return PlatformError.systemError({ + _tag: "BadResource", + module: "Sandbox", + method, + pathOrDescriptor: target(value), + description: "Sandbox proxy network mode and allowedHosts are not supported", + }) +} + +function unsupportedProfile(profile: Profile) { + return profile.network.mode === "proxy" || profile.network.allowedHosts.length > 0 +} + +export function networkEnvironment(profile: Profile, environment: Record) { + if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return environment + return Object.fromEntries(Object.entries(environment).filter(([key]) => !proxies.has(key))) +} + +export function assertProcessNetwork(profile: Profile, command: string) { + if (!unsupportedProfile(profile)) return Effect.void + return Effect.fail(unsupported(command, "prepareNetwork")) +} + +export function assertNetwork(value: string, method = "network") { + return Effect.gen(function* () { + const profile = yield* current + if (!profile) return + if (unsupportedProfile(profile)) yield* Effect.fail(unsupported(value, method)) + if (profile.network.mode === "allow") return + yield* Effect.fail(denied(value, method)) + }) +} + +function requestError(request: HttpClientRequest.HttpClientRequest, description: string) { + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, description }), + }) +} + +function assertRequest(request: HttpClientRequest.HttpClientRequest) { + return Effect.gen(function* () { + const profile = yield* current + if (!profile) return request + if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return request + const description = unsupportedProfile(profile) + ? "Sandbox proxy network mode and allowedHosts are not supported" + : "Sandbox denied outbound network access" + return yield* Effect.fail(requestError(request, description)) + }) +} + +export function decorateHttpClient(http: HttpClient.HttpClient): HttpClient.HttpClient { + return HttpClient.mapRequestEffect(http, assertRequest) +} + +export const httpLayer = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, decorateHttpClient)) diff --git a/packages/kilo-sandbox/src/path.ts b/packages/kilo-sandbox/src/path.ts new file mode 100644 index 00000000000..1a666b8a2a9 --- /dev/null +++ b/packages/kilo-sandbox/src/path.ts @@ -0,0 +1,92 @@ +import { lstatSync, readlinkSync, realpathSync } from "node:fs" +import path from "node:path" +import { Effect, PlatformError } from "effect" +import type { PathRule, Profile } from "./profile" + +function code(cause: unknown) { + if (typeof cause !== "object" || cause === null || !("code" in cause)) return undefined + return typeof cause.code === "string" ? cause.code : undefined +} + +function resolve(input: string, seen = new Set()) { + const target = path.resolve(input) + if (seen.has(target)) throw Object.assign(new Error("Symlink cycle"), { code: "ELOOP" }) + seen.add(target) + const suffix: Array = [] + let ancestor = target + + while (true) { + try { + return path.resolve(realpathSync.native(ancestor), ...suffix) + } catch (cause) { + const tag = code(cause) + if (tag !== "ENOENT" && tag !== "ENOTDIR") throw cause + try { + if (lstatSync(ancestor).isSymbolicLink()) { + const link = readlinkSync(ancestor) + return resolve(path.resolve(path.dirname(ancestor), link, ...suffix), seen) + } + } catch (error) { + if (code(error) !== "ENOENT" && code(error) !== "ENOTDIR") throw error + } + const parent = path.dirname(ancestor) + if (parent === ancestor) throw cause + suffix.unshift(path.basename(ancestor)) + ancestor = parent + } + } +} + +function attempt(input: string, method: string, fn: () => string): Effect.Effect { + return Effect.try({ + try: fn, + catch: (cause) => + PlatformError.systemError({ + _tag: code(cause) === "EACCES" || code(cause) === "EPERM" ? "PermissionDenied" : "Unknown", + module: "Sandbox", + method, + pathOrDescriptor: input, + description: "Could not resolve the path", + cause, + }), + }) +} + +export function canonicalize(input: string): Effect.Effect { + return attempt(input, "canonicalize", () => resolve(input)) +} + +export function canonicalizeEntry(input: string): Effect.Effect { + return attempt(input, "canonicalizeEntry", () => path.join(resolve(path.dirname(input)), path.basename(input))) +} + +function normalizeRule(rule: PathRule) { + return Effect.map(canonicalize(rule.path), (target): PathRule => ({ path: target, kind: rule.kind })) +} + +export function normalize(profile: Profile): Effect.Effect { + return Effect.gen(function* () { + const allowWrite = yield* Effect.forEach(profile.filesystem.allowWrite, normalizeRule) + const denyWrite = yield* Effect.forEach(profile.filesystem.denyWrite, normalizeRule) + const temporaryDirectory = profile.filesystem.temporaryDirectory + ? yield* canonicalize(profile.filesystem.temporaryDirectory) + : undefined + + return { + ...profile, + filesystem: { + allowWrite, + denyWrite, + denyNames: profile.filesystem.denyNames, + ...(temporaryDirectory === undefined ? {} : { temporaryDirectory }), + }, + } + }) +} + +export function matches(rule: PathRule, target: string) { + const relative = path.relative(rule.path, target) + if (relative === "") return true + if (rule.kind === "literal") return false + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) +} diff --git a/packages/kilo-sandbox/src/profile.ts b/packages/kilo-sandbox/src/profile.ts new file mode 100644 index 00000000000..2e955779429 --- /dev/null +++ b/packages/kilo-sandbox/src/profile.ts @@ -0,0 +1,29 @@ +export type PathKind = "literal" | "subtree" + +export interface PathRule { + readonly path: string + readonly kind: PathKind +} + +export interface FilesystemProfile { + readonly allowWrite: ReadonlyArray + readonly denyWrite: ReadonlyArray + readonly denyNames: ReadonlyArray + readonly temporaryDirectory?: string | undefined +} + +export interface NetworkProfile { + readonly mode: "allow" | "deny" | "proxy" + readonly allowedHosts: ReadonlyArray +} + +export interface EnvironmentProfile { + readonly deny: ReadonlyArray + readonly set: Readonly> +} + +export interface Profile { + readonly filesystem: FilesystemProfile + readonly network: NetworkProfile + readonly environment: EnvironmentProfile +} diff --git a/packages/kilo-sandbox/src/seatbelt-base.ts b/packages/kilo-sandbox/src/seatbelt-base.ts new file mode 100644 index 00000000000..a439d94611f --- /dev/null +++ b/packages/kilo-sandbox/src/seatbelt-base.ts @@ -0,0 +1,112 @@ +export const base = `(version 1) + +(deny default) + +(allow process-exec) +(allow process-fork) +(allow signal (target same-sandbox)) +(allow process-info* (target same-sandbox)) + +(allow file-write-data + (require-all + (path "/dev/null") + (vnode-type CHARACTER-DEVICE))) + +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.model") + (sysctl-name "hw.memsize") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name-prefix "hw.optional.arm.") + (sysctl-name-prefix "hw.optional.armv8_") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.pagesize") + (sysctl-name "hw.physicalcpu") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.logicalcpu") + (sysctl-name "hw.cpufrequency") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "machdep.cpu.brand_string") + (sysctl-name "kern.argmax") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.maxproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name "vm.loadavg") + (sysctl-name-prefix "hw.perflevel") + (sysctl-name-prefix "kern.proc.pgrp.") + (sysctl-name-prefix "kern.proc.pid.") + (sysctl-name-prefix "net.routetable.") +) + +(allow sysctl-write + (sysctl-name "kern.grade_cputype")) + +(allow iokit-open + (iokit-registry-entry-class "RootDomainUserClient")) + +(allow mach-lookup + (global-name "com.apple.system.opendirectoryd.libinfo")) + +(allow ipc-posix-sem) + +(allow ipc-posix-shm-read-data + ipc-posix-shm-write-create + ipc-posix-shm-write-unlink + (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$")) + +(allow mach-lookup + (global-name "com.apple.PowerManagement.control")) + +(allow pseudo-tty) +(allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* + (require-all + (regex #"^/dev/ttys[0-9]+") + (extension "com.apple.sandbox.pty"))) +(allow file-ioctl (regex #"^/dev/ttys[0-9]+")) + +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) + +; system services required by common command-line runtimes +(allow system-socket) +(allow mach-lookup + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd")) +(allow sysctl-read (sysctl-name-regex #"^net.routetable")) +` diff --git a/packages/kilo-sandbox/src/seatbelt-network.ts b/packages/kilo-sandbox/src/seatbelt-network.ts new file mode 100644 index 00000000000..3b742d6b88f --- /dev/null +++ b/packages/kilo-sandbox/src/seatbelt-network.ts @@ -0,0 +1,12 @@ +import type { Profile } from "./profile" + +export function networkPolicy(profile: Profile) { + if (profile.network.mode === "allow") { + return "; sandbox network mode: allow\n(allow network-outbound)\n(allow network-inbound)" + } + return [ + `; sandbox network mode: ${profile.network.mode}`, + '(deny network-outbound (with message "Sandbox denied outbound network access"))', + "(allow network-inbound)", + ].join("\n") +} diff --git a/packages/kilo-sandbox/src/seatbelt.ts b/packages/kilo-sandbox/src/seatbelt.ts new file mode 100644 index 00000000000..2c01b64e069 --- /dev/null +++ b/packages/kilo-sandbox/src/seatbelt.ts @@ -0,0 +1,81 @@ +import { existsSync } from "node:fs" +import { Effect } from "effect" +import type { Backend, Launch, Support } from "./backend" +import type { PathRule, Profile } from "./profile" +import { base } from "./seatbelt-base" +import { networkPolicy } from "./seatbelt-network" + +const executable = "/usr/bin/sandbox-exec" + +interface Param { + readonly key: string + readonly value: string +} + +function filter(rule: PathRule, key: string) { + if (rule.kind === "literal") return `(literal (param "${key}"))` + return `(require-any (literal (param "${key}")) (subpath (param "${key}")))` +} + +function escape(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function quote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function exclude(rule: PathRule, key: string) { + if (rule.kind === "literal") return [`(require-not (literal (param "${key}")))`] + return [`(require-not (literal (param "${key}")))`, `(require-not (subpath (param "${key}")))`] +} + +function policy(profile: Profile) { + const params: Array = [] + const allow = profile.filesystem.allowWrite.map((rule, index) => { + const key = `ALLOW_WRITE_${index}` + params.push({ key, value: rule.path }) + return filter(rule, key) + }) + const deny = profile.filesystem.denyWrite.flatMap((rule, index) => { + const key = `DENY_WRITE_${index}` + params.push({ key, value: rule.path }) + return exclude(rule, key) + }) + const names = profile.filesystem.denyNames.map((name) => `(require-not (regex #"(^|/)${escape(name)}(/|$)"))`) + const write = + allow.length === 0 + ? "" + : `(allow file-write*\n (require-all\n (require-any ${allow.join(" ")})\n ${[...deny, ...names].join("\n ")}\n )\n)` + return { + value: [ + base, + networkPolicy(profile), + "; reads are not confined by the file-level sandbox\n(allow file-read*)", + write, + ].join("\n"), + params, + } +} + +export function generate(profile: Profile, launch: Launch): Launch { + const generated = policy(profile) + const args = ["-p", generated.value, ...generated.params.map((param) => `-D${param.key}=${param.value}`)] + const command = launch.shell ? (typeof launch.shell === "string" ? launch.shell : "/bin/sh") : launch.command + const commandArgs = launch.shell ? ["-c", [launch.command, ...launch.args.map(quote)].join(" ")] : launch.args + args.push("--", command, ...commandArgs) + return { + ...launch, + command: executable, + args, + } +} + +const available: Support = existsSync(executable) + ? { available: true } + : { available: false, reason: `${executable} is not available` } + +export const seatbelt: Backend = { + support: available, + prepare: (profile, launch) => Effect.succeed(generate(profile, launch)), +} diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts new file mode 100644 index 00000000000..9646f96a044 --- /dev/null +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Result } from "effect" +import { backendSupport, prepare, type Launch } from "../src/backend" +import { run } from "../src/context" +import type { Profile } from "../src/profile" +import { generate } from "../src/seatbelt" + +function makeProfile(mode: Profile["network"]["mode"] = "deny"): Profile { + return { + filesystem: { + allowWrite: [{ path: "/workspace", kind: "subtree" }], + denyWrite: [{ path: "/workspace/.git", kind: "subtree" }], + denyNames: [".git"], + }, + network: { mode, allowedHosts: mode === "proxy" ? ["example.com"] : [] }, + environment: { deny: ["DROP", "RESET"], set: { KEEP: "profile", RESET: "removed" } }, + } +} + +const launch: Launch = { + command: "/bin/echo", + args: ["hello"], + cwd: "/workspace", + environment: { + KEEP: "launch", + DROP: "secret", + HTTPS_PROXY: "http://127.0.0.1:9000", + no_proxy: "*", + }, +} + +describe("sandbox launch preparation", () => { + test("generates a globally overriding overlapping deny policy with parameterized paths", () => { + const result = generate(makeProfile(), launch) + const policy = result.args[1] + expect(policy).toContain('(require-any (literal (param "ALLOW_WRITE_0")) (subpath (param "ALLOW_WRITE_0")))') + expect(policy).toContain('(require-not (literal (param "DENY_WRITE_0")))') + expect(policy).toContain('(require-not (subpath (param "DENY_WRITE_0")))') + expect(policy).toContain('(require-not (regex #"(^|/)\\.git(/|$)"))') + expect(policy).toContain("(allow file-read*)") + expect(policy).toContain("sandbox network mode: deny") + expect(policy).toContain("(deny network-outbound") + expect(policy).not.toContain("(allow network-outbound)") + expect(policy).toContain("(allow network-inbound)") + expect(policy).not.toContain("/workspace/.git") + expect(result.args).toContain("-DALLOW_WRITE_0=/workspace") + expect(result.args).toContain("-DDENY_WRITE_0=/workspace/.git") + expect(result.args.slice(-3)).toEqual(["--", "/bin/echo", "hello"]) + }) + + test("preserves unrestricted networking in allow mode", () => { + const result = generate(makeProfile("allow"), launch) + const policy = result.args[1] + expect(policy).toContain("sandbox network mode: allow") + expect(policy).toContain("(allow network-outbound)") + expect(policy).toContain("(allow network-inbound)") + expect(policy).not.toContain("(deny network-outbound") + }) + + test("places shell commands inside the sandbox backend", () => { + const result = generate(makeProfile(), { ...launch, command: "echo hello", args: [], shell: "/bin/zsh" }) + expect(result.args.slice(-4)).toEqual(["--", "/bin/zsh", "-c", "echo hello"]) + + const args = generate(makeProfile(), { + ...launch, + command: "printf", + args: ["%s", "hello world"], + shell: true, + }) + expect(args.args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "printf '%s' 'hello world'"]) + }) + + test("passes the launch through unchanged when no profile is active", async () => { + const result = await Effect.runPromise(Effect.scoped(prepare(launch))) + expect(result.command).toBe(launch.command) + expect(result.args).toBe(launch.args) + expect(result.cwd).toBe(launch.cwd) + expect(result.environment).toBe(launch.environment) + }) + + test("merges profile environment values and applies exact deny names", async () => { + const result = await Effect.runPromise(Effect.scoped(run(makeProfile(), prepare(launch)))) + expect(result.environment?.KEEP).toBe("profile") + expect(result.environment?.DROP).toBeUndefined() + expect(result.environment?.RESET).toBeUndefined() + expect(result.environment?.HTTPS_PROXY).toBeUndefined() + expect(result.environment?.no_proxy).toBeUndefined() + expect(result.environment?.PATH).toBeUndefined() + }) + + test("fails proxy mode closed before launching a process", async () => { + const result = await Effect.runPromise( + Effect.scoped(run(makeProfile("proxy"), prepare(launch))).pipe(Effect.result), + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure.reason._tag).toBe("BadResource") + expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + } + }) + + test("fails non-empty allowedHosts closed before launching a process", async () => { + const input = makeProfile("allow") + const result = await Effect.runPromise( + Effect.scoped(run({ ...input, network: { mode: "allow", allowedHosts: ["example.com"] } }, prepare(launch))).pipe( + Effect.result, + ), + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure.reason._tag).toBe("BadResource") + expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + } + }) + + test("reports backend support with a reason when unavailable", () => { + expect(typeof backendSupport.available).toBe("boolean") + if (!backendSupport.available) expect(backendSupport.reason?.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/kilo-sandbox/test/context.test.ts b/packages/kilo-sandbox/test/context.test.ts new file mode 100644 index 00000000000..5eb79d7da08 --- /dev/null +++ b/packages/kilo-sandbox/test/context.test.ts @@ -0,0 +1,92 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { Effect } from "effect" +import { assertWrite, current, enabled, run } from "../src/context" +import type { Profile } from "../src/profile" + +function makeProfile( + allowWrite: Profile["filesystem"]["allowWrite"], + denyWrite: Profile["filesystem"]["denyWrite"] = [], + denyNames: Profile["filesystem"]["denyNames"] = [], +): Profile { + return { + filesystem: { allowWrite, denyWrite, denyNames }, + network: { mode: "allow", allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} + +describe("sandbox profile context", () => { + let root = "" + + beforeAll(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), "kilo-sandbox-context-"))) + }) + + afterAll(async () => { + await rm(root, { recursive: true, force: true }) + }) + + test("is disabled outside run and exposes the normalized current profile inside run", async () => { + expect(await Effect.runPromise(enabled)).toBe(false) + const value = await Effect.runPromise( + run(makeProfile([{ path: root, kind: "subtree" }]), Effect.all([enabled, current])), + ) + expect(value[0]).toBe(true) + expect(value[1]?.filesystem.allowWrite[0]?.path).toBe(root) + expect(await Effect.runPromise(current)).toBeUndefined() + }) + + test("rejects writes outside the allowed roots", async () => { + const error = await Effect.runPromise( + run(makeProfile([]), assertWrite(path.join(root, "outside.txt")).pipe(Effect.flip)), + ) + expect(error.reason._tag).toBe("PermissionDenied") + }) + + test("applies deny rules before overlapping allows", async () => { + const denied = path.join(root, ".git") + const target = path.join(denied, "config") + const profile = makeProfile([{ path: root, kind: "subtree" }], [{ path: denied, kind: "subtree" }]) + const error = await Effect.runPromise(run(profile, assertWrite(target).pipe(Effect.flip))) + expect(error.reason._tag).toBe("PermissionDenied") + }) + + test("applies denied path names under allowed roots", async () => { + const target = path.join(root, "external", ".git", "config") + const error = await Effect.runPromise( + run(makeProfile([{ path: root, kind: "subtree" }], [], [".git"]), assertWrite(target).pipe(Effect.flip)), + ) + expect(error.reason._tag).toBe("PermissionDenied") + }) + + test("canonicalizes the longest existing ancestor across symlinks", async () => { + const allowed = path.join(root, "allowed") + const outside = path.join(root, "outside") + await mkdir(allowed) + await mkdir(outside) + await symlink(outside, path.join(allowed, "link"), "junction") + + const error = await Effect.runPromise( + run( + makeProfile([{ path: allowed, kind: "subtree" }]), + assertWrite(path.join(allowed, "link", "new.txt")).pipe(Effect.flip), + ), + ) + expect(error.reason._tag).toBe("PermissionDenied") + }) + + test("resolves dangling symlinks before authorizing a write", async () => { + const allowed = path.join(root, "dangling-allowed") + const outside = path.join(root, "dangling-outside.txt") + await mkdir(allowed) + await symlink(outside, path.join(allowed, "link")) + + const error = await Effect.runPromise( + run(makeProfile([{ path: allowed, kind: "subtree" }]), assertWrite(path.join(allowed, "link")).pipe(Effect.flip)), + ) + expect(error.reason._tag).toBe("PermissionDenied") + }) +}) diff --git a/packages/kilo-sandbox/test/filesystem.test.ts b/packages/kilo-sandbox/test/filesystem.test.ts new file mode 100644 index 00000000000..03881bcaa61 --- /dev/null +++ b/packages/kilo-sandbox/test/filesystem.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, FileSystem, Layer, Scope, Stream } from "effect" +import { run } from "../src/context" +import { layer } from "../src/filesystem" +import type { Profile } from "../src/profile" + +const live = layer.pipe(Layer.provide(NodeFileSystem.layer)) + +function makeProfile(root: string, temporaryDirectory?: string): Profile { + return { + filesystem: { + allowWrite: [{ path: root, kind: "subtree" }], + denyWrite: [], + denyNames: [], + ...(temporaryDirectory === undefined ? {} : { temporaryDirectory }), + }, + network: { mode: "allow", allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} + +function execute(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.provide(live), Effect.scoped)) +} + +describe("sandbox FileSystem", () => { + let root = "" + let allowed = "" + let outside = "" + + beforeAll(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), "kilo-sandbox-filesystem-"))) + allowed = path.join(root, "allowed") + outside = path.join(root, "outside.txt") + await writeFile(outside, "outside") + }) + + afterAll(async () => { + await rm(root, { recursive: true, force: true }) + }) + + test("guards writes with PermissionDenied and forwards mutation options", async () => { + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const nested = path.join(allowed, "nested", "directory") + yield* fs.makeDirectory(nested, { recursive: true, mode: 0o700 }) + const file = path.join(nested, "value.txt") + yield* fs.writeFileString(file, "first", { flag: "wx", mode: 0o600 }) + const exists = yield* fs.writeFileString(file, "second", { flag: "wx" }).pipe(Effect.flip) + expect(exists.reason._tag).toBe("AlreadyExists") + const denied = yield* fs.writeFileString(outside, "blocked").pipe(Effect.flip) + expect(denied.reason._tag).toBe("PermissionDenied") + }), + ), + ) + expect(await readFile(path.join(allowed, "nested", "directory", "value.txt"), "utf8")).toBe("first") + expect(await readFile(outside, "utf8")).toBe("outside") + }) + + test("allows read-only open but guards writable open and sink", async () => { + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.open(outside, { flag: "r" }) + const open = yield* fs.open(outside, { flag: "r+" }).pipe(Effect.flip) + expect(open.reason._tag).toBe("PermissionDenied") + const sink = yield* Stream.run(Stream.make(new TextEncoder().encode("blocked")), fs.sink(outside)).pipe( + Effect.flip, + ) + expect(sink.reason._tag).toBe("PermissionDenied") + }), + ), + ) + }) + + test("redirects default temporary files and directories and preserves their options", async () => { + await execute( + run( + makeProfile(allowed, allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(allowed, { recursive: true }) + const directory = yield* fs.makeTempDirectory({ prefix: "directory-" }) + const file = yield* fs.makeTempFile({ prefix: "file-", suffix: ".txt" }) + expect(path.dirname(directory)).toBe(allowed) + expect(path.basename(directory).startsWith("directory-")).toBe(true) + expect(path.dirname(path.dirname(file))).toBe(allowed) + expect(path.basename(path.dirname(file)).startsWith("file-")).toBe(true) + expect(file.endsWith(".txt")).toBe(true) + }), + ), + ) + }) + + test("removes and renames allowed symlink entries without following their targets", async () => { + await mkdir(allowed, { recursive: true }) + const removed = path.join(allowed, "removed-link") + const renamed = path.join(allowed, "renamed-link") + const moved = path.join(allowed, "moved-link") + await symlink(outside, removed) + await symlink(outside, renamed) + + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.remove(removed) + yield* fs.rename(renamed, moved) + }), + ), + ) + const missing = await lstat(removed).then( + () => false, + () => true, + ) + expect(missing).toBe(true) + expect((await lstat(moved)).isSymbolicLink()).toBe(true) + expect(await readFile(outside, "utf8")).toBe("outside") + }) + + test("passes through mutations when no profile is active", async () => { + await execute( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.writeFileString(outside, "passthrough") + }), + ) + expect(await readFile(outside, "utf8")).toBe("passthrough") + }) +}) diff --git a/packages/kilo-sandbox/test/network.test.ts b/packages/kilo-sandbox/test/network.test.ts new file mode 100644 index 00000000000..380fdc758f4 --- /dev/null +++ b/packages/kilo-sandbox/test/network.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Result } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { run } from "../src/context" +import { assertNetwork, decorateHttpClient } from "../src/network" +import type { Profile } from "../src/profile" + +function profile(mode: Profile["network"]["mode"]): Profile { + return { + filesystem: { + allowWrite: [{ path: process.cwd(), kind: "subtree" }], + denyWrite: [], + denyNames: [".git"], + }, + network: { mode, allowedHosts: mode === "proxy" ? ["example.com"] : [] }, + environment: { deny: [], set: {} }, + } +} + +function server() { + const paths: string[] = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname + paths.push(path) + return new Response(path) + }, + }) + return { server, paths } +} + +describe("sandbox in-process network capability", () => { + test("keeps concurrent allow, deny, and control-plane requests call-local", async () => { + const http = server() + try { + const effect = Effect.gen(function* () { + const raw = yield* HttpClient.HttpClient + const guarded = decorateHttpClient(raw) + return yield* Effect.all( + { + denied: run(profile("deny"), guarded.get(new URL("/denied", http.server.url))).pipe(Effect.result), + allowed: run( + profile("allow"), + Effect.flatMap(guarded.get(new URL("/allowed", http.server.url)), (response) => response.text), + ), + control: run( + profile("deny"), + Effect.flatMap(raw.get(new URL("/control", http.server.url)), (response) => response.text), + ), + }, + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provide(FetchHttpClient.layer)) + + const result = await Effect.runPromise(effect) + expect(Result.isFailure(result.denied)).toBe(true) + if (Result.isFailure(result.denied)) { + expect(result.denied.failure.message).toContain("Sandbox denied outbound network access") + } + expect(result.allowed).toBe("/allowed") + expect(result.control).toBe("/control") + expect(http.paths.sort()).toEqual(["/allowed", "/control"]) + } finally { + await http.server.stop(true) + } + }) + + test("fails closed when allowedHosts is set outside proxy mode", async () => { + for (const mode of ["allow", "deny"] as const) { + const input = profile(mode) + const result = await Effect.runPromise( + run( + { ...input, network: { mode, allowedHosts: ["example.com"] } }, + assertNetwork("https://example.com", "testRequest"), + ).pipe(Effect.result), + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + } + } + }) + + test("fails closed with a clear unsupported result for proxy mode", async () => { + const http = server() + try { + const result = await Effect.runPromise( + Effect.gen(function* () { + const raw = yield* HttpClient.HttpClient + const guarded = decorateHttpClient(raw) + return yield* Effect.all({ + capability: run(profile("proxy"), assertNetwork("https://example.com/path", "testRequest")).pipe( + Effect.result, + ), + request: run(profile("proxy"), guarded.get(new URL("/proxy", http.server.url))).pipe(Effect.result), + }) + }).pipe(Effect.provide(FetchHttpClient.layer)), + ) + expect(Result.isFailure(result.capability)).toBe(true) + if (Result.isFailure(result.capability)) { + expect(result.capability.failure.reason._tag).toBe("BadResource") + expect(result.capability.failure.message).toContain("proxy network mode and allowedHosts are not supported") + expect(result.capability.failure.message).toContain("https://example.com") + expect(result.capability.failure.message).not.toContain("/path") + } + expect(Result.isFailure(result.request)).toBe(true) + if (Result.isFailure(result.request)) { + expect(result.request.failure.message).toContain("proxy network mode and allowedHosts are not supported") + } + expect(http.paths).toEqual([]) + } finally { + await http.server.stop(true) + } + }) +}) diff --git a/packages/kilo-sandbox/test/seatbelt-network.test.ts b/packages/kilo-sandbox/test/seatbelt-network.test.ts new file mode 100644 index 00000000000..bb2eb4dead6 --- /dev/null +++ b/packages/kilo-sandbox/test/seatbelt-network.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { prepare, type Launch } from "../src/backend" +import { run } from "../src/context" +import type { Profile } from "../src/profile" + +const mac = process.platform === "darwin" ? test : test.skip +const roots: string[] = [] + +function profile(root: string, mode: Profile["network"]["mode"]): Profile { + return { + filesystem: { + allowWrite: [{ path: root, kind: "subtree" }], + denyWrite: [], + denyNames: [".protected"], + }, + network: { mode, allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} + +function prepareLaunch(profile: Profile, input: Launch) { + return Effect.runPromise(Effect.scoped(run(profile, prepare(input)))) +} + +async function launch(profile: Profile, input: Launch) { + const target = await prepareLaunch(profile, input) + const child = Bun.spawn([target.command, ...target.args], { + cwd: target.cwd, + env: target.environment, + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + return { code, stdout, stderr } +} + +function server(hostname: string) { + let accepted = 0 + const listener = Bun.listen({ + hostname, + port: 0, + socket: { + open(socket) { + accepted++ + socket.write("sandbox-tcp-ok") + socket.end() + }, + data() {}, + }, + }) + return { + listener, + accepted: () => accepted, + } +} + +function http() { + let requests = 0 + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requests++ + return new Response("sandbox-http-ok") + }, + }) + return { server, requests: () => requests } +} + +async function root() { + const dir = await mkdtemp(join(tmpdir(), "kilo-seatbelt-network-")) + roots.push(dir) + await mkdir(join(dir, ".protected")) + return dir +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe("macOS Seatbelt network integration", () => { + mac("allows a sandboxed child to exchange loopback TCP data in allow mode", async () => { + const dir = await root() + const tcp = server("127.0.0.1") + try { + const result = await launch(profile(dir, "allow"), { + command: "/usr/bin/nc", + args: ["127.0.0.1", String(tcp.listener.port)], + cwd: dir, + }) + expect(result.code).toBe(0) + expect(result.stdout).toBe("sandbox-tcp-ok") + expect(tcp.accepted()).toBe(1) + } finally { + tcp.listener.stop(true) + } + }) + + mac("allows a sandboxed child to listen for inbound loopback traffic in deny mode", async () => { + const dir = await root() + const probe = server("127.0.0.1") + const port = probe.listener.port + probe.listener.stop(true) + const target = await prepareLaunch(profile(dir, "deny"), { + command: "/usr/bin/nc", + args: ["-l", "127.0.0.1", String(port)], + cwd: dir, + }) + const child = Bun.spawn([target.command, ...target.args], { + cwd: target.cwd, + env: target.environment, + stdout: "pipe", + stderr: "pipe", + }) + const timeout = setTimeout(() => child.kill(), 5_000) + try { + const connected = await (async () => { + for (const _ of Array.from({ length: 100 })) { + const socket = await Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + open(socket) { + socket.write("sandbox-inbound-ok") + socket.end() + }, + data() {}, + error() {}, + }, + }).catch(() => undefined) + if (socket) return true + await Bun.sleep(20) + } + return false + })() + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(connected).toBe(true) + expect(code).toBe(0) + expect(stdout).toBe("sandbox-inbound-ok") + expect(stderr).toBe("") + } finally { + clearTimeout(timeout) + child.kill() + } + }) + + mac("denies a sandboxed child loopback TCP connection with a kernel permission error", async () => { + const dir = await root() + const tcp = server("127.0.0.1") + try { + const result = await launch(profile(dir, "deny"), { + command: "/usr/bin/nc", + args: ["-v", "127.0.0.1", String(tcp.listener.port)], + cwd: dir, + }) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain("Operation not permitted") + expect(tcp.accepted()).toBe(0) + } finally { + tcp.listener.stop(true) + } + }) + + mac("enforces allow and deny modes for child HTTP requests", async () => { + const dir = await root() + const allowed = http() + const denied = http() + try { + const allow = await launch(profile(dir, "allow"), { + command: "/usr/bin/curl", + args: ["--noproxy", "*", "-fsS", allowed.server.url.toString()], + cwd: dir, + }) + const deny = await launch(profile(dir, "deny"), { + command: "/usr/bin/curl", + args: ["--noproxy", "*", "-fsS", denied.server.url.toString()], + cwd: dir, + }) + expect(allow.code).toBe(0) + expect(allow.stdout).toBe("sandbox-http-ok") + expect(allowed.requests()).toBe(1) + expect(deny.code).not.toBe(0) + expect(denied.requests()).toBe(0) + } finally { + await Promise.all([allowed.server.stop(true), denied.server.stop(true)]) + } + }) + + mac("denies hostname and IPv6 loopback forms", async () => { + const dir = await root() + const ipv4 = server("127.0.0.1") + const ipv6 = server("::1") + try { + const localhost = await launch(profile(dir, "deny"), { + command: "/usr/bin/nc", + args: ["-v", "localhost", String(ipv4.listener.port)], + cwd: dir, + }) + const direct = await launch(profile(dir, "deny"), { + command: "/usr/bin/nc", + args: ["-v", "::1", String(ipv6.listener.port)], + cwd: dir, + }) + expect(localhost.code).not.toBe(0) + expect(localhost.stderr).toContain("Operation not permitted") + expect(direct.code).not.toBe(0) + expect(direct.stderr).toContain("Operation not permitted") + expect(ipv4.accepted()).toBe(0) + expect(ipv6.accepted()).toBe(0) + } finally { + ipv4.listener.stop(true) + ipv6.listener.stop(true) + } + }) + + for (const mode of ["allow", "deny"] as const) { + mac(`preserves filesystem policy in ${mode} network mode`, async () => { + const dir = await root() + const outside = join(await root(), `${mode}.txt`) + const allowed = join(dir, `${mode}.txt`) + const blocked = join(dir, ".protected", `${mode}.txt`) + const write = await launch(profile(dir, mode), { + command: "/bin/sh", + args: ["-c", 'printf allowed > "$1"', "sandbox-write", allowed], + cwd: dir, + }) + const deny = await launch(profile(dir, mode), { + command: "/bin/sh", + args: ["-c", 'printf blocked > "$1"', "sandbox-write", blocked], + cwd: dir, + }) + const escape = await launch(profile(dir, mode), { + command: "/bin/sh", + args: ["-c", 'printf blocked > "$1"', "sandbox-write", outside], + cwd: dir, + }) + expect(write.code).toBe(0) + expect(await readFile(allowed, "utf8")).toBe("allowed") + expect(deny.code).not.toBe(0) + expect(await Bun.file(blocked).exists()).toBe(false) + expect(escape.code).not.toBe(0) + expect(await Bun.file(outside).exists()).toBe(false) + }) + } +}) diff --git a/packages/kilo-sandbox/tsconfig.json b/packages/kilo-sandbox/tsconfig.json new file mode 100644 index 00000000000..a495f567aaa --- /dev/null +++ b/packages/kilo-sandbox/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"], + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 925c32cbe13..4ab768a0fee 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.3.8", + "version": "7.3.54", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/__tests__/telemetry-shutdown.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry-shutdown.test.ts new file mode 100644 index 00000000000..ed0782aa4ef --- /dev/null +++ b/packages/kilo-telemetry/src/__tests__/telemetry-shutdown.test.ts @@ -0,0 +1,52 @@ +// Isolated test file so `mock.module("posthog-node", ...)` registers before +// any import of `client.ts`. Living alongside `telemetry.test.ts` would let the +// top-level `Telemetry` import there resolve the real PostHog into the module +// cache before the mock is set, making the test rely on bun:test's cache +// invalidation timing rather than testing the shutdown path directly. +import { beforeEach, describe, test, expect, mock } from "bun:test" + +const timeout = "Timeout while shutting down PostHog. Some events may not have been sent." + +mock.module("posthog-node", () => ({ + PostHog: class { + async flush() { + flushCalls += 1 + throw new Error("flush should not be called") + } + async shutdown(timeoutMs?: number) { + shutdownCalls.push(timeoutMs) + throw timeout + } + optIn() {} + optOut() {} + capture() {} + alias() {} + }, +})) + +let flushCalls = 0 +const shutdownCalls: Array = [] + +describe("Telemetry.shutdown timeout (#9788)", () => { + beforeEach(() => { + flushCalls = 0 + shutdownCalls.length = 0 + }) + + test("passes timeoutMs through to PostHog.shutdown and skips unbounded explicit flush()", async () => { + // Reproduces the CLI exit hang reported in #9788: when the PostHog endpoint + // is unreachable (offline, firewall, DNS adblock resolving the host to + // 0.0.0.0), an explicit flush() call before shutdown retries 3x with 3s + // gaps plus 10s per attempt before throwing, blocking process.exit on + // short-lived commands like `kilo --help`. The fix drops the explicit + // flush() (PostHog.shutdown drains the queue itself) and threads a caller- + // supplied timeoutMs through to PostHog.shutdown. + const { Telemetry } = await import("../telemetry.js") + const { Client } = await import("../client.js") + Client.init() + await expect(Telemetry.shutdown(50)).rejects.toBe(timeout) + + expect(flushCalls).toBe(0) + expect(shutdownCalls).toEqual([50]) + }) +}) diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index ea5adaafd52..ff87bf69094 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -96,3 +96,4 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) }) + diff --git a/packages/kilo-telemetry/src/client.ts b/packages/kilo-telemetry/src/client.ts index 0b9f3d885b5..24c902b5435 100644 --- a/packages/kilo-telemetry/src/client.ts +++ b/packages/kilo-telemetry/src/client.ts @@ -68,12 +68,18 @@ export namespace Client { }) } - export async function shutdown(): Promise { + export async function shutdown(timeoutMs?: number): Promise { if (client) { - // Flush any pending events before shutdown - await client.flush() - await client.shutdown() - client = null + try { + // PostHog's shutdown drains the queue internally and is bounded by + // shutdownTimeoutMs. Calling flush() first is redundant and unbounded: + // when the endpoint is unreachable (offline, firewall, DNS adblock), + // flush retries up to 3x with 3s delays plus 10s per attempt before + // throwing, blocking process exit before shutdown's outer cap kicks in. + await client.shutdown(timeoutMs) + } finally { + client = null + } } } } diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index bb1c037d02d..21ea9043c81 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -285,7 +285,7 @@ export namespace Telemetry { track(TelemetryEvent.FEEDBACK_SUBMITTED, props) } - export async function shutdown(): Promise { - await Client.shutdown() + export async function shutdown(timeoutMs?: number): Promise { + await Client.shutdown(timeoutMs) } } diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 902e709ad2a..e30fbb37c0f 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.3.8", + "version": "7.3.54", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 7ea63c439e2..0111b6381a9 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -35,18 +35,29 @@ [data-slot="basic-tool-tool-subtitle"] { font-size: var(--kilo-font-size-12); + color: var(--text-weak); } [data-slot="basic-tool-tool-title"] { - font-size: var(--kilo-font-size-14); + font-size: var(--kilo-font-size-12); + font-weight: var(--font-weight-regular); + color: var(--text-weak); } [data-slot="message-part-title-text"] { - font-size: var(--kilo-font-size-14); + font-size: var(--kilo-font-size-12); + font-weight: var(--font-weight-regular); + color: var(--text-weak); + } + + [data-slot="basic-tool-tool-arg"] { + font-size: var(--kilo-font-size-12); + color: var(--text-weak); } [data-slot="message-part-title-filename"] { font-size: var(--kilo-font-size-12); + color: var(--text-weak); } [data-slot="message-part-meta-line"] { @@ -58,6 +69,7 @@ min-width: 0; flex-shrink: 1; font-size: var(--kilo-font-size-12); + color: var(--text-weak); &.clickable { cursor: pointer; @@ -108,37 +120,93 @@ html[data-theme="kilo-vscode"] [data-component="tool-trigger"] { align-items: center; justify-content: center; flex-shrink: 0; - color: var(--icon-base); + color: var(--text-weak); [data-component="icon"] { display: flex; + color: inherit; } } } /* Card styling for tool calls in the VS Code sidebar theme */ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-type="tool"] { - border-radius: var(--radius-md); - border: 1px solid var(--border-weak-base, var(--vscode-panel-border)); - overflow: hidden; + border-radius: 0; + border: 0; + overflow: visible; /* Header trigger — add bottom border when the collapsible is open */ [data-component="collapsible"].tool-collapsible { - gap: 0px; + gap: 0; border-radius: 0; + &:has([data-slot="collapsible-trigger"][aria-expanded="true"]) { + gap: 6px; + } + [data-slot="collapsible-trigger"] { - padding: 0 8px; - height: 36px; - background-color: var(--surface-inset-base, var(--vscode-sideBar-background)); + margin-inline: -6px; + width: calc(100% + 12px); + padding: 2px 6px; + min-height: 24px; + height: auto; + background-color: transparent; &:hover { - background-color: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground)); + background-color: transparent; } } + [data-slot="basic-tool-tool-trigger-content"] { + align-self: center; + gap: 6px; + } + + [data-slot="basic-tool-tool-info-structured"], + [data-slot="basic-tool-tool-info-main"] { + gap: 6px; + } + + [data-slot="basic-tool-tool-title"] + + :is( + [data-slot="basic-tool-tool-subtitle"], + [data-slot="basic-tool-tool-arg"], + [data-slot="message-part-meta-line"], + [data-slot="webfetch-meta"], + [data-component="shell-submessage"] + )::before { + content: "·"; + margin-right: 6px; + color: var(--text-weak); + } + + [data-slot="message-part-title-text"] + + :is([data-slot="message-part-meta-line"], [data-slot="basic-tool-tool-subtitle"])::before { + content: "·"; + margin-right: 6px; + color: var(--text-weak); + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-subtitle"], + [data-slot="basic-tool-tool-arg"], + [data-slot="message-part-title-text"], + [data-slot="message-part-title-filename"], + [data-slot="message-part-directory-inline"], + [data-slot="message-part-meta-line"] { + line-height: var(--kilo-font-size-16); + } + [data-slot="collapsible-trigger"][aria-expanded="true"] { - border-bottom: 1px solid var(--border-weak-base, var(--vscode-panel-border)); + border-bottom: 0; + } + + [data-slot="collapsible-trigger"]:has([data-slot="collapsible-arrow"]) { + border-radius: 4px; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; } /* Arrow always visible (no fade-on-hover) */ @@ -146,13 +214,32 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty opacity: 1; } - /* Arrow: DOWN when collapsed, UP when expanded (matches question-dock toggle) */ + /* Arrow: RIGHT when collapsed, DOWN when expanded. */ [data-slot="collapsible-arrow-icon"] { - transform: translateZ(0) rotate(0deg); + transform: translateZ(0) rotate(-90deg); } [data-slot="collapsible-trigger"][aria-expanded="true"] [data-slot="collapsible-arrow-icon"] { - transform: translateZ(0) rotate(180deg); + transform: translateZ(0) rotate(0deg); + } + + [data-slot="collapsible-trigger"]:has([data-slot="collapsible-arrow"]):hover { + background-color: var(--vscode-list-hoverBackground, var(--surface-raised-base-hover)); + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-subtitle"], + [data-slot="basic-tool-tool-arg"], + [data-slot="message-part-title-text"], + [data-slot="message-part-title-filename"], + [data-slot="message-part-directory-inline"], + [data-slot="message-part-meta-line"] { + color: var(--vscode-list-hoverForeground, var(--vscode-foreground, var(--text-strong))); + } + + [data-slot="basic-tool-icon"], + [data-slot="collapsible-arrow-icon"] { + color: var(--vscode-list-hoverForeground, var(--vscode-foreground, var(--icon-base))); + } } } @@ -174,6 +261,100 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty margin-top: 4px; } + [data-slot="mcp-section-label"] + [data-component="tool-output"] { + margin-inline: -6px; + width: calc(100% + 12px); + max-height: 240px; + padding: 8px 12px 10px; + border: 0; + border-radius: var(--radius-sm); + background: var(--surface-tool-output-base); + color: var(--text-weak); + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-16); + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + [data-component="markdown"] { + color: inherit; + font-size: inherit; + line-height: inherit; + } + + [data-component="markdown"] :is(a, code, span, [data-highlight="file"]) { + color: inherit; + } + } + + [data-component="context-tool-group-trigger"] { + min-height: 20px; + + [data-slot="context-tool-group-title"] { + gap: 6px; + font-size: var(--kilo-font-size-12); + font-weight: var(--font-weight-regular); + line-height: var(--kilo-font-size-16); + color: var(--text-weak); + } + + [data-slot="context-tool-group-summary"] { + color: var(--text-weak); + } + + [data-slot="context-tool-group-summary"]::before { + content: "·"; + margin-right: 6px; + color: var(--text-weak); + } + } + + [data-component="context-tool-expanded-list"] { + max-height: 180px; + overflow-y: auto; + padding: 4px 0; + border: 1px solid var(--border-weak-base); + background: color-mix(in srgb, var(--vscode-terminal-background, var(--vscode-panel-background)) 92%, black 8%); + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + [data-component="context-tool-expanded-row"], + [data-component="context-tool-rolling-row"] { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: baseline; + column-gap: 8px; + min-height: 20px; + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-16); + } + + [data-component="context-tool-expanded-row"] { + padding: 2px 12px; + } + + [data-slot="context-tool-expanded-action"], + [data-slot="context-tool-rolling-action"] { + color: var(--text-weak); + } + + [data-slot="context-tool-expanded-detail"], + [data-slot="context-tool-rolling-detail"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-weak); + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + } + /* Expandable tool output content */ [data-component="tool-output"] { padding: 8px 12px; @@ -186,4 +367,45 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty overflow-y: auto; } } + + [data-component="tool-output"][data-variant="preview"] { + margin-inline: -6px; + width: calc(100% + 12px); + max-height: 240px; + padding: 8px 12px 10px; + border: 0; + border-radius: var(--radius-sm); + background: var(--surface-tool-output-base); + color: var(--text-weak); + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--kilo-font-size-12); + line-height: var(--line-height-large); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: normal; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + [data-component="markdown"] { + color: inherit; + font: inherit; + line-height: inherit; + white-space: inherit; + overflow-wrap: inherit; + } + + [data-component="markdown"] :is(a, code, span, [data-highlight="file"]) { + color: inherit; + } + + [data-component="markdown"] > *, + [data-component="markdown"] p, + [data-component="markdown"] pre { + margin: 0; + } + } } diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index 1cf5a25bf1f..0f4234ad1a4 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -11,9 +11,15 @@ export interface BasicToolProps extends BaseProps { partID?: string } +type OpenProps = Pick + +export function initialOpen(props: OpenProps) { + return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen) +} + export function BasicTool(props: BasicToolProps) { const key = () => toolOpenKey(props) - const initial = () => (props.forceOpen ? true : readToolOpen(key(), props.defaultOpen)) + const initial = () => initialOpen(props) return ( (props: SSRDiffProps) { const [local, others] = splitProps(props, [ "before", "after", + "patch", + "fileDiff", "class", "classList", "annotations", "selectedLines", "commentedLines", + "virtualized", ]) const workerPool = useWorkerPool(props.diffStyle) @@ -227,7 +230,7 @@ export function Diff(props: SSRDiffProps) { onCleanup(() => monitor.disconnect()) } - const virtualizer = getVirtualizer() + const virtualizer = local.virtualized === false ? undefined : getVirtualizer() fileDiffInstance = virtualizer ? new VirtualizedFileDiff( @@ -250,9 +253,12 @@ export function Diff(props: SSRDiffProps) { ) // @ts-expect-error - fileContainer is private but needed for SSR hydration fileDiffInstance.fileContainer = fileDiffRef + const patch = "patch" in local && typeof local.patch === "string" ? local.patch : "" + const metadata = local.fileDiff ?? (patch ? processFile(patch, { cacheKey: patch }) : undefined) fileDiffInstance.hydrate({ - oldFile: local.before, - newFile: local.after, + oldFile: metadata ? undefined : local.before, + newFile: metadata ? undefined : local.after, + fileDiff: metadata, lineAnnotations: local.annotations, fileContainer: fileDiffRef, containerWrapper: container, diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index febb70ca813..947cb2b4dc3 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -1,5 +1,12 @@ import { sampledChecksum } from "@opencode-ai/core/util/encode" -import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" +import { + FileDiff, + type FileDiffMetadata, + type FileDiffOptions, + processFile, + type SelectedLineRange, + VirtualizedFileDiff, +} from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" @@ -142,6 +149,7 @@ export function Diff(props: DiffProps) { let container!: HTMLDivElement let observer: MutationObserver | undefined let sharedVirtualizer: NonNullable> | undefined + let parsed: { patch: string; diff: FileDiffMetadata } | undefined let renderToken = 0 let selectionFrame: number | undefined let dragFrame: number | undefined @@ -156,6 +164,7 @@ export function Diff(props: DiffProps) { const [local, others] = splitProps(props, [ "before", "after", + "patch", "fileDiff", "class", "classList", @@ -163,6 +172,7 @@ export function Diff(props: DiffProps) { "selectedLines", "commentedLines", "onRendered", + "virtualized", ]) const mobile = createMediaQuery("(max-width: 640px)") @@ -178,11 +188,24 @@ export function Diff(props: DiffProps) { }) const estimate = createMemo(() => { - const value = Math.max(lines(before()), lines(after())) * ESTIMATED_LINE_HEIGHT + // A tracked detail response already carries a hunk-bounded git patch. Base + // placeholder height on that patch instead of the full source file so a + // tiny change in a large file does not reserve a large gray body. + const patch = "patch" in local && typeof local.patch === "string" ? local.patch : "" + const value = (patch ? lines(patch) : Math.max(lines(before()), lines(after()))) * ESTIMATED_LINE_HEIGHT if (value === 0) return MIN_PLACEHOLDER_HEIGHT return Math.max(MIN_PLACEHOLDER_HEIGHT, Math.min(value, MAX_PLACEHOLDER_HEIGHT)) }) + const patchDiff = () => { + if (!("patch" in local) || typeof local.patch !== "string" || local.patch.length === 0) return + if (parsed?.patch === local.patch) return parsed.diff + const diff = processFile(local.patch, { cacheKey: local.patch }) + if (!diff) return + parsed = { patch: local.patch, diff } + return diff + } + const large = createMemo(() => { return Math.max(before().length, after().length) > 500_000 }) @@ -200,7 +223,7 @@ export function Diff(props: DiffProps) { } const perf = large() ? { ...base, ...largeOptions } : base - if (!mobile()) return perf + if (!mobile() || props.disableLineNumbers === false) return perf return { ...perf, @@ -681,8 +704,19 @@ export function Diff(props: DiffProps) { const opts = options() const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) - const virtualizer = getVirtualizer() + // Eager (non-virtualized) patch-backed diffs render their visible hunks once + // and never re-render on scroll or height changes, avoiding Pierre's + // re-render-all storms. Full-content or oversized diffs keep virtualizing. + const virtualizer = local.virtualized === false ? undefined : getVirtualizer() + if (local.virtualized === false && sharedVirtualizer) { + sharedVirtualizer.release() + sharedVirtualizer = undefined + } const annotations = untrack(() => local.annotations) + // Parse hunk-bounded patches only after the deferred visibility gate. This + // preserves quick session switching while avoiding a full before/after diff + // reconstruction for tiny changes inside large source files. + const metadata = local.fileDiff ?? patchDiff() // Preserve container height during re-render to prevent scroll jumps. // When Pierre tears down the DOM (innerHTML = ""), the container collapses @@ -699,9 +733,9 @@ export function Diff(props: DiffProps) { container.innerHTML = "" - if (local.fileDiff) { + if (metadata) { instance.render({ - fileDiff: local.fileDiff, + fileDiff: metadata, lineAnnotations: annotations, containerWrapper: container, }) diff --git a/packages/kilo-ui/src/components/error-details.css b/packages/kilo-ui/src/components/error-details.css index 259150984e0..88f97120a2e 100644 --- a/packages/kilo-ui/src/components/error-details.css +++ b/packages/kilo-ui/src/components/error-details.css @@ -1,42 +1,92 @@ .error-card { - padding-bottom: 0; - background-color: var(--surface-critical-base); + gap: 8px; + padding-bottom: 12px; + --error-card-accent: var(--text-on-critical-base); +} + +.error-card-body { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.error-card-body [data-component="icon"] { + color: var(--error-card-accent); + margin-top: 2px; +} + +.error-card-message { + flex: 1; + min-width: 0; + color: var(--text-strong); + font-size: var(--font-size-base); + line-height: var(--line-height-large); + overflow-wrap: anywhere; } .error-card [data-component="collapsible"] { - margin-top: 8px; + margin-top: 0; + padding-left: 24px; } -.error-details-trigger { +.error-card .error-details-trigger[data-slot="collapsible-trigger"] { display: inline-flex; align-items: center; - gap: 4px; + align-self: flex-start; + gap: 2px; + width: auto; + height: 22px; font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); opacity: 0.85; cursor: pointer; background: none; border: none; - color: inherit; - padding: 0; + border-radius: var(--radius-sm); + color: var(--error-card-accent); + padding: 0 6px; } -.error-details-trigger:hover { +.error-card .error-details-trigger[data-slot="collapsible-trigger"]:hover { + background-color: color-mix(in srgb, var(--error-card-accent) 12%, transparent); opacity: 1; } +.error-card .error-details-trigger[data-slot="collapsible-trigger"]:focus-visible { + background-color: color-mix(in srgb, var(--error-card-accent) 12%, transparent); + outline: 1px solid var(--border-focus); + outline-offset: 2px; +} + +.error-card .error-details-trigger [data-slot="collapsible-arrow"] { + width: 16px; + height: 16px; + opacity: 1; +} + +.error-card .error-details-trigger [data-slot="collapsible-arrow-icon"] { + color: currentColor; +} + .error-details { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; font-size: var(--font-size-small); - margin-top: 4px; + margin-top: 8px; } .error-detail-pre { margin: 0; max-height: 120px; overflow-y: auto; + background-color: color-mix(in srgb, var(--surface-inset-base) 82%, var(--background-base)); + border: 1px solid color-mix(in srgb, var(--border-critical-base) 30%, var(--border-weaker-base)); + border-radius: var(--radius-sm); + color: var(--text-base); font-size: var(--font-size-small); + line-height: var(--line-height-large); + padding: 8px; white-space: pre-wrap; word-break: break-all; flex: 1; diff --git a/packages/kilo-ui/src/components/error-details.tsx b/packages/kilo-ui/src/components/error-details.tsx index c720bd80f8c..25f379468cc 100644 --- a/packages/kilo-ui/src/components/error-details.tsx +++ b/packages/kilo-ui/src/components/error-details.tsx @@ -11,7 +11,9 @@ export function ErrorDetails(props: ErrorDetailsProps) { return (
        -
        {raw()}
        +
        +        {raw()}
        +      
        ) } diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index ff3335bea0a..01fb920cf51 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -2,6 +2,10 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon" import { splitProps } from "solid-js" const icons: Record = { + "book-open-check": { + viewBox: "0 0 24 24", + path: ``, + }, "circuit-board": { viewBox: "0 0 16 16", path: ``, diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 9af62a9b949..ffb4ae84ec4 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -210,30 +210,72 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { border-radius: 0; - background: var(--vscode-terminal-background, var(--vscode-panel-background)); + border: 0; + background: transparent; + overflow: visible; - [data-slot="bash-pre"] code { - color: var(--vscode-terminal-foreground, var(--vscode-editor-foreground)); + [data-slot="bash-terminal"] { + border: 0; + border-radius: var(--radius-sm); + background: var(--surface-tool-output-base); } - [data-slot="bash-divider"] { - background: var(--vscode-panel-border, var(--border-weak-base)); + [data-slot="bash-pre"] code, + .shiki code { + color: var(--text-weak); } } -/* Bash tool: section layout (command + output) */ +/* Bash tool: static terminal preview with separate command/output surfaces. */ +[data-component="bash-output"] { + display: flex; + flex-direction: column; + gap: 4px; + margin-inline: -6px; + width: calc(100% + 12px); + border: 0; + border-radius: 0; + background: transparent; + overflow: visible; +} + +[data-component="bash-output"] [data-slot="bash-terminal"] { + border-radius: 0; + border: 1px solid var(--border-weak-base); + background: var(--surface-raised-base); + overflow: hidden; +} + [data-component="bash-output"] [data-slot="bash-section"] { position: relative; } +[data-component="bash-output"] [data-slot="bash-section"][data-kind="command"] { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: start; + column-gap: 8px; + padding: 8px 12px 7px; +} + +[data-component="bash-output"] [data-slot="bash-section"][data-kind="output"] { + padding: 8px 12px 10px; +} + +[data-component="bash-output"] [data-slot="bash-prompt"] { + color: var(--text-weak, var(--vscode-descriptionForeground)); + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--kilo-font-size-12); + line-height: var(--line-height-large); + user-select: none; +} + [data-component="bash-output"] [data-slot="bash-section-code"] { + min-width: 0; overflow-y: auto; overflow-x: hidden; max-height: 240px; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } } [data-component="bash-output"] [data-slot="bash-section-actions"] { @@ -254,25 +296,29 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { pointer-events: auto; } -/* Edge-to-edge divider (matches MCP tool divider) */ -[data-component="bash-output"] [data-slot="bash-divider"] { - height: 1px; - background: var(--border-weak-base); +[data-component="bash-output"] [data-slot="bash-pre"] { + margin: 0; + padding: 0; +} + +[data-component="bash-output"] .shiki { + margin: 0; + padding: 0; + background: transparent !important; } -/* Shiki syntax-highlighted shell output */ -[data-component="bash-output"] .shiki, [data-component="shell-expanded-output"] .shiki { margin: 0; padding: 12px; background: transparent !important; } +[data-component="bash-output"] [data-slot="bash-pre"] code, [data-component="bash-output"] .shiki code, [data-component="shell-expanded-output"] .shiki code { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); + font-size: var(--kilo-font-size-12); line-height: var(--line-height-large); white-space: pre-wrap; overflow-wrap: anywhere; @@ -292,26 +338,50 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { [data-slot="message-part-tool-error-content"] { display: flex; - align-items: start; - gap: 8px; - } - - [data-slot="message-part-tool-error-title"] { + flex: 1; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 8px; + min-width: 0; font-family: var(--font-family-sans); font-size: var(--font-size-base); font-style: normal; - font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="message-part-tool-error-heading"] { + display: inline-flex; + flex: none; + align-items: baseline; + gap: 8px; + white-space: nowrap; + } + + [data-slot="message-part-tool-error-title"] { + font-weight: var(--font-weight-medium); color: var(--text-on-critical-base); white-space: nowrap; } + [data-slot="message-part-tool-error-code"] { + color: var(--text-on-critical-weak); + } + [data-slot="message-part-tool-error-message"] { color: var(--text-on-critical-weak); + flex: 1 0 auto; + min-width: 0; + max-width: 100%; max-height: 240px; overflow-y: auto; - word-break: break-word; + overflow-wrap: anywhere; + } +} + +@media (max-width: 240px) { + [data-component="tool-error"] > [data-component="icon"] { + display: none; } } @@ -356,6 +426,32 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } } +html[data-theme="kilo-vscode"] [data-component="todos"] { + margin-inline: -6px; + width: calc(100% + 12px); + padding: 8px 12px 10px; + border: 0; + border-radius: var(--radius-sm); + background: var(--surface-tool-output-base); + color: var(--text-weak); + + [data-slot="checkbox-checkbox-label"] { + color: var(--text-weak); + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-16); + } + + [data-slot="message-part-todo-hidden"] { + color: var(--text-weaker); + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-16); + } + + [data-slot="message-part-todo-content"][data-changed="changed"] { + color: var(--text-base); + } +} + /* User message copy button: collapse wrapper when idle, show on hover. * The wrapper must be tall enough to fully contain the button (20px) * so that hovering the button keeps [data-component="user-message"] @@ -490,6 +586,100 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } } +html[data-theme="kilo-vscode"] [data-component="text-part"] { + margin-top: 0; +} + +html[data-theme="kilo-vscode"] [data-component="reasoning-part"] { + border-left: 0; + border-radius: 0; + overflow: visible; + + [data-component="collapsible"].tool-collapsible { + gap: 0; + border-radius: 0; + + &:has([data-slot="collapsible-trigger"][aria-expanded="true"]) { + gap: 6px; + } + } + + [data-slot="collapsible-trigger"] { + margin-inline: -6px; + width: calc(100% + 12px); + min-height: 24px; + height: auto; + padding: 2px 6px; + background: transparent; + border-radius: 4px; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background-color: var(--vscode-list-hoverBackground, var(--surface-raised-base-hover)); + } + } + + [data-slot="collapsible-arrow"] { + opacity: 1; + } + + [data-slot="reasoning-header"] { + gap: 6px; + color: var(--text-weak); + font-size: var(--kilo-font-size-12); + font-weight: var(--font-weight-regular); + line-height: var(--kilo-font-size-16); + + [data-component="icon"] { + color: var(--text-weak); + } + } + + [data-slot="reasoning-label"], + [data-slot="reasoning-title"] { + color: var(--text-weak); + font-weight: var(--font-weight-regular); + line-height: var(--kilo-font-size-16); + } + + [data-slot="reasoning-label"] + [data-slot="reasoning-title"]::before { + content: "·"; + margin-right: 6px; + color: var(--text-weak); + } + + [data-slot="collapsible-trigger"]:hover { + [data-slot="reasoning-label"], + [data-slot="reasoning-title"] { + color: var(--vscode-list-hoverForeground, var(--vscode-foreground, var(--text-strong))); + } + + [data-slot="reasoning-header"] [data-component="icon"], + [data-slot="collapsible-arrow-icon"] { + color: var(--vscode-list-hoverForeground, var(--vscode-foreground, var(--icon-base))); + } + } + + [data-slot="collapsible-content"] { + margin-inline: -6px; + width: calc(100% + 12px); + } + + [data-slot="reasoning-content"] { + padding: 8px 12px 10px; + border: 0; + border-radius: var(--radius-sm); + background: var(--surface-tool-output-base); + + [data-component="markdown"] { + font-size: var(--kilo-font-size-12); + line-height: 160%; + } + } +} + @keyframes reasoning-pulse { 0%, 100% { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 6e7905c478a..d8a448ed0ed 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -348,7 +348,7 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell if (tool === "edit" || tool === "write") return edit - if (tool === "apply_patch") return false + if (tool === "apply_patch") return edit } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -731,6 +731,9 @@ export function UserMessageDisplay(props: { interrupted?: boolean animate?: boolean queued?: boolean + text?: string + copyText?: string + header?: JSX.Element onFork?: () => void onRevert?: () => void }) { @@ -743,7 +746,7 @@ export function UserMessageDisplay(props: { () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, ) - const text = createMemo(() => textPart()?.text || "") + const text = createMemo(() => props.text ?? textPart()?.text ?? "") const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) @@ -767,7 +770,7 @@ export function UserMessageDisplay(props: { const providerID = props.message.model?.providerID const modelID = props.message.model?.modelID if (!providerID || !modelID) return "" - const match = data.store.provider?.all?.find((p) => p.id === providerID) + const match = data.store.provider?.all?.get(providerID) return match?.models?.[modelID]?.name ?? modelID }) @@ -797,7 +800,7 @@ export function UserMessageDisplay(props: { } const handleCopy = async () => { - const content = text() + const content = props.copyText ?? text() if (!content) return await navigator.clipboard.writeText(content) setCopied(true) @@ -840,12 +843,15 @@ export function UserMessageDisplay(props: { - + <>
        -
        - -
        + {props.header} + +
        + +
        +
        @@ -1240,6 +1246,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { ) } const [title, ...rest] = cleaned.split(": ") + const message = rest.join(": ") + const status = message.match(/^(\d{3})(?:\s+|$)/) + const code = status?.[1] + const detail = code ? message.slice(code.length).trimStart() : message return (
        @@ -1247,8 +1257,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
        -
        {title}
        - {rest.join(": ")} +
        +
        {title}
        + + {code} + +
        + + {detail} +
        @@ -1377,7 +1394,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
        - +
        @@ -1575,7 +1592,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
        - +
        @@ -1835,7 +1852,7 @@ ToolRegistry.register({ > {(output) => ( -
        +
        )} @@ -1866,7 +1883,7 @@ ToolRegistry.register({ > {(output) => ( -
        +
        )} @@ -1900,7 +1917,7 @@ ToolRegistry.register({ > {(output) => ( -
        +
        )} @@ -2145,34 +2162,36 @@ function BashHighlightedOutput(props: { cmd: string; output: string; outputPath? return (
        -
        {i18n.t("ui.messagePart.shell.command")}
        -
        -
        -
        - props.cmd} label={i18n.t("ui.message.copy")} /> +
        +
        + +
        +
        + props.cmd} label={i18n.t("ui.message.copy")} /> +
        - -
        - -
        {i18n.t("ui.messagePart.shell.output")}
        -
        -
        -
        - - - - - - props.output} label={i18n.t("ui.message.copy")} /> +
        +
        +
        +
        + + + + + + props.output} label={i18n.t("ui.message.copy")} /> +
        @@ -2189,6 +2208,11 @@ ToolRegistry.register({ const subtitle = () => props.input.description ?? props.metadata.description const key = () => toolOpenKey(props) const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true) + const [mounted, setMounted] = createSignal(open()) + + createEffect(() => { + if (open() || pending()) setMounted(true) + }) // also apply processCarriageReturns for Windows CLI tools const cmd = createMemo(() => { @@ -2207,7 +2231,7 @@ ToolRegistry.register({ } > - + + + ) }, @@ -2293,6 +2319,7 @@ ToolRegistry.register({ {...props} icon="code-lines" defer + hasDetails trigger={
        @@ -2407,6 +2434,7 @@ ToolRegistry.register({ {...props} icon="code-lines" defer + hasDetails trigger={
        @@ -2539,6 +2567,7 @@ ToolRegistry.register({ {...props} icon="code-lines" defer + hasDetails trigger={
        diff --git a/packages/kilo-ui/src/components/session-diff.test.ts b/packages/kilo-ui/src/components/session-diff.test.ts index b576d28ffdc..0f6a39b668b 100644 --- a/packages/kilo-ui/src/components/session-diff.test.ts +++ b/packages/kilo-ui/src/components/session-diff.test.ts @@ -35,6 +35,14 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("two\n") }) + test("handles legacy snapshots without a file path", () => { + const view = normalize({ additions: 0, deletions: 0 }) + + expect(view.file).toBe("") + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) + test("preserves real line numbers from hunk headers without padding", () => { const diff = { file: "a.ts", diff --git a/packages/kilo-ui/src/components/session-diff.ts b/packages/kilo-ui/src/components/session-diff.ts index de8528d076d..c471f47bae4 100644 --- a/packages/kilo-ui/src/components/session-diff.ts +++ b/packages/kilo-ui/src/components/session-diff.ts @@ -53,15 +53,18 @@ function reconstruct(patch: string) { type DiffText = { before: string; after: string; patch: string } +function name(diff: ReviewDiff) { + return diff.file ?? "" +} + function contents(diff: ReviewDiff): DiffText { if (typeof diff.patch === "string") { return { ...reconstruct(diff.patch), patch: diff.patch } } const before = "before" in diff && typeof diff.before === "string" ? diff.before : "" const after = "after" in diff && typeof diff.after === "string" ? diff.after : "" - const patch = formatPatch( - structuredPatch(diff.file, diff.file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }), - ) + const file = name(diff) + const patch = formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) return { before, after, patch } } @@ -69,9 +72,9 @@ function fileDiffFor(diff: ReviewDiff, view: DiffText): FileDiffMetadata { const hit = cache.get(view.patch) if (hit) return hit const fromPatch = typeof diff.patch === "string" ? processFile(diff.patch, { cacheKey: diff.patch }) : undefined + const file = name(diff) const value = - fromPatch ?? - parseDiffFromFile({ name: diff.file, contents: view.before }, { name: diff.file, contents: view.after }) + fromPatch ?? parseDiffFromFile({ name: file, contents: view.before }, { name: file, contents: view.after }) cache.set(view.patch, value) return value } @@ -79,7 +82,7 @@ function fileDiffFor(diff: ReviewDiff, view: DiffText): FileDiffMetadata { export function normalize(diff: ReviewDiff): ViewDiff { const view = contents(diff) return { - file: diff.file, + file: name(diff), patch: view.patch, before: view.before, after: view.after, diff --git a/packages/kilo-ui/src/hooks/auto-scroll.ts b/packages/kilo-ui/src/hooks/auto-scroll.ts new file mode 100644 index 00000000000..3f588b49591 --- /dev/null +++ b/packages/kilo-ui/src/hooks/auto-scroll.ts @@ -0,0 +1,3 @@ +export const distanceFromBottom = (el: HTMLElement) => el.scrollHeight - el.clientHeight - el.scrollTop + +export const canScroll = (el: HTMLElement) => el.scrollHeight - el.clientHeight > 1 diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx new file mode 100644 index 00000000000..a6cc29d0b26 --- /dev/null +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -0,0 +1,186 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { createRoot } from "solid-js" + +const observers: Array<() => void> = [] + +mock.module("@solid-primitives/resize-observer", () => ({ + createResizeObserver: (_source: () => HTMLElement | undefined, callback: () => void) => { + observers.push(callback) + }, +})) + +const originalElement = globalThis.Element +const originalWheelEvent = globalThis.WheelEvent + +type Listener = { + callback: (event: Event) => void + capture: boolean +} + +class FakeElement { + scrollHeight = 100 + clientHeight = 100 + scrollTop = 0 + style = { overflowAnchor: "" } + private listeners = new Map() + + closest() { + return null + } + + scrollTo(options: ScrollToOptions) { + this.scrollTop = options.top ?? this.scrollTop + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) { + const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event) + const capture = typeof options === "boolean" ? options : (options?.capture ?? false) + const listeners = this.listeners.get(type) ?? [] + listeners.push({ callback, capture }) + this.listeners.set(type, listeners) + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ) { + const capture = typeof options === "boolean" ? options : (options?.capture ?? false) + const listeners = this.listeners.get(type) ?? [] + this.listeners.set( + type, + listeners.filter((item) => item.callback !== listener || item.capture !== capture), + ) + } + + fire(type: string, event: Event) { + const listeners = this.listeners.get(type) ?? [] + for (const item of listeners.toSorted((a, b) => Number(b.capture) - Number(a.capture))) { + item.callback(event) + } + } +} + +class FakeWheelEvent { + constructor( + readonly deltaY: number, + readonly target: FakeElement, + ) {} +} + +globalThis.Element = FakeElement as unknown as typeof Element +globalThis.WheelEvent = FakeWheelEvent as unknown as typeof WheelEvent + +const { createAutoScroll } = await import("./create-auto-scroll") + +function setup(options?: { interacted?: () => void }) { + const el = new FakeElement() + const root = createRoot((dispose) => ({ + dispose, + scroll: createAutoScroll({ + working: () => false, + onUserInteracted: options?.interacted, + }), + })) + root.scroll.scrollRef(el as unknown as HTMLElement) + root.scroll.contentRef(new FakeElement() as unknown as HTMLElement) + + const resize = (index?: number) => { + if (index !== undefined) { + observers[index]?.() + return + } + observers.forEach((callback) => callback()) + } + + return { ...root, el, resize } +} + +beforeEach(() => { + observers.length = 0 +}) + +afterAll(() => { + if (originalElement) globalThis.Element = originalElement + else Reflect.deleteProperty(globalThis, "Element") + if (originalWheelEvent) globalThis.WheelEvent = originalWheelEvent + else Reflect.deleteProperty(globalThis, "WheelEvent") +}) + +describe("createAutoScroll non-scrollable layouts", () => { + test("preserves an established pause through temporary non-overflow", () => { + const ctx = setup() + ctx.el.scrollHeight = 300 + ctx.el.scrollTop = 80 + ctx.scroll.pause() + + ctx.el.scrollHeight = 100 + ctx.el.scrollTop = 0 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.resize(0) + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.resize(1) + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollHeight = 300 + ctx.el.scrollTop = 80 + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(80) + ctx.dispose() + }) + + test("allows session restoration to pause before content overflows", () => { + const ctx = setup() + ctx.scroll.pause() + + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollHeight = 300 + ctx.el.scrollTop = 60 + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(60) + + ctx.scroll.resume() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(300) + ctx.dispose() + }) + + test("does not pause for an upward wheel on short content", () => { + let interactions = 0 + const ctx = setup({ interacted: () => interactions++ }) + const event = new FakeWheelEvent(-20, ctx.el) + + ctx.el.fire("wheel", event as unknown as Event) + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(interactions).toBe(0) + ctx.dispose() + }) + + test("follows when initially short content starts overflowing", () => { + const ctx = setup() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(false) + + ctx.el.scrollHeight = 300 + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(300) + ctx.dispose() + }) +}) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index fcef54cb27c..94c28bd4b6f 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -1,12 +1,13 @@ import { createEffect, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { canScroll, distanceFromBottom } from "./auto-scroll" +import { createUserActivity } from "./scroll-user-activity" const DEBOUNCE_MS = 100 -// Grace window after a real user interaction (wheel/pointer/key/touch) during -// which a ResizeObserver or non-user scroll event must not snap the view back -// to the bottom. Long enough to cover a single scroll gesture plus the -// DEBOUNCE_MS window used by handleScroll to flip userScrolled. +// Grace window after a real pointer/key/touch interaction during which a +// ResizeObserver or non-user scroll event must not snap the view back to the +// bottom. Upward wheel intent pauses immediately in its capture handler. const USER_INTERACTION_GRACE_MS = 300 export interface AutoScrollOptions { @@ -16,129 +17,107 @@ export interface AutoScrollOptions { } export function createAutoScroll(options: AutoScrollOptions) { + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + let scroll: HTMLElement | undefined let settling = false let settleTimer: ReturnType | undefined let stopTimer: ReturnType | undefined let cleanup: (() => void) | undefined - let userInitiated = false - let lastScrollTop: number | undefined - let lastInteraction = 0 - - const threshold = () => options.bottomThreshold ?? 10 const [store, setStore] = createStore({ contentRef: undefined as HTMLElement | undefined, + scrollRef: undefined as HTMLElement | undefined, userScrolled: false, }) - const active = () => options.working() || settling - - const distanceFromBottom = (el: HTMLElement) => { - return el.scrollHeight - el.clientHeight - el.scrollTop - } - - const canScroll = (el: HTMLElement) => { - return el.scrollHeight - el.clientHeight > 1 - } + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- - const markUser = (e: Event) => { - if (e instanceof WheelEvent) { - const target = e.target instanceof Element ? e.target : undefined - const nested = target?.closest("[data-scrollable]") - if (scroll && nested && nested !== scroll) return - } - userInitiated = true - lastInteraction = performance.now() - } - - const recentlyInteracted = () => - lastInteraction > 0 && performance.now() - lastInteraction < USER_INTERACTION_GRACE_MS - - const scrollToBottomNow = (behavior: ScrollBehavior) => { - const el = scroll - if (!el) return - if (behavior === "smooth") { - el.scrollTo({ top: el.scrollHeight, behavior }) - return - } + const threshold = () => options.bottomThreshold ?? 10 + const active = () => options.working() || settling + const bottom = () => { + if (!scroll) return // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. - el.scrollTop = el.scrollHeight - lastScrollTop = el.scrollTop + scroll.scrollTop = scroll.scrollHeight } - const scrollToBottom = (force: boolean) => { - if (!force && !active()) return - const el = scroll - if (!el) return - - if (!force && store.userScrolled) return - if (force && store.userScrolled) setStore("userScrolled", false) + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- - const distance = distanceFromBottom(el) - if (distance < 2) return + const follow = () => { + if (!active() || store.userScrolled) return + if (!scroll || distanceFromBottom(scroll) < 2) return // For auto-following content we prefer immediate updates to avoid // visible "catch up" animations while content is still settling. - scrollToBottomNow("auto") + bottom() } - const stop = () => { - const el = scroll - if (!el) return - if (!canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } - if (store.userScrolled) return + const force = () => { + if (!scroll) return + if (store.userScrolled) setStore("userScrolled", false) + if (distanceFromBottom(scroll) < 2) return + bottom() + } + + const resume = () => { + if (store.userScrolled) setStore("userScrolled", false) + force() + } + const pause = () => { + if (!scroll || store.userScrolled) return setStore("userScrolled", true) options.onUserInteracted?.() } - const handleWheel = (e: WheelEvent) => { - if (e.deltaY >= 0) return - // If the user is scrolling within a nested scrollable region (tool output, - // code block, etc), don't treat it as leaving the "follow bottom" mode. - // Those regions opt in via `data-scrollable`. - const el = scroll - const target = e.target instanceof Element ? e.target : undefined - const nested = target?.closest("[data-scrollable]") - if (el && nested && nested !== el) return - stop() + const stop = () => { + if (!scroll || !canScroll(scroll)) return + pause() } + // --------------------------------------------------------------------------- + // User activity + // --------------------------------------------------------------------------- + + const userActivity = createUserActivity({ + grace: USER_INTERACTION_GRACE_MS, + // Upward wheel input anywhere in the transcript expresses the user's + // intent to review earlier content, even when a nested region consumes it. + onWheelUp: stop, + }) + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + const handleScroll = () => { - const el = scroll - if (!el) return + if (!scroll) return - const byUser = userInitiated - userInitiated = false - const distance = distanceFromBottom(el) + const input = userActivity.consumeScroll() + const distance = distanceFromBottom(scroll) - if (!canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } + if (!canScroll(scroll)) return if (distance < threshold()) { if (store.userScrolled) setStore("userScrolled", false) - lastScrollTop = el.scrollTop return } - if (!store.userScrolled && !byUser) { - // virtua fires programmatic scroll events as it measures virtualized - // items. Don't let those snap the view back to the bottom while the - // user is mid-gesture — the wheel event fires before the scroll event, - // so `recentlyInteracted()` is reliable here. - if (el.scrollTop < (lastScrollTop ?? el.scrollTop) || recentlyInteracted()) { + if (!store.userScrolled && !input) { + // Only explicit user input can pause following. Treat unclassified + // scroll events from virtualization or layout changes as programmatic. + if (userActivity.isRecent()) { stop() } else { - scrollToBottomNow("auto") + bottom() } - lastScrollTop = el.scrollTop return } @@ -147,51 +126,52 @@ export function createAutoScroll(options: AutoScrollOptions) { if (stopTimer) clearTimeout(stopTimer) stopTimer = setTimeout(() => { stopTimer = undefined - const cur = scroll - if (!cur) return - if (distanceFromBottom(cur) < threshold()) return + if (!scroll) return + if (distanceFromBottom(scroll) < threshold()) return stop() }, DEBOUNCE_MS) } - const handleInteraction = () => { - if (!active()) return - stop() - } - - createResizeObserver( - () => store.contentRef, - () => { - const el = scroll - if (el && !canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } - if (!active()) { - if (!store.userScrolled && el && distanceFromBottom(el) > threshold()) { - scrollToBottomNow("auto") - return - } - return - } - if (store.userScrolled) { + const onContentResize = () => { + if (scroll && !canScroll(scroll)) return + if (!active()) { + if (!store.userScrolled && scroll && distanceFromBottom(scroll) > threshold()) { + bottom() return } - // Virtualized lists (virtua) re-measure items during user scroll, firing - // resize events that race ahead of handleScroll's DEBOUNCE_MS window. - // If the user just interacted with the scroller and is no longer near - // the bottom, treat the resize as a layout reflow on top of their - // scroll — pause auto-follow instead of snapping back to the bottom. - if (el && recentlyInteracted() && distanceFromBottom(el) > threshold()) { - stop() - return - } - // ResizeObserver fires after layout, before paint. - // Keep the bottom locked in the same frame to avoid visible - // "jump up then catch up" artifacts while streaming content. - scrollToBottom(false) - }, - ) + return + } + if (store.userScrolled) { + return + } + // Virtualized lists (virtua) re-measure items during user scroll, firing + // resize events that race ahead of handleScroll's DEBOUNCE_MS window. + // If the user just interacted with the scroller and is no longer near + // the bottom, treat the resize as a layout reflow on top of their + // scroll — pause auto-follow instead of snapping back to the bottom. + if (scroll && userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) { + stop() + return + } + // ResizeObserver fires after layout, before paint. + // Keep the bottom locked in the same frame to avoid visible + // "jump up then catch up" artifacts while streaming content. + follow() + } + + const onViewportResize = () => { + if (!scroll) return + if (!canScroll(scroll)) return + if (store.userScrolled || userActivity.isRecent()) return + bottom() + } + + // --------------------------------------------------------------------------- + // Effects + // --------------------------------------------------------------------------- + + createResizeObserver(() => store.contentRef, onContentResize) + createResizeObserver(() => store.scrollRef, onViewportResize) createEffect( on(options.working, (working: boolean) => { @@ -200,7 +180,7 @@ export function createAutoScroll(options: AutoScrollOptions) { settleTimer = undefined if (working) { - scrollToBottom(true) + force() return } @@ -211,49 +191,43 @@ export function createAutoScroll(options: AutoScrollOptions) { }), ) + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + const setScroll = (el: HTMLElement | undefined) => { + if (cleanup) { + cleanup() + cleanup = undefined + } + + scroll = el + setStore("scrollRef", el) + + if (!el) return + + el.style.overflowAnchor = "auto" + cleanup = userActivity.listen(el) + } + onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) if (stopTimer) clearTimeout(stopTimer) if (cleanup) cleanup() }) - return { - scrollRef: (el: HTMLElement | undefined) => { - if (cleanup) { - cleanup() - cleanup = undefined - } - - lastScrollTop = undefined - scroll = el - - if (!el) return + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- - el.style.overflowAnchor = "auto" - el.addEventListener("wheel", handleWheel, { passive: true }) - el.addEventListener("wheel", markUser, { passive: true, capture: true }) - el.addEventListener("pointerdown", markUser, { passive: true }) - el.addEventListener("keydown", markUser, { passive: true }) - el.addEventListener("touchstart", markUser, { passive: true }) - - cleanup = () => { - el.removeEventListener("wheel", handleWheel) - el.removeEventListener("wheel", markUser, { capture: true }) - el.removeEventListener("pointerdown", markUser) - el.removeEventListener("keydown", markUser) - el.removeEventListener("touchstart", markUser) - } - }, + return { + scrollRef: setScroll, contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, - handleInteraction, - pause: stop, - resume: () => { - if (store.userScrolled) setStore("userScrolled", false) - scrollToBottom(true) - }, - scrollToBottom: () => scrollToBottom(false), - forceScrollToBottom: () => scrollToBottom(true), + pause, + resume, + scrollToBottom: follow, + forceScrollToBottom: force, userScrolled: () => store.userScrolled, } } diff --git a/packages/kilo-ui/src/hooks/scroll-user-activity.ts b/packages/kilo-ui/src/hooks/scroll-user-activity.ts new file mode 100644 index 00000000000..0b0f64f7524 --- /dev/null +++ b/packages/kilo-ui/src/hooks/scroll-user-activity.ts @@ -0,0 +1,50 @@ +interface UserActivityOptions { + grace: number + onWheelUp: () => void +} + +const isPotentialScrollInput = (event: Event) => { + if (!(event.target instanceof Element)) return true + const editable = event.target.closest("[contenteditable]") + return !event.target.closest("button, input, textarea, select") && !editable?.isContentEditable +} + +export const createUserActivity = (options: UserActivityOptions) => { + let marked = false + let time = 0 + + // Mark input that may cause the next scroll so layout-driven scroll events + // do not get mistaken for the user leaving auto-follow mode. + const mark = (event: Event) => { + if (!isPotentialScrollInput(event)) return + marked = true + time = performance.now() + } + + const handleWheel = (event: WheelEvent) => { + if (event.deltaY >= 0) return + options.onWheelUp() + } + + return { + listen: (el: HTMLElement) => { + el.addEventListener("wheel", handleWheel, { passive: true, capture: true }) + el.addEventListener("pointerdown", mark, { passive: true }) + el.addEventListener("keydown", mark, { passive: true }) + el.addEventListener("touchstart", mark, { passive: true }) + + return () => { + el.removeEventListener("wheel", handleWheel, { capture: true }) + el.removeEventListener("pointerdown", mark) + el.removeEventListener("keydown", mark) + el.removeEventListener("touchstart", mark) + } + }, + consumeScroll: () => { + const value = marked + marked = false + return value + }, + isRecent: () => time > 0 && performance.now() - time < options.grace, + } +} diff --git a/packages/kilo-ui/src/i18n/it.ts b/packages/kilo-ui/src/i18n/it.ts new file mode 100644 index 00000000000..3de0fcb4112 --- /dev/null +++ b/packages/kilo-ui/src/i18n/it.ts @@ -0,0 +1 @@ +export * from "@opencode-ai/ui/i18n/it" diff --git a/packages/kilo-ui/src/pierre/index.test.ts b/packages/kilo-ui/src/pierre/index.test.ts new file mode 100644 index 00000000000..7bc73fd3a24 --- /dev/null +++ b/packages/kilo-ui/src/pierre/index.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test" +import { createDefaultOptions } from "./index" + +describe("Pierre diff options", () => { + test("keeps changed identifiers intact in unified and split diffs", () => { + expect(createDefaultOptions("unified").lineDiffType).toBe("word-alt") + expect(createDefaultOptions("split").lineDiffType).toBe("word-alt") + }) +}) diff --git a/packages/kilo-ui/src/pierre/index.ts b/packages/kilo-ui/src/pierre/index.ts index 20b1ad7fd1c..7b4b13aabe9 100644 --- a/packages/kilo-ui/src/pierre/index.ts +++ b/packages/kilo-ui/src/pierre/index.ts @@ -6,8 +6,43 @@ import { type SelectedLineRange, } from "@pierre/diffs" import { ComponentProps } from "solid-js" +import { createDefaultOptions as defaults, styleVariables } from "@opencode-ai/ui/pierre" -export { createDefaultOptions, styleVariables } from "@opencode-ai/ui/pierre" +export { styleVariables } + +// Character matching fragments inserted identifiers when they share letters with +// existing symbols. Word-alt keeps those logical additions visually intact. +export const LINE_DIFF_TYPE = "word-alt" as const + +// Pierre 1.1 treats its changed-line override properties as tint targets. Apply +// Kilo semantic surfaces at the computed row level so host diff colors stay final. +const css = ` +[data-diff][data-background] [data-line][data-line-type='change-addition'] { + --diffs-computed-diff-line-bg: var(--surface-diff-add-base, var(--diffs-bg-addition)); + --diffs-computed-selected-line-bg: var(--surface-diff-add-base, var(--diffs-bg-addition)); +} +[data-diff][data-background] [data-column-number][data-line-type='change-addition'] { + --diffs-computed-diff-line-bg: var(--surface-diff-add-weaker, var(--diffs-bg-addition-number)); + --diffs-computed-selected-line-bg: var(--surface-diff-add-weaker, var(--diffs-bg-addition-number)); +} +[data-diff][data-background] [data-line][data-line-type='change-deletion'] { + --diffs-computed-diff-line-bg: var(--surface-diff-delete-base, var(--diffs-bg-deletion)); + --diffs-computed-selected-line-bg: var(--surface-diff-delete-base, var(--diffs-bg-deletion)); +} +[data-diff][data-background] [data-column-number][data-line-type='change-deletion'] { + --diffs-computed-diff-line-bg: var(--surface-diff-delete-weaker, var(--diffs-bg-deletion-number)); + --diffs-computed-selected-line-bg: var(--surface-diff-delete-weaker, var(--diffs-bg-deletion-number)); +} +` + +export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) { + const opts = defaults(style) + return { + ...opts, + lineDiffType: LINE_DIFF_TYPE, + unsafeCSS: `${opts.unsafeCSS}\n${css}`, + } +} // Extends upstream DiffProps with a `fileDiff` variant so Pierre can render // a precomputed FileDiffMetadata directly. The pair (before/after) variant @@ -18,6 +53,11 @@ type DiffShared = FileDiffOptions & { commentedLines?: SelectedLineRange[] onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void onRendered?: () => void + // When false, render the supplied diff once instead of row-virtualizing it. + // Callers should supply hunk-bounded `fileDiff`/`patch` data for large source + // files so eager rendering does not expand full before/after content. + // Defaults to virtualized. + virtualized?: boolean class?: string classList?: ComponentProps<"div">["classList"] } @@ -25,6 +65,8 @@ type DiffShared = FileDiffOptions & { type DiffPair = DiffShared & { before: FileContents after: FileContents + /** Unified patch used to parse only rendered hunks instead of full file contents. */ + patch?: string fileDiff?: undefined } @@ -32,6 +74,7 @@ type DiffPatch = DiffShared & { fileDiff: FileDiffMetadata before?: undefined after?: undefined + patch?: undefined } export type DiffProps = DiffPair | DiffPatch diff --git a/packages/kilo-ui/src/styles/globals.css b/packages/kilo-ui/src/styles/globals.css index eb946b3785d..d2352c526d9 100644 --- a/packages/kilo-ui/src/styles/globals.css +++ b/packages/kilo-ui/src/styles/globals.css @@ -37,6 +37,21 @@ --radius-xl: 6px; } +/* ===== Interactive cursors ===== */ + +:where( + button:not(:disabled), + a[href], + summary, + [role="button"]:not([aria-disabled="true"]), + [role="link"]:not([aria-disabled="true"]), + [role="menuitem"]:not([aria-disabled="true"]), + [role="option"]:not([aria-disabled="true"]), + [role="tab"]:not([aria-disabled="true"]) +) { + cursor: pointer; +} + /* ===== Scrollbar styling ===== */ ::-webkit-scrollbar { diff --git a/packages/kilo-ui/src/styles/vscode-bridge.css b/packages/kilo-ui/src/styles/vscode-bridge.css index 35bb4f6e3bd..54e1f4b89c6 100644 --- a/packages/kilo-ui/src/styles/vscode-bridge.css +++ b/packages/kilo-ui/src/styles/vscode-bridge.css @@ -48,6 +48,7 @@ html[data-theme="kilo-vscode"] { --surface-weak: var(--vscode-input-background, var(--vscode-list-hoverBackground)); --surface-weaker: var(--vscode-editor-inactiveSelectionBackground); --surface-strong: var(--vscode-editorWidget-background); + --surface-tool-output-base: color-mix(in srgb, var(--vscode-sideBar-background, var(--surface-base)) 64%, black 36%); --surface-brand-base: var(--vscode-button-background); --surface-brand-hover: var(--vscode-button-hoverBackground); @@ -68,7 +69,7 @@ html[data-theme="kilo-vscode"] { --surface-critical-base: var(--vscode-editorMarkerNavigationError-headerBackground); --surface-critical-weak: var(--vscode-editorMarkerNavigationError-headerBackground); - --surface-critical-strong: var(--vscode-charts-red); + --surface-critical-strong: var(--vscode-errorForeground, var(--vscode-charts-red)); --surface-info-base: var(--vscode-editorMarkerNavigationInfo-headerBackground); --surface-info-weak: var(--vscode-editorMarkerNavigationInfo-headerBackground); @@ -76,13 +77,22 @@ html[data-theme="kilo-vscode"] { /* Diff surfaces */ --surface-diff-unchanged-base: var(--vscode-editor-background); - --surface-diff-skip-base: var(--vscode-diffEditor-unchangedCodeBackground); + --surface-diff-skip-base: var( + --vscode-diffEditor-unchangedRegionBackground, + var(--vscode-diffEditor-unchangedCodeBackground, var(--vscode-editor-background)) + ); --surface-diff-add-base: var(--vscode-diffEditor-insertedLineBackground); --surface-diff-add-weak: var(--vscode-diffEditor-insertedTextBackground); - --surface-diff-add-weaker: var(--vscode-diffEditor-insertedLineBackground); + --surface-diff-add-weaker: var( + --vscode-diffEditorGutter-insertedLineBackground, + var(--vscode-diffEditor-insertedLineBackground) + ); --surface-diff-delete-base: var(--vscode-diffEditor-removedLineBackground); --surface-diff-delete-weak: var(--vscode-diffEditor-removedTextBackground); - --surface-diff-delete-weaker: var(--vscode-diffEditor-removedLineBackground); + --surface-diff-delete-weaker: var( + --vscode-diffEditorGutter-removedLineBackground, + var(--vscode-diffEditor-removedLineBackground) + ); /* ===== Inputs ===== */ --input-base: var(--vscode-input-background); @@ -116,9 +126,9 @@ html[data-theme="kilo-vscode"] { --text-on-success-base: var(--vscode-charts-green); --text-on-success-weak: var(--vscode-charts-green); --text-on-success-strong: var(--vscode-charts-green); - --text-on-critical-base: var(--vscode-charts-red); - --text-on-critical-weak: var(--vscode-charts-red); - --text-on-critical-strong: var(--vscode-charts-red); + --text-on-critical-base: var(--vscode-errorForeground, var(--vscode-charts-red)); + --text-on-critical-weak: var(--vscode-errorForeground, var(--vscode-charts-red)); + --text-on-critical-strong: var(--vscode-errorForeground, var(--vscode-charts-red)); --text-on-warning-base: var(--vscode-charts-yellow); --text-on-warning-weak: var(--vscode-charts-yellow); --text-on-warning-strong: var(--vscode-charts-yellow); @@ -183,9 +193,18 @@ html[data-theme="kilo-vscode"] { --border-warning-base: var(--vscode-charts-yellow); --border-warning-hover: var(--vscode-charts-yellow); --border-warning-selected: var(--vscode-charts-yellow); - --border-critical-base: var(--vscode-charts-red); - --border-critical-hover: var(--vscode-charts-red); - --border-critical-selected: var(--vscode-charts-red); + --border-critical-base: var( + --vscode-inputValidation-errorBorder, + var(--vscode-errorForeground, var(--vscode-charts-red)) + ); + --border-critical-hover: var( + --vscode-inputValidation-errorBorder, + var(--vscode-errorForeground, var(--vscode-charts-red)) + ); + --border-critical-selected: var( + --vscode-inputValidation-errorBorder, + var(--vscode-errorForeground, var(--vscode-charts-red)) + ); --border-info-base: var(--vscode-charts-blue); --border-info-hover: var(--vscode-charts-blue); --border-info-selected: var(--vscode-charts-blue); @@ -221,9 +240,9 @@ html[data-theme="kilo-vscode"] { --icon-warning-base: var(--vscode-charts-yellow); --icon-warning-hover: var(--vscode-charts-yellow); --icon-warning-active: var(--vscode-charts-yellow); - --icon-critical-base: var(--vscode-charts-red); - --icon-critical-hover: var(--vscode-charts-red); - --icon-critical-active: var(--vscode-charts-red); + --icon-critical-base: var(--vscode-errorForeground, var(--vscode-charts-red)); + --icon-critical-hover: var(--vscode-errorForeground, var(--vscode-charts-red)); + --icon-critical-active: var(--vscode-errorForeground, var(--vscode-charts-red)); --icon-info-base: var(--vscode-charts-blue); --icon-info-hover: var(--vscode-charts-blue); --icon-info-active: var(--vscode-charts-blue); @@ -239,9 +258,9 @@ html[data-theme="kilo-vscode"] { --icon-on-warning-base: var(--vscode-charts-yellow); --icon-on-warning-hover: var(--vscode-charts-yellow); --icon-on-warning-selected: var(--vscode-charts-yellow); - --icon-on-critical-base: var(--vscode-charts-red); - --icon-on-critical-hover: var(--vscode-charts-red); - --icon-on-critical-selected: var(--vscode-charts-red); + --icon-on-critical-base: var(--vscode-errorForeground, var(--vscode-charts-red)); + --icon-on-critical-hover: var(--vscode-errorForeground, var(--vscode-charts-red)); + --icon-on-critical-selected: var(--vscode-errorForeground, var(--vscode-charts-red)); --icon-on-info-base: var(--vscode-charts-blue); --icon-on-info-hover: var(--vscode-charts-blue); --icon-on-info-selected: var(--vscode-charts-blue); @@ -273,57 +292,22 @@ html[data-theme="kilo-vscode"] { --syntax-object: var(--vscode-editor-foreground); --syntax-success: var(--vscode-charts-green); --syntax-warning: var(--vscode-charts-yellow); - --syntax-critical: var(--vscode-charts-red); + --syntax-critical: var(--vscode-errorForeground, var(--vscode-charts-red)); --syntax-info: var(--vscode-charts-blue); --syntax-diff-add: var(--vscode-gitDecoration-addedResourceForeground); --syntax-diff-delete: var(--vscode-gitDecoration-deletedResourceForeground); --syntax-diff-unknown: var(--vscode-charts-red); /* ===== Pierre diff engine (theme its CSS vars to VS Code editor colors) ===== */ - --diffs-light-bg: var(--background-stronger); - --diffs-dark-bg: var(--background-stronger); - - --diffs-bg-addition-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 82%, - var(--syntax-diff-add, #318430) - ); - --diffs-bg-addition-number-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 72%, - var(--syntax-diff-add, #318430) - ); - --diffs-bg-addition-emphasis-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 60%, - var(--syntax-diff-add, #318430) - ); - --diffs-bg-addition-hover-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 72%, - var(--syntax-diff-add, #318430) - ); - - --diffs-bg-deletion-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 82%, - var(--syntax-diff-delete, #da3319) - ); - --diffs-bg-deletion-number-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 72%, - var(--syntax-diff-delete, #da3319) - ); - --diffs-bg-deletion-emphasis-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 60%, - var(--syntax-diff-delete, #da3319) - ); - --diffs-bg-deletion-hover-override: color-mix( - in lab, - var(--vscode-editor-background, #1e1e1e) 72%, - var(--syntax-diff-delete, #da3319) + --diffs-light-bg: var(--vscode-editor-background, #1e1e1e); + --diffs-dark-bg: var(--vscode-editor-background, #1e1e1e); + --diffs-bg-separator-override: var(--surface-diff-skip-base); + --diffs-fg-number-override: var( + --vscode-diffEditor-unchangedRegionForeground, + var(--vscode-editorLineNumber-foreground, var(--vscode-descriptionForeground)) ); + --diffs-bg-addition-emphasis-override: var(--surface-diff-add-weak); + --diffs-bg-deletion-emphasis-override: var(--surface-diff-delete-weak); /* ===== Markdown ===== */ --markdown-heading: var(--vscode-textLink-foreground); @@ -355,3 +339,8 @@ html[data-theme="kilo-vscode"] { --avatar-text-cyan: var(--vscode-charts-blue); --avatar-text-lime: var(--vscode-charts-green); } + +body.vscode-light, +body.vscode-high-contrast-light { + --surface-tool-output-base: color-mix(in srgb, var(--vscode-sideBar-background, var(--surface-base)) 96%, black 4%); +} diff --git a/packages/kilo-vscode/.storybook/main.ts b/packages/kilo-vscode/.storybook/main.ts index d1ef464f284..300333356fb 100644 --- a/packages/kilo-vscode/.storybook/main.ts +++ b/packages/kilo-vscode/.storybook/main.ts @@ -5,7 +5,7 @@ import solidPlugin from "vite-plugin-solid" const config: StorybookConfig = { framework: "storybook-solidjs-vite", stories: ["../webview-ui/src/stories/**/*.stories.@(ts|tsx)"], - addons: ["@storybook/addon-docs"], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], staticDirs: [{ from: "../assets/icons", to: "/icons" }], refs: {}, viteFinal: async (config) => { diff --git a/packages/kilo-vscode/.storybook/preview.tsx b/packages/kilo-vscode/.storybook/preview.tsx index 21758200fda..7add8f8897e 100644 --- a/packages/kilo-vscode/.storybook/preview.tsx +++ b/packages/kilo-vscode/.storybook/preview.tsx @@ -82,6 +82,7 @@ const preview: Preview = { theme: "kilo-vscode", colorScheme: "dark", vscodeTheme: "dark-modern", + a11y: { manual: true }, }, } diff --git a/packages/kilo-vscode/.vscodeignore b/packages/kilo-vscode/.vscodeignore index a1779eec27d..b8e0d54b256 100644 --- a/packages/kilo-vscode/.vscodeignore +++ b/packages/kilo-vscode/.vscodeignore @@ -25,3 +25,6 @@ AGENTS.md # Include bin/ directory for CLI binary (production) !bin/** + +# Include WAV assets used by extension-host notification playback +!audio-wav/** diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index a1583f8dbc4..842cb9ba4c2 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -88,7 +88,7 @@ The script checks for a prebuilt binary in `packages/opencode/dist/`, builds the ### Extension ↔ CLI Backend -The extension is a client of the CLI. At startup it spawns `bin/kilo serve --port 0`, captures the dynamically-assigned port from stdout, and communicates over HTTP + SSE. A random password is generated and passed via `KILO_SERVER_PASSWORD` env var for basic auth. +The extension is a client of the CLI. Activation creates one shared `KiloConnectionService`; on its first connection, which autocomplete may prewarm, `ServerManager` spawns `bin/kilo serve --port 0`, captures the dynamically assigned port from stdout, and communicates over HTTP + SSE. The current child process is reused unless it exits. A random password is generated and passed via `KILO_SERVER_PASSWORD` env var for basic auth. ``` Extension (Node.js) CLI Backend (child process) @@ -104,9 +104,10 @@ Extension (Node.js) CLI Backend (child process) └──────────────────────────┘ ``` -- **`KiloConnectionService`** (`src/services/cli-backend/connection-service.ts`) is a singleton shared across all webviews. It owns the server process, HTTP client, and SSE connection. -- **`ServerManager`** (`src/services/cli-backend/server-manager.ts`) spawns the CLI binary and manages the process lifecycle. -- Multiple **`KiloProvider`** instances (sidebar, Agent Manager, "open in tab" panels) subscribe to the shared connection. SSE events are filtered per-webview via a `trackedSessionIds` Set. +- **`KiloConnectionService`** (`src/services/cli-backend/connection-service.ts`) is created once during extension activation and shared across the sidebar, Kilo editor tabs, and Agent Manager. It owns the current server process, HTTP client, and SSE connection. +- **`ServerManager`** (`src/services/cli-backend/server-manager.ts`) lazily spawns the CLI binary, reuses its current process, and can start a replacement if that process exits. +- The sidebar, every **Open in Tab** Kilo panel, and the Agent Manager chat provider reuse this connection. Multiple **`KiloProvider`** instances subscribe to it, with SSE events filtered per-webview via a `trackedSessionIds` Set. Agent Manager terminals may use additional PTY/WebSocket channels to the same backend, not separate `kilo serve` processes. +- Backend state follows where it is allocated, not the worktree shown in a panel. Snapshot repository state uses directory-keyed `InstanceState`, while `trackState` is created once in the active Snapshot service closure. For these shared VS Code session paths, its slow-track `asked` guard spans worktree requests; choosing **Continue with snapshots** resets `asked` only when continued tracking returns a snapshot hash. ### Builds @@ -161,7 +162,7 @@ The Agent Manager is a feature within this extension (not a separate product). I ### Architecture -All Agent Manager sessions share the **single `kilo serve` process** managed by `KiloConnectionService`. No separate server is spawned per session. Session isolation comes from directory scoping — worktree sessions pass the worktree path to the CLI backend, which creates a session scoped to that directory. +Agent Manager local worktree sessions use the current shared `kilo serve` process owned by `KiloConnectionService`; no session starts its own backend. Their CLI requests pass the worktree path as `directory`, which resolves directory-scoped backend state. Setup scripts, terminal PTYs, git subprocesses, and a separately opened VS Code window are separate process or extension-host boundaries, not per-worktree `kilo serve` instances. Extension-side code lives in `src/agent-manager/`, webview code in `webview-ui/agent-manager/`. The webview reuses the sidebar's provider chain and `ChatView` component, adding a `WorktreeModeProvider` and a split layout. @@ -180,6 +181,12 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw - **Prefer kilo-ui styles**: Always reuse existing kilo-ui CSS variables, tokens, and component styles instead of writing custom CSS. If a style doesn't exist in kilo-ui yet, add it there and reuse it rather than inlining or duplicating styles in the webview. - **Icons**: kilo-ui has 75+ custom SVG icons in [`packages/ui/src/components/icon.tsx`](../../packages/ui/src/components/icon.tsx). To list all available icon names: `node -e "const c=require('fs').readFileSync('../../packages/ui/src/components/icon.tsx','utf8');[...c.matchAll(/^\\s{2}[\"']?([\\w-]+)[\"']?:\\s*\x60/gm)].map(m=>m[1]).sort().forEach(n=>console.log(n))"`. Icon names use both hyphenated (`arrow-left`) and bare-word (`brain`, `console`, `providers`) keys. +### Diff Rendering Performance + +- Preserve hunk-bounded unified `patch` data through Changes/review detail flows and pass patch-derived `FileDiffMetadata` to Pierre when available. Do not eagerly render Pierre from complete `before`/`after` contents based only on changed-line counts: a tiny patch in a large source file can otherwise parse and render the entire file while the user sees a placeholder. +- Pierre workers can offload highlighted updates, but they do not make an expensive synchronous initial render safe. Keep initial rendering hunk-bounded, and keep patch parsing behind deferred visibility/activation where session-switch responsiveness depends on it. +- When changing diff scheduling, verify both rapid session switching and fast scrolling through a review. Improving one by shifting work into the other is a regression, not an optimization. + ## Docs Screenshot Stories When adding or updating Storybook stories for screenshots used by docs, make the story content match the docs page closely before replacing the docs image. Do not replace screenshots from VSCode Legacy docs tabs or sections. @@ -190,6 +197,7 @@ Generated screenshot baselines live under `packages/kilo-docs/public/img/screens - Extension logs: "Extension Host" output channel (not Debug Console) - Webview logs: Command Palette → "Developer: Open Webview Developer Tools" +- In Chrome/VS Code performance traces, associate CPU `ProfileChunk` events to their `Profile.id` target before attributing work to a thread. `v8:ProfEvntProc` is a profile delivery thread, not evidence that application work ran off the webview main thread. - All debug output must be prepended with `[Kilo New]` for easy filtering ## Naming Conventions diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 530859308b1..9247ea6e3d0 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,636 @@ # kilo-code +## 7.3.54 + +## 7.3.53 + +### Patch Changes + +- [#11533](https://github.com/Kilo-Org/kilocode/pull/11533) [`15f42d4`](https://github.com/Kilo-Org/kilocode/commit/15f42d4bec51bbb127636738275f36fdc07e7b33) - Restore bounded text-file reads and keep zero-limit pagination and Unicode truncation from producing unusable tool output. + +- Updated dependencies [[`6c55c28`](https://github.com/Kilo-Org/kilocode/commit/6c55c28ec345a6d90d2d7a4e345abf962f208e29)]: + - @kilocode/kilo-gateway@7.3.53 + - @kilocode/kilo-indexing@7.3.53 + - @kilocode/kilo-ui@7.3.53 + - @opencode-ai/ui@7.3.53 + +## 7.3.52 + +### Patch Changes + +- [#11450](https://github.com/Kilo-Org/kilocode/pull/11450) [`cc924a6`](https://github.com/Kilo-Org/kilocode/commit/cc924a67d9b190ccffebaefa983213e173db54d8) - Changes from opencode v1.15.9 to v1.15.13 upstream: + - Core Improvements: Added `headerTimeout` config for provider requests, with a 10s default for default OpenAI setups. + - Core Improvements: Experimental background agents now push updates without polling. + - Core Improvements: You can now set only `modalities.input` or `modalities.output` in config. (@robposch) + - Core Improvements: Remote-backed projects now resolve a stable project identity. + - Core Improvements: ACP integrations can now send prompts, slash commands, and usage updates through `acp-next` + - Core Improvements: Added WebSocket transport for OpenAI responses on supported channels (set KILO_EXPERIMENTAL_WEBSOCKETS=true) + - Core Improvements: Sessions can now store custom metadata through the API and SDK. (@shantur) + - Core Improvements: Config now loads from the opened location upward, so directory-specific settings and provider policies apply more predictably. + - Core Bugfixes: Dynamically added MCP servers now disconnect cleanly when removed. + - Core Bugfixes: DigitalOcean inference now uses your OAuth token directly instead of creating a MAK. (@Spherrrical) + - Core Bugfixes: Config loading now falls back cleanly when user info is unavailable. + - Core Bugfixes: Fixed Google tool calling after the upstream tool ID regression. + - Core Bugfixes: Experimental flags can now override the umbrella experimental flag. + - Core Bugfixes: Resumed sessions no longer continue orphaned interrupted tools. (@edevil) + - Core Bugfixes: OpenAI reasoning summaries now render as separate blocks. + - Core Bugfixes: Updated Google Vertex support for reasoning signatures. + - Core Bugfixes: The shell tool now advertises your configured timeout to the model. + - Core Bugfixes: Enabled adaptive reasoning controls for Anthropic Opus 4.7+ models + - Core Bugfixes: Allowed colons in passwords (@neriousy) + - Core Bugfixes: Sped up warm `acp-next` model and config switches + - Core Bugfixes: Improved first-session `acp-next` startup time + - Core Bugfixes: Kept OpenAI WebSocket response timeouts active + - Core Bugfixes: Retried failed OpenAI WebSocket streams before falling back + - Core Bugfixes: Handled `acp-next` permission prompts correctly + - Core Bugfixes: Used the persisted session directory for existing-session requests + - Core Bugfixes: Forwarded remote workspace request bodies correctly + - Core Bugfixes: Supported custom base URLs for OpenAI WebSocket responses (@Tarquinen) + - Core Bugfixes: Gateway Anthropic Opus 4.7+ adaptive reasoning now keeps summarized thinking instead of returning empty thinking blocks. + - TUI Improvements: Made the prompt resize with terminal width and added prompt size config. (@bjschafer) + - TUI Improvements: Added a workspace management dialog + - TUI Bugfixes: Accelerated diff viewer scrolling. + - TUI Bugfixes: External editors now open from the worktree directory when available. + - TUI Bugfixes: Kept session navigation working while prompt modes are open + - TUI Bugfixes: Restored the thinking spinner + - TUI Bugfixes: Surfaced subagent retry status + - TUI Bugfixes: Fixed opening editors from non-Git project paths (@OpeOginni) + - TUI Bugfixes: Wrapped inline tool rows now stay aligned, and failed inline tools can expand their error details in place. + - Extensions Improvements: Added a `dispose` hook for plugins. + - Extensions Bugfixes: Fixed Codex plugin requests to send the expected session ID header. + +## 7.3.51 + +## 7.3.50 + +### Minor Changes + +- [#11421](https://github.com/Kilo-Org/kilocode/pull/11421) [`ccec216`](https://github.com/Kilo-Org/kilocode/commit/ccec2162383a6f378ed5e62d630720607d185209) - Show a BYOK badge for Kilo Gateway models that can use an enabled personal or organization provider key. + +### Patch Changes + +- [#11418](https://github.com/Kilo-Org/kilocode/pull/11418) [`8b32375`](https://github.com/Kilo-Org/kilocode/commit/8b32375fe67d96f29fa88933d711699e3720ebf4) - Describe custom providers independently of their selected API protocol. + +- [#11455](https://github.com/Kilo-Org/kilocode/pull/11455) [`4d09333`](https://github.com/Kilo-Org/kilocode/commit/4d0933371ca9be212cdd0357605e250ebacf7e1b) - Hide reverted provider errors so Redo controls remain visible after rewinding a session. + +- [#11423](https://github.com/Kilo-Org/kilocode/pull/11423) [`aa17a8a`](https://github.com/Kilo-Org/kilocode/commit/aa17a8a4191a04604b3a402e75d5c7e7b8149da4) - Keep changed identifiers intact when highlighting edits within diff lines. + +- [#11453](https://github.com/Kilo-Org/kilocode/pull/11453) [`f7e68d1`](https://github.com/Kilo-Org/kilocode/commit/f7e68d19d9d8b23b087d3c7c92d487abced8d7ec) - Limit completion sounds to parent agent sessions. + +- [#10940](https://github.com/Kilo-Org/kilocode/pull/10940) [`4de8293`](https://github.com/Kilo-Org/kilocode/commit/4de82931ed3d5315e6717827a9b53b11c1162e7f) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Avoid failing Agent Manager startup when another extension already registered VS Code panel commands. + +- [#11464](https://github.com/Kilo-Org/kilocode/pull/11464) [`c829642`](https://github.com/Kilo-Org/kilocode/commit/c8296420544a68e730fa4307e1a045210d79fcad) - Keep chat output updating after reverting and resubmitting a prompt. + +- [#11433](https://github.com/Kilo-Org/kilocode/pull/11433) [`867beac`](https://github.com/Kilo-Org/kilocode/commit/867beac3a19881861536d6c22a9efcb5ae379cc4) - Prevent concurrent subagent updates from blanking the Agent Manager webview. + +- [#11463](https://github.com/Kilo-Org/kilocode/pull/11463) [`909ec73`](https://github.com/Kilo-Org/kilocode/commit/909ec73b9c852de6899c20f473b6da14d6c428b5) - Speed up Agent Manager worktree hover cards so pull request details appear and dismiss more quickly. + +- [#11451](https://github.com/Kilo-Org/kilocode/pull/11451) [`732fbc3`](https://github.com/Kilo-Org/kilocode/commit/732fbc30dc3ee969caabc96282ad1a0ff5c652ce) - Widen the chat readable lane from 88ch to 98ch so conversations, tools, and diffs can use more editor space. + +- Updated dependencies [[`ccec216`](https://github.com/Kilo-Org/kilocode/commit/ccec2162383a6f378ed5e62d630720607d185209), [`2c9e72c`](https://github.com/Kilo-Org/kilocode/commit/2c9e72c14a87387199fd42546746bbea30aa1570), [`f7e68d1`](https://github.com/Kilo-Org/kilocode/commit/f7e68d19d9d8b23b087d3c7c92d487abced8d7ec)]: + - @kilocode/kilo-gateway@7.4.0 + - @kilocode/sdk@7.3.50 + - @kilocode/kilo-indexing@7.3.50 + - @kilocode/kilo-ui@7.3.50 + - @kilocode/plugin@7.3.50 + - @opencode-ai/ui@7.3.50 + +## 7.3.49 + +### Minor Changes + +- [#11303](https://github.com/Kilo-Org/kilocode/pull/11303) [`6faa3f1`](https://github.com/Kilo-Org/kilocode/commit/6faa3f14b619144a1353f169a0a2855dcbf69bbb) - Add an "Import Sessions from Roo Code" button in the About settings tab that discovers and imports conversation history from an existing Roo Code installation, reusing the existing migration wizard. + +## 7.3.48 + +### Minor Changes + +- [#11239](https://github.com/Kilo-Org/kilocode/pull/11239) [`a5b87fa`](https://github.com/Kilo-Org/kilocode/commit/a5b87fafa37f223877d07cfba2882b298a25f742) - Support multiple provider APIs, adaptive thinking, split reasoning, and output effort variants for custom providers. + +- [#11331](https://github.com/Kilo-Org/kilocode/pull/11331) [`c5bd1b3`](https://github.com/Kilo-Org/kilocode/commit/c5bd1b3e0959e019b015ca73c8db71726a5ffba3) - Support closing other Agent Manager tabs from the tab context menu. + +- [#11098](https://github.com/Kilo-Org/kilocode/pull/11098) [`b0ec91c`](https://github.com/Kilo-Org/kilocode/commit/b0ec91cef439633eef955d44747e03bb5e15ee4f) - Offer opt-in attention sounds when sessions finish, error, or need input across the sidebar, editor tabs, and Agent Manager. + +- [#11367](https://github.com/Kilo-Org/kilocode/pull/11367) [`1933cf7`](https://github.com/Kilo-Org/kilocode/commit/1933cf7cab911e295b99cb0ecf8821312e47c23e) - Open the selected Agent Manager worktree's pull request with Cmd/Ctrl+Shift+R. + +- [#11308](https://github.com/Kilo-Org/kilocode/pull/11308) [`d56bfdd`](https://github.com/Kilo-Org/kilocode/commit/d56bfdd9aa8c3112550be11f4761c66918283dde) - Keep conversations readable across the sidebar, editor tabs, and Agent Manager by aligning messages, tool output, and the composer in one centered lane. + +- [#11258](https://github.com/Kilo-Org/kilocode/pull/11258) [`056e069`](https://github.com/Kilo-Org/kilocode/commit/056e06911b0bf4232021e8a56676ae9b3f888bb0) - Render image changes in diff viewers and open images with VS Code's image preview. + +- [#11294](https://github.com/Kilo-Org/kilocode/pull/11294) [`c961f66`](https://github.com/Kilo-Org/kilocode/commit/c961f66bd4f38c922640d721d1ebdbcc9f97c0a6) - Search and switch between local sessions, worktrees, and their sessions from the Agent Manager sidebar. + +- [#11317](https://github.com/Kilo-Org/kilocode/pull/11317) [`a2ccf57`](https://github.com/Kilo-Org/kilocode/commit/a2ccf574a7b3f1414b9e9d785265dc9c4d16b44e) - Support VS Code switch links that select a Kilo model, agent, or both. + +### Patch Changes + +- [#11242](https://github.com/Kilo-Org/kilocode/pull/11242) [`9211000`](https://github.com/Kilo-Org/kilocode/commit/9211000aadd909f0d46746604c3e963966a59660) - Support unauthenticated OpenAI-compatible endpoints for codebase indexing without requiring a placeholder API key. + +- [#11249](https://github.com/Kilo-Org/kilocode/pull/11249) [`2c30dc7`](https://github.com/Kilo-Org/kilocode/commit/2c30dc75ce18c018f603a30d1c9e3c70fe8fc036) - Show a clear, retryable provider rate-limit error instead of raw response JSON in chat. + +- [#11080](https://github.com/Kilo-Org/kilocode/pull/11080) [`e335f97`](https://github.com/Kilo-Org/kilocode/commit/e335f97856c2a13ab75c2cb6e24f7df2626d41a0) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Support opening code edit and diff blocks expanded by default in the VS Code chat. + +- [#11297](https://github.com/Kilo-Org/kilocode/pull/11297) [`06da363`](https://github.com/Kilo-Org/kilocode/commit/06da3635993c17053d33cfd64d5002f3868eba11) - Remove stray rounded corners between files in the Changes diff viewer. + +- [#11352](https://github.com/Kilo-Org/kilocode/pull/11352) [`5dd5c9a`](https://github.com/Kilo-Org/kilocode/commit/5dd5c9a0de5d92b1646c647e3efbeeee29937721) - Improve screen reader navigation and provider group controls in the model picker. + +- [#10788](https://github.com/Kilo-Org/kilocode/pull/10788) [`941007b`](https://github.com/Kilo-Org/kilocode/commit/941007b68dfa9500ad604620ae861accf2a74643) Thanks [@noobezlol](https://github.com/noobezlol)! - Handle string-form permission values when migrating bash permissions. + +- [#11158](https://github.com/Kilo-Org/kilocode/pull/11158) [`8ff8371`](https://github.com/Kilo-Org/kilocode/commit/8ff83711766ff6b18ea23d1990d6fedd8e79c5ae) - Add a shared model setting to hide Kilo Gateway models that may train on your prompts across Kilo clients. + +- [#11349](https://github.com/Kilo-Org/kilocode/pull/11349) [`a5395a3`](https://github.com/Kilo-Org/kilocode/commit/a5395a35210b92d658b8efacea0fb9d25ddb27d0) - Highlight the exact changed characters within modified diff lines. + +- [#10261](https://github.com/Kilo-Org/kilocode/pull/10261) [`8efc296`](https://github.com/Kilo-Org/kilocode/commit/8efc296ae54292183579e004eae52813a96abe0d) Thanks [@singhvishalkr](https://github.com/singhvishalkr)! - Ignore Enter while IME composition is active (including Windows virtual-key 229) so composed text is not accidentally submitted from chat and sidebar inputs. + +- [#11236](https://github.com/Kilo-Org/kilocode/pull/11236) [`1511d13`](https://github.com/Kilo-Org/kilocode/commit/1511d13b3f7f20001d2111f14bdfae7155372cf8) Thanks [@kapelame](https://github.com/kapelame)! - Add an instant/thinking reasoning toggle for MiniMax M-series models, matching the existing glm/kimi/qwen behavior. + +- [#11358](https://github.com/Kilo-Org/kilocode/pull/11358) [`0920b3f`](https://github.com/Kilo-Org/kilocode/commit/0920b3ffa8ec5f7b327681f446af1616d6d652a2) - Clean up incomplete worktrees when moving a session fails to transfer its Git changes. + +- [#11348](https://github.com/Kilo-Org/kilocode/pull/11348) [`d190725`](https://github.com/Kilo-Org/kilocode/commit/d190725c3cfd30741aa0e19dac3ee7d626654bd1) - Announce every Settings tab by name to screen readers in compact sidebars. + +- [#11257](https://github.com/Kilo-Org/kilocode/pull/11257) [`f42789d`](https://github.com/Kilo-Org/kilocode/commit/f42789d0ef5585aad4080bdc5c96856675cd9503) - Changes from opencode v1.14.51 to v1.15.4 upstream: + - Core Improvements: Clarified how to recover when the npm package is installed without its native binary. + - Core Improvements: Reduced unnecessary prompting around shell, task, and todo flows. + - Core Bugfixes: Ignored invalid exports in custom tool modules instead of failing tool loading. + - Core Bugfixes: Ignored project instruction lookup errors so sessions keep loading when project instruction discovery fails. + - Core Bugfixes: Fixed versioned event projector lookups so event replay uses the right handlers. + - Core Bugfixes: Avoid duplicate consecutive entries in prompt history. + - Core Bugfixes: Show full config validation errors during TUI startup instead of a generic failure. + - Core Bugfixes: Fixed npm installs so the CLI can recover and fetch the right native binary on more setups. + - Core Bugfixes: Fixed multiline `@` mentions in prompts. + - Core Bugfixes: Preserved custom tool metadata from Zod schemas. + - Core Bugfixes: Preserved custom tool argument descriptions in generated schemas. + - Core Bugfixes: Fixed file watching in repos where `.git` is a symlink. (@kagura-agent) + - Core Bugfixes: Fixed sync events not reaching project-scoped subscribers in injected instances. + - Core Bugfixes: Reduced wasted work when reading very large files after output truncation. + - Core Bugfixes: Fixed project-scoped bus events so file watcher and update notifications reach the right instance. + - Core Bugfixes: Fixed custom LSP servers not sending refresh events after they initialize. + - Core Bugfixes: Hid background subagent task instructions unless experimental background mode is enabled. + - TUI Improvements: Added a collapsed thinking view that can be expanded inline. + - TUI Improvements: Added pinned sessions with quick-switch slots in the session picker. + - TUI Improvements: Newly pinned sessions now stay at the end of the pinned list instead of jumping to the top. + - TUI Improvements: Made Markdown H1 headings easier to distinguish. + - TUI Bugfixes: Fixed thinking mode defaults so reasoning starts collapsed consistently. + - TUI Bugfixes: Limited session quick-switching to pinned sessions. + - TUI Bugfixes: Fixed Markdown table rendering in chat output. + - TUI Bugfixes: Fixed `kilo run --agent` resolving project-local agents. + - TUI Bugfixes: Fixed async commands losing the active instance context, which could break agent generation and GitHub-driven runs. + +- [#11356](https://github.com/Kilo-Org/kilocode/pull/11356) [`326ff35`](https://github.com/Kilo-Org/kilocode/commit/326ff351460342f93b0bf97f0beb6383357c5d05) - Changes from opencode v1.15.4 to v1.15.9 upstream: + - Core Improvements: Preview the native OpenAI runtime path behind an experimental flag + - Core Improvements: Add `--replay` and `--replay-limit` to show recent history when resuming interactive runs + - Core Improvements: Added a diff viewer in the TUI for reviewing changes. + - Core Improvements: Collapsed single-child directories in the diff viewer file tree. + - Core Improvements: Added shell mode to the `run` prompt. + - Core Improvements: Replaced subagent tabs with an on-demand picker in `run`. + - Core Improvements: Plugin file load errors no longer break the rest of plugin loading. + - Core Improvements: Anthropic API-key models now use the native runtime. + - Core Improvements: The v2 HTTP API now exposes structured public error schemas. + - Core Improvements: Added Grok OAuth sign-in, including device-code login. (@Jaaneek) + - Core Improvements: Redesigned the diff viewer with a file tree and refreshed layout. + - Core Bugfixes: Fix plugin tools using `ask` so tool calls complete correctly + - Core Bugfixes: Reduce missed `/event` updates caused by a subscription race + - Core Bugfixes: Sort the v2 session list by most recently updated + - Core Bugfixes: Zed editor context now only activates inside Zed terminals. + - Core Bugfixes: Agent and command names now resolve correctly from relative config paths. + - Core Bugfixes: Invalid `KILO_PERMISSION` JSON no longer crashes startup. + - Core Bugfixes: Plugin tools with missing `args` no longer break tool loading. + - Core Bugfixes: Restored legacy `PgUp` and `PgDn` TUI keybind aliases. + - Core Bugfixes: Native runtime now prefers the console provider token for OpenCode models. + - Core Bugfixes: V2 session APIs now return safe `UnknownError` responses with log reference IDs when stored messages are corrupt. + - Core Bugfixes: Generic API 500s no longer expose config details from server errors. + - Core Bugfixes: Unknown API errors now include reference IDs so you can match responses to server logs. + - Core Bugfixes: V2 session APIs now return `503 ServiceUnavailableError` for mutations that are not available yet. + - Core Bugfixes: V2 session APIs now return `SessionNotFoundError` for missing sessions. + - Core Bugfixes: Deduped concurrent Codex OAuth refreshes to avoid repeated refresh failures. (@cooper-oai) + - Core Bugfixes: Restored native OpenAI OAuth requests. + - Core Bugfixes: Tool schema failures now surface as friendly tool errors. + - Core Bugfixes: Added PDF attachment support for Grok. + - Core Bugfixes: Restored OpenAI reasoning streams. + - Core Bugfixes: Return to the previous screen when closing the diff viewer. + - Core Bugfixes: Show clearer errors when a default model is invalid or unavailable. + - Core Bugfixes: Surface missing PTY session errors instead of failing generically. + - Core Bugfixes: Improve diff viewer empty states and context handling. + - Core Bugfixes: Show clearer errors when a skill invocation fails as expected. + - Core Bugfixes: Show clearer errors when an installation upgrade fails. + - Core Bugfixes: Show clearer project not found errors from the HTTP API. + - Core Bugfixes: Return PTY error bodies from the HTTP API. + - Core Bugfixes: Enable the diff viewer by default. + - Core Bugfixes: Return MCP server not found errors from the HTTP API. + - Core Bugfixes: Let MCP OAuth configs set a callback port and include configured scopes in client metadata. (@sebin) + - Core Bugfixes: Use working Vertex Anthropic endpoints for `us` and `eu` multi-region setups. (@JPFrancoia) + - Core Bugfixes: Return session busy error bodies from the HTTP API. + - Core Bugfixes: Preserve native reasoning continuation metadata across turns. + - TUI Improvements: Refresh the prompt layout after pasting content + - TUI Improvements: The diff viewer now focuses the first file automatically. + - TUI Improvements: Copy the current worktree path from the command palette. + - TUI Bugfixes: Keep file references scoped to the current workspace + - TUI Bugfixes: Preserve pasted prompt content when copying + - TUI Bugfixes: Collapse very long tool output lines to keep the layout readable + - TUI Bugfixes: Use a higher-contrast paste summary badge color in some themes (@kagura-agent) + - TUI Bugfixes: Imported sessions now refresh their directory and relative path fields correctly. (@OpeOginni) + - TUI Bugfixes: Collapsed thinking labels now use clearer punctuation. + - TUI Bugfixes: New sessions now default to the local project. + - TUI Bugfixes: Single-select question checkmarks no longer run into option labels. + - TUI Bugfixes: Refine diff viewer keyboard shortcuts. + - TUI Bugfixes: Restore question prompt key handling. + - TUI Bugfixes: Keep the spinner color aligned with the active agent. (@OpeOginni) + +- [#11146](https://github.com/Kilo-Org/kilocode/pull/11146) [`481f836`](https://github.com/Kilo-Org/kilocode/commit/481f8361cfc5ea4be929d9afd63d996ed51131af) Thanks [@Drixled](https://github.com/Drixled)! - Polish chat tool-call previews for shell, search, and background-process output. + +- [#11373](https://github.com/Kilo-Org/kilocode/pull/11373) [`f21a34a`](https://github.com/Kilo-Org/kilocode/commit/f21a34a1e63107da085eb9e57172ca6025d2dbe0) - Skip attention sounds when a session is manually interrupted. + +- [#11351](https://github.com/Kilo-Org/kilocode/pull/11351) [`94bdfb9`](https://github.com/Kilo-Org/kilocode/commit/94bdfb9f8e4d88205f876e7936e10908c68a8b44) - Enable remote control from Agent Manager without disrupting concurrent sidebar sessions, and include all open Agent Manager sessions. + +- [#11318](https://github.com/Kilo-Org/kilocode/pull/11318) [`7f18708`](https://github.com/Kilo-Org/kilocode/commit/7f18708308ad47fd14f876ed4a25c4322bd29751) - Remove the divider between agent conversations and the prompt input across extension chat views. + +- [#11295](https://github.com/Kilo-Org/kilocode/pull/11295) [`2fa0890`](https://github.com/Kilo-Org/kilocode/commit/2fa0890928f7dd060125ad4f4083b8bd2bf3e69b) - Restore speech input when profile details are unavailable, move transcription model selection to the Models tab, and default transcription to Whisper Large V3 Turbo. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`9b73bf9`](https://github.com/Kilo-Org/kilocode/commit/9b73bf91e4303249f30517d4cacfa8287680a82c) - Restore popular provider suggestions in the VS Code provider settings panel when provider metadata is unavailable. + +- [#11410](https://github.com/Kilo-Org/kilocode/pull/11410) [`344a6a5`](https://github.com/Kilo-Org/kilocode/commit/344a6a5f0f8377d8ab38792e6141d08947a7dc19) - Keep server controls and events connected to active sessions and subagents. + +- [#11221](https://github.com/Kilo-Org/kilocode/pull/11221) [`987da27`](https://github.com/Kilo-Org/kilocode/commit/987da2728731e1da1c974996b5bcddafe745cea7) - Show shared provider descriptions and provider icons in JetBrains and VS Code provider settings. + +- [#11357](https://github.com/Kilo-Org/kilocode/pull/11357) [`14cb3df`](https://github.com/Kilo-Org/kilocode/commit/14cb3dfd048ba4217bcd1f26ad7b76ce9a2a2e7f) - Show line numbers in edit approval diffs, including compact sidebar views. + +- [#11402](https://github.com/Kilo-Org/kilocode/pull/11402) [`146c4f4`](https://github.com/Kilo-Org/kilocode/commit/146c4f4396fd45c6bfc567f9b3ee1de46e26a3b6) - Prevent large highlighted shell outputs from blanking the VS Code webview when expanded. + +- [#11404](https://github.com/Kilo-Org/kilocode/pull/11404) [`f35d9c5`](https://github.com/Kilo-Org/kilocode/commit/f35d9c5fa3017dcc35d260b5669daf87411419f5) - Keep the Stop button working when an active session moves between the workspace and an Agent Manager worktree. + +- [#11366](https://github.com/Kilo-Org/kilocode/pull/11366) [`4739954`](https://github.com/Kilo-Org/kilocode/commit/47399547e1d60f016542f6a83fbf91a2bce91692) - Widen the sidebar chat readable lane from 78ch to 88ch so tools and content use a little more screen space. + +- Updated dependencies [[`9211000`](https://github.com/Kilo-Org/kilocode/commit/9211000aadd909f0d46746604c3e963966a59660), [`2fa0890`](https://github.com/Kilo-Org/kilocode/commit/2fa0890928f7dd060125ad4f4083b8bd2bf3e69b), [`973d02c`](https://github.com/Kilo-Org/kilocode/commit/973d02cfd15b3bf3eefefe92e7fb61059eba26f7), [`66af690`](https://github.com/Kilo-Org/kilocode/commit/66af6907005b99bb39a0869b35dfe1ec180cc0b5)]: + - @kilocode/kilo-indexing@7.4.0 + - @kilocode/sdk@7.4.0 + - @kilocode/kilo-ui@7.3.47 + - @kilocode/plugin@7.3.47 + - @opencode-ai/ui@7.3.47 + - @kilocode/kilo-gateway@7.3.47 + +## 7.3.46 + +### Patch Changes + +- [#11184](https://github.com/Kilo-Org/kilocode/pull/11184) [`adf03a9`](https://github.com/Kilo-Org/kilocode/commit/adf03a98245e8877c580cb1f77a7e0ea4f0af61d) - Support model-specific reasoning overrides for task subagents, including custom subagents with their own model and variant settings. + +- [#11238](https://github.com/Kilo-Org/kilocode/pull/11238) [`fb2db2e`](https://github.com/Kilo-Org/kilocode/commit/fb2db2e0182b637d92d3158da9dbea953974cf71) - Allow reasoning to be removed from custom provider models after it has been enabled. + +- [#11232](https://github.com/Kilo-Org/kilocode/pull/11232) [`f326be4`](https://github.com/Kilo-Org/kilocode/commit/f326be4b6c9d5af9d944c8e5e7f9c524c173052f) - Clarify potential loop permission prompts and auto-approval rules with localized tool names. + +- [#11183](https://github.com/Kilo-Org/kilocode/pull/11183) [`1fd0960`](https://github.com/Kilo-Org/kilocode/commit/1fd0960cbcc82cb78a17c62332c08c5a7ba7d7c4) - Restore reverted sessions on the first Redo click. + +- [#11169](https://github.com/Kilo-Org/kilocode/pull/11169) [`e2939c7`](https://github.com/Kilo-Org/kilocode/commit/e2939c7557a08e2377c2dbbc0433f4817f4a121f) - Route question responses to the worktree where the question was created. + +- [#11240](https://github.com/Kilo-Org/kilocode/pull/11240) [`f820e57`](https://github.com/Kilo-Org/kilocode/commit/f820e57bab6c1ddd26f73964160bee7134488b96) - Prevent skill removal from recursively deleting working directories. + +## 7.3.45 + +### Minor Changes + +- [#9750](https://github.com/Kilo-Org/kilocode/pull/9750) [`57f2e79`](https://github.com/Kilo-Org/kilocode/commit/57f2e794ca2301108dd01c73f43d0171c00756c6) - Support choosing an agent autonomy preset during VS Code onboarding. + +- [#10827](https://github.com/Kilo-Org/kilocode/pull/10827) [`235e769`](https://github.com/Kilo-Org/kilocode/commit/235e769fb359531da4716999ef5feac839affdcc) - Support selecting Kilo Gateway catalog models from VS Code deep links. + +### Patch Changes + +- [#11152](https://github.com/Kilo-Org/kilocode/pull/11152) [`b23d3df`](https://github.com/Kilo-Org/kilocode/commit/b23d3dfd756461ae02e2ed2872aded09d65dc1af) - Allow Escape to stop Agent Manager prompts while their sessions are still starting. + +- [#11173](https://github.com/Kilo-Org/kilocode/pull/11173) [`555dc23`](https://github.com/Kilo-Org/kilocode/commit/555dc2359373774a68e546b1dbe58d9d942d453f) - Show submitted review comments as interactive message cards instead of raw markdown. + +- [#11175](https://github.com/Kilo-Org/kilocode/pull/11175) [`fb62e61`](https://github.com/Kilo-Org/kilocode/commit/fb62e61caf1a4a14ec8db30648e9f008e70604b3) - Hide turn outcome warnings caused only by unfinished to-dos. + +## 7.3.44 + +### Minor Changes + +- [#11082](https://github.com/Kilo-Org/kilocode/pull/11082) [`a16e82a`](https://github.com/Kilo-Org/kilocode/commit/a16e82a77abf883c2c07c11464d50e08a518acd7) - Use embedded LanceDB as the default semantic search vector store so indexing works without a separate Qdrant server. Existing Qdrant users and Intel Mac users can select `qdrant` with `indexing.vectorStore`. + +### Patch Changes + +- [#11001](https://github.com/Kilo-Org/kilocode/pull/11001) [`b64d7e0`](https://github.com/Kilo-Org/kilocode/commit/b64d7e00953a6b99b3e41019d811a3917c465880) - Preserve existing Agent Manager worktrees and use deterministic suffixes when explicit branch names collide. + +- [#10084](https://github.com/Kilo-Org/kilocode/pull/10084) [`f180658`](https://github.com/Kilo-Org/kilocode/commit/f180658b99e88825f27fdd84dd34cdddb5347792) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Group Kilo Code commands in the VS Code command palette and clarify the Open in Tab command title. + +- [#11118](https://github.com/Kilo-Org/kilocode/pull/11118) [`143ae8e`](https://github.com/Kilo-Org/kilocode/commit/143ae8e99205e7c8bc4743a1ff33e6ac8d456cc6) - Dismiss stale permission prompts when another view has already answered them. + +- [#11094](https://github.com/Kilo-Org/kilocode/pull/11094) [`4acc1f8`](https://github.com/Kilo-Org/kilocode/commit/4acc1f8bcccc48f01fc046d799f5546bff247407) - Keep large chat transcripts responsive by mounting only viewport-visible conversation rows. + +- [#11154](https://github.com/Kilo-Org/kilocode/pull/11154) [`9129844`](https://github.com/Kilo-Org/kilocode/commit/9129844225e2e0bc551cd91dcdbacf046992e94b) - Load large Agent Manager change reviews faster without deferring file previews. + +- [#11075](https://github.com/Kilo-Org/kilocode/pull/11075) [`e17ce0c`](https://github.com/Kilo-Org/kilocode/commit/e17ce0c9ecaf4cc4cad3e0fd99b28bef561705fc) - Speed up large session forks by retaining final task outcomes instead of duplicating resumable subagent histories, and load completed task details only when expanded. + +- [#11081](https://github.com/Kilo-Org/kilocode/pull/11081) [`9c279a1`](https://github.com/Kilo-Org/kilocode/commit/9c279a16b4a14fc117f34d7aa19e771149031931) - Show model free and prompt-training indicators only when their explicit catalog metadata is enabled. + +- [#11100](https://github.com/Kilo-Org/kilocode/pull/11100) [`5b38d29`](https://github.com/Kilo-Org/kilocode/commit/5b38d299c439e076857e84390a9355372190557a) - Keep HTTP status codes beside tool error titles when error details wrap. + +- [#11101](https://github.com/Kilo-Org/kilocode/pull/11101) [`294c532`](https://github.com/Kilo-Org/kilocode/commit/294c532f6a355b78ed86d2188891883b07e90cc8) - Prevent task subagents from asking questions that users cannot answer from the parent session. + +- [#11103](https://github.com/Kilo-Org/kilocode/pull/11103) [`8b2a100`](https://github.com/Kilo-Org/kilocode/commit/8b2a100084496deca171243ddd50a26fa74343f2) - Show the first Agent Manager Local prompt and progress indicator immediately while its session is being created. + +- [#11104](https://github.com/Kilo-Org/kilocode/pull/11104) [`514af4c`](https://github.com/Kilo-Org/kilocode/commit/514af4c36145610e22d3888b7b5836fab1351272) - Open subagent transcripts faster while preserving live reasoning and paginating long histories. + +- [#11151](https://github.com/Kilo-Org/kilocode/pull/11151) [`77fe7c9`](https://github.com/Kilo-Org/kilocode/commit/77fe7c9e3fee173276401ad45774e2031d394443) - Keep binary and audio files collapsed in diff reviews instead of showing empty diff panels. + +- [#11097](https://github.com/Kilo-Org/kilocode/pull/11097) [`f8ec1dc`](https://github.com/Kilo-Org/kilocode/commit/f8ec1dc7a8c0ab0b88d1cd14e35182adef33847a) - Remove the unsupported paste-summary toggle from VS Code settings. + +- [#11091](https://github.com/Kilo-Org/kilocode/pull/11091) [`57bef8a`](https://github.com/Kilo-Org/kilocode/commit/57bef8ae68793c9b627ba0400b596bf932311e17) - Prevent streamed tool calls from executing twice and leaving answered questions disabled in VS Code. + +- [#11031](https://github.com/Kilo-Org/kilocode/pull/11031) [`bbfd59b`](https://github.com/Kilo-Org/kilocode/commit/bbfd59b85c383277fd8db77fcfd0ec56ea1a25d8) - Remove the unsupported code search tool. + +- [#11133](https://github.com/Kilo-Org/kilocode/pull/11133) [`68fd8cc`](https://github.com/Kilo-Org/kilocode/commit/68fd8ccce4d0a9b6987ea9c620a072a15b924c9c) - Keep permission approvals working when continuing or switching Agent Manager worktree sessions. + +- [#10866](https://github.com/Kilo-Org/kilocode/pull/10866) [`d5112ed`](https://github.com/Kilo-Org/kilocode/commit/d5112edf90d33333d1064c7ab885cf0a4d92d892) - Support configuring code indexing separately for global and project settings in Kilo Console, the CLI TUI, and VS Code. + +- [#11051](https://github.com/Kilo-Org/kilocode/pull/11051) [`856fc66`](https://github.com/Kilo-Org/kilocode/commit/856fc6665617d0a9753bc857fcd2c745b663196f) - Keep custom-answer selection in sync and show one submit action while entering custom text. + +- [#11031](https://github.com/Kilo-Org/kilocode/pull/11031) [`b2798ef`](https://github.com/Kilo-Org/kilocode/commit/b2798ef41e2b7875442aacaac65397a2d2be3b74) - Preserve complete session state when applying partial session updates in VS Code. + +- [#11087](https://github.com/Kilo-Org/kilocode/pull/11087) [`5969a4c`](https://github.com/Kilo-Org/kilocode/commit/5969a4c210beaf3dc968a56da541cfcdd7989d84) - Restore persisted sessions across development extension branches and worktrees. + +- [#11153](https://github.com/Kilo-Org/kilocode/pull/11153) [`7955f86`](https://github.com/Kilo-Org/kilocode/commit/7955f865d8f05920bf6a03e8daa5193da6e6b5f9) - Keep streamed conversation content visually stable while preserving virtualized transcript history. + +- [#11144](https://github.com/Kilo-Org/kilocode/pull/11144) [`287f75a`](https://github.com/Kilo-Org/kilocode/commit/287f75aa2bc84d931baccfb217dda0618be07ea9) - Agent Manager terminals now use `terminal.integrated.fontFamily` and `terminal.integrated.fontSize` (including Nerd Font glyphs) instead of the editor font. + +- [#11154](https://github.com/Kilo-Org/kilocode/pull/11154) [`9129844`](https://github.com/Kilo-Org/kilocode/commit/9129844225e2e0bc551cd91dcdbacf046992e94b) - Keep expanded Agent Manager reviews responsive by virtualizing file rows and preserving anchored scrolling. + +- [#11074](https://github.com/Kilo-Org/kilocode/pull/11074) [`915ecfb`](https://github.com/Kilo-Org/kilocode/commit/915ecfbefbaf1b0a7f5876dedbca6c79008ee771) - Start MCP servers while Agent Manager worktree sessions initialize to reduce the delay before the first response. + +- Updated dependencies [[`a16e82a`](https://github.com/Kilo-Org/kilocode/commit/a16e82a77abf883c2c07c11464d50e08a518acd7), [`e17ce0c`](https://github.com/Kilo-Org/kilocode/commit/e17ce0c9ecaf4cc4cad3e0fd99b28bef561705fc), [`9c279a1`](https://github.com/Kilo-Org/kilocode/commit/9c279a16b4a14fc117f34d7aa19e771149031931), [`57bef8a`](https://github.com/Kilo-Org/kilocode/commit/57bef8ae68793c9b627ba0400b596bf932311e17), [`b75af0d`](https://github.com/Kilo-Org/kilocode/commit/b75af0de8865234a745f71eac03bf2bdea2271b4)]: + - @kilocode/kilo-indexing@7.4.0 + - @kilocode/kilo-ui@7.3.43 + - @kilocode/kilo-gateway@7.3.43 + - @opencode-ai/ui@7.3.43 + +## 7.3.42 + +### Minor Changes + +- [#11063](https://github.com/Kilo-Org/kilocode/pull/11063) [`4446102`](https://github.com/Kilo-Org/kilocode/commit/4446102dac38ceb8229c43e919915081084f4287) - Add a Fork Session button to completed Agent Manager sessions. + +### Patch Changes + +- [#11037](https://github.com/Kilo-Org/kilocode/pull/11037) [`15d771e`](https://github.com/Kilo-Org/kilocode/commit/15d771e8fa2bb40294d0ea892ee8fcd73c66f215) - Keep task timelines in place while reviewing earlier activity across Kilo chat surfaces, and resume following updates from the latest bar. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`256fe3a`](https://github.com/Kilo-Org/kilocode/commit/256fe3a819eefcebf4c40c8fca0be83b9361167f) - Improve JetBrains reasoning blocks so active reasoning opens while streaming, completed reasoning collapses automatically, empty blocks stay hidden, and adjacent reasoning renders as one block. + +- [#11065](https://github.com/Kilo-Org/kilocode/pull/11065) [`339c70a`](https://github.com/Kilo-Org/kilocode/commit/339c70a9476034ca1a33c4343fcb1671ef057b31) - Pause chat auto-scroll whenever the user scrolls upward over nested tool output. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`3b76248`](https://github.com/Kilo-Org/kilocode/commit/3b762484f37e36510efb430366071ad0f2a31e93) - Hide thematic separator lines in JetBrains chat markdown while preserving surrounding prose and code blocks. + +- [#11015](https://github.com/Kilo-Org/kilocode/pull/11015) [`8c4c337`](https://github.com/Kilo-Org/kilocode/commit/8c4c3375b9f7ba21e2c9bc806b63ad4d9f17c3ec) - Display JetBrains search tool paths relative to the current repository when possible. + +- Updated dependencies [[`339c70a`](https://github.com/Kilo-Org/kilocode/commit/339c70a9476034ca1a33c4343fcb1671ef057b31), [`b98fb9a`](https://github.com/Kilo-Org/kilocode/commit/b98fb9afdb7b4984f56f35f7546d88a87c4e8572)]: + - @kilocode/kilo-ui@7.3.42 + +## 7.3.41 + +### Minor Changes + +- [#10879](https://github.com/Kilo-Org/kilocode/pull/10879) [`b0a4f03`](https://github.com/Kilo-Org/kilocode/commit/b0a4f0391106a837b78200e6de52621a6872b890) - Show Terminal Bench completion scores and per-attempt costs in supported model details. + +- [#10948](https://github.com/Kilo-Org/kilocode/pull/10948) [`6ee090b`](https://github.com/Kilo-Org/kilocode/commit/6ee090b5a404924f00c1f4771b09c1f4a1e352ca) - Restore cloud session filesystem changes from synced session diffs when importing sessions, including inherited changes across imported session forks. + +### Patch Changes + +- [#10094](https://github.com/Kilo-Org/kilocode/pull/10094) [`7ac7c1e`](https://github.com/Kilo-Org/kilocode/commit/7ac7c1e2832235e418eb7d27f4defdb6a3d139c6) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Fix agent-manager model sync on config change + +- [#10999](https://github.com/Kilo-Org/kilocode/pull/10999) [`ad93990`](https://github.com/Kilo-Org/kilocode/commit/ad93990ca9c84215bd71dee9febe76eebf07f1b5) - Show a pointer cursor for clickable controls and links in Kilo webviews. + +- [#11038](https://github.com/Kilo-Org/kilocode/pull/11038) [`70f9441`](https://github.com/Kilo-Org/kilocode/commit/70f9441b50568d2f3330b81b316f5907f4f797e0) - Speed up long streamed responses by preserving completed Markdown blocks and updating only the active tail. + +- [#11033](https://github.com/Kilo-Org/kilocode/pull/11033) [`10520d0`](https://github.com/Kilo-Org/kilocode/commit/10520d04e19a8b85072bdc25ff8d3dd51e5c7629) - Keep Agent Manager streaming responsive when multiple sessions run concurrently. + +- [#11039](https://github.com/Kilo-Org/kilocode/pull/11039) [`422bc8a`](https://github.com/Kilo-Org/kilocode/commit/422bc8aca87794858ddd3c0b5e0081def0033758) - Remove the obsolete speech-to-text enabled setting while preserving provider-based availability and model selection. + +- [#10992](https://github.com/Kilo-Org/kilocode/pull/10992) [`2d7c49d`](https://github.com/Kilo-Org/kilocode/commit/2d7c49d2a8d8fe32f80eab732774defed55da27f) - Rename Mercury autocomplete model labels to distinguish FIM and Next Edit modes. + +- [#10787](https://github.com/Kilo-Org/kilocode/pull/10787) [`c6679ea`](https://github.com/Kilo-Org/kilocode/commit/c6679eaec5112e0355f81c140901171df64481a3) - Route Agent Manager permission approvals to the worktree that created the request. + +- Updated dependencies [[`ad93990`](https://github.com/Kilo-Org/kilocode/commit/ad93990ca9c84215bd71dee9febe76eebf07f1b5), [`b0a4f03`](https://github.com/Kilo-Org/kilocode/commit/b0a4f0391106a837b78200e6de52621a6872b890)]: + - @kilocode/kilo-ui@7.3.41 + - @kilocode/kilo-gateway@7.4.0 + - @kilocode/kilo-indexing@7.3.41 + +## 7.3.40 + +## 7.3.39 + +### Patch Changes + +- [#10946](https://github.com/Kilo-Org/kilocode/pull/10946) [`6a64794`](https://github.com/Kilo-Org/kilocode/commit/6a64794a5b49acf1ce9ab768c6bce8090f863036) - Keep chat auto-scroll active when interacting with question answers and other controls. + +- [#10944](https://github.com/Kilo-Org/kilocode/pull/10944) [`f5b5797`](https://github.com/Kilo-Org/kilocode/commit/f5b57975805ce945b16604037724f2ca7389ff43) - Keep chat pinned to the latest output when the visible message area resizes without overriding an intentional scroll position. + +- [#10950](https://github.com/Kilo-Org/kilocode/pull/10950) [`225b914`](https://github.com/Kilo-Org/kilocode/commit/225b914b5971819641834a3180c2e123cb71d7e6) - Reduce chat layout movement when live output finishes and session actions appear. + +- [#10951](https://github.com/Kilo-Org/kilocode/pull/10951) [`54534bd`](https://github.com/Kilo-Org/kilocode/commit/54534bd64162172322ba3785a6970ac3d94ccbf3) - Keep the Explorer and other primary sidebar views open when VS Code reloads while Kilo Code is hidden. + +- [#10942](https://github.com/Kilo-Org/kilocode/pull/10942) [`049d567`](https://github.com/Kilo-Org/kilocode/commit/049d567d1300487e735905920adeeae4bed21acc) - Keep chat auto-scroll following after programmatic or layout-driven scroll position changes. + +- Updated dependencies [[`6a64794`](https://github.com/Kilo-Org/kilocode/commit/6a64794a5b49acf1ce9ab768c6bce8090f863036), [`f5b5797`](https://github.com/Kilo-Org/kilocode/commit/f5b57975805ce945b16604037724f2ca7389ff43), [`049d567`](https://github.com/Kilo-Org/kilocode/commit/049d567d1300487e735905920adeeae4bed21acc)]: + - @kilocode/kilo-ui@7.3.34 + +## 7.3.33 + +### Patch Changes + +- [#10935](https://github.com/Kilo-Org/kilocode/pull/10935) [`6cab5f1`](https://github.com/Kilo-Org/kilocode/commit/6cab5f18e76b5ab0f738c2e20e93f12f3679b5dc) - Prevent the macOS Apple Silicon CLI from failing to start because of malformed bundled exports. + +## 7.3.30 + +## 7.3.29 + +### Patch Changes + +- [#9976](https://github.com/Kilo-Org/kilocode/pull/9976) [`56b0834`](https://github.com/Kilo-Org/kilocode/commit/56b0834db524d29737043c250b45e8c973add350) - Reduce JetBrains memory usage by disposing hidden session UIs after a configurable timeout. + +- [#9976](https://github.com/Kilo-Org/kilocode/pull/9976) [`750bb77`](https://github.com/Kilo-Org/kilocode/commit/750bb778998a405719d3132d17867d2868e4defd) - Improve JetBrains session stability by keeping controller subscription state on the UI thread. + +- [#9976](https://github.com/Kilo-Org/kilocode/pull/9976) [`50f4d28`](https://github.com/Kilo-Org/kilocode/commit/50f4d28e07f5fb0c71d231d9eead8c0b370c05aa) - Show JetBrains markdown code blocks as multiline boxed editors that expand to their full height. + +- [#9976](https://github.com/Kilo-Org/kilocode/pull/9976) [`e17c4f9`](https://github.com/Kilo-Org/kilocode/commit/e17c4f936c1c111d7019122b7c794661a610eb52) - Improve JetBrains chat streaming performance by retaining existing markdown and code block views while responses stream, and keep streamed code fences intact without showing raw fence markers during updates. + +- [#9976](https://github.com/Kilo-Org/kilocode/pull/9976) [`2a3975e`](https://github.com/Kilo-Org/kilocode/commit/2a3975ea29acfe4e5c4a4cd293de7e6f9b789e86) - Support coherent selection and copy behavior across JetBrains session transcript fragments. + +- [#10822](https://github.com/Kilo-Org/kilocode/pull/10822) [`8b1ee66`](https://github.com/Kilo-Org/kilocode/commit/8b1ee6628c7ee552814980465af7233522dd5528) - Preserve worktree routing for Kilo HTTP API clients and keep inherited task-subagent restrictions active. + +## 7.3.28 + +### Patch Changes + +- [#10847](https://github.com/Kilo-Org/kilocode/pull/10847) [`cdf46c9`](https://github.com/Kilo-Org/kilocode/commit/cdf46c97354630e2f1b392092ee0ffcc18b19640) - Clarify when free-model data may be used for training and identify it with a brain circuit icon. + +- [#10806](https://github.com/Kilo-Org/kilocode/pull/10806) [`ed3e1ac`](https://github.com/Kilo-Org/kilocode/commit/ed3e1ac99bffd2c1fa0480d80907d298f48ce175) - Make Marketplace skill installation resilient to missing project directories and overlapping install attempts. + +- [#10831](https://github.com/Kilo-Org/kilocode/pull/10831) [`837a875`](https://github.com/Kilo-Org/kilocode/commit/837a87509cb323dbf212cbf40af112f218221dd0) - Keep post-compaction tool calls and follow-up messages ordered after the compaction summary in the CLI and VS Code transcript. + +- [#10849](https://github.com/Kilo-Org/kilocode/pull/10849) [`a6b005d`](https://github.com/Kilo-Org/kilocode/commit/a6b005dfede302731dcbb00ac74e744333db9104) - Restore Cloud Agent transcripts in VS Code session previews and stop cloud session previews or continuation from loading indefinitely when a request stalls. + +- [#10692](https://github.com/Kilo-Org/kilocode/pull/10692) [`eadfb2b`](https://github.com/Kilo-Org/kilocode/commit/eadfb2b80a1a7ca4b469d78d85fec023c8c0387b) - Show shell command descriptions in permission approval prompts. + +- [#10816](https://github.com/Kilo-Org/kilocode/pull/10816) [`16341a6`](https://github.com/Kilo-Org/kilocode/commit/16341a6647d1a662ce1e9fceec09a9cf33bf0be6) - Keep chat auto-scroll stable while edit, write, and patch tools hand off between assistant steps. + +- [#10846](https://github.com/Kilo-Org/kilocode/pull/10846) [`48340fe`](https://github.com/Kilo-Org/kilocode/commit/48340fe2ec44d75c5210a0ebeaf18575f5935774) - Preserve unfinished inline review comments while diffs refresh. + +- [#10810](https://github.com/Kilo-Org/kilocode/pull/10810) [`5b34dfc`](https://github.com/Kilo-Org/kilocode/commit/5b34dfce3d62946f3aa2ad8e65f618af05246f4d) - Speed up VS Code session switching for long transcripts by lazily mounting collapsed historical tool details, sharing timeline hover infrastructure across activity bars, omitting transcript metadata that the webview does not use, and avoiding shimmer markup for inactive historical labels. + +- Updated dependencies [[`cdf46c9`](https://github.com/Kilo-Org/kilocode/commit/cdf46c97354630e2f1b392092ee0ffcc18b19640), [`fc4cf10`](https://github.com/Kilo-Org/kilocode/commit/fc4cf10b0a65ec2b2949dd695ebec6ebb619cd15), [`a6b005d`](https://github.com/Kilo-Org/kilocode/commit/a6b005dfede302731dcbb00ac74e744333db9104)]: + - @kilocode/kilo-ui@7.3.23 + - @kilocode/sdk@7.3.23 + - @kilocode/kilo-gateway@7.3.23 + - @opencode-ai/ui@7.3.23 + - @kilocode/kilo-indexing@7.3.23 + +## 7.3.21 + +## 7.3.20 + +### Minor Changes + +- [#10754](https://github.com/Kilo-Org/kilocode/pull/10754) [`cf85e0d`](https://github.com/Kilo-Org/kilocode/commit/cf85e0d51f6e84298524cf39ce06f5c40a8599f4) - Experimental: Serve Kilo Console from the local daemon. Use `kilo console` to open it automatically. + +### Patch Changes + +- [#10786](https://github.com/Kilo-Org/kilocode/pull/10786) [`7dd8aab`](https://github.com/Kilo-Org/kilocode/commit/7dd8aabadeb1b5bcf69f5fb9545a57ac91daf54f) - Limit inferred background-process port discovery to the TUI and stop scanning after startup to avoid unnecessary Bun subprocess polling. + +- [#10783](https://github.com/Kilo-Org/kilocode/pull/10783) [`c44cd78`](https://github.com/Kilo-Org/kilocode/commit/c44cd786688a5050805fbd3c17e23ca14a5324a5) - Show a retryable connection error and preserve unsent prompts when the VS Code background CLI process exits. + +- [#10782](https://github.com/Kilo-Org/kilocode/pull/10782) [`1c06a1d`](https://github.com/Kilo-Org/kilocode/commit/1c06a1dbc7c2339fc7b7dd4bb45e31c9d80f259d) - Preserve the Changes review scroll position while agents update files. + +## 7.3.18 + +### Patch Changes + +- [#10594](https://github.com/Kilo-Org/kilocode/pull/10594) [`56d2ac4`](https://github.com/Kilo-Org/kilocode/commit/56d2ac40da6710adfe3de94f6b09bd53d9bb6db9) Thanks [@Ipsumlorem](https://github.com/Ipsumlorem)! - Fix Windows speech input device detection when FFmpeg lists DirectShow microphones by section. + +- [#10730](https://github.com/Kilo-Org/kilocode/pull/10730) [`51ce1b8`](https://github.com/Kilo-Org/kilocode/commit/51ce1b82b3374a9d573e3fe5ecbe19f4a22db9a4) - Reduce lag and gray placeholders in the diff and Changes views by enabling worker-backed highlighting and rendering patch-backed review hunks without rebuilding full source files. + +- Updated dependencies [[`51ce1b8`](https://github.com/Kilo-Org/kilocode/commit/51ce1b82b3374a9d573e3fe5ecbe19f4a22db9a4)]: + - @kilocode/kilo-ui@7.3.18 + +## 7.3.17 + +### Minor Changes + +- [#10674](https://github.com/Kilo-Org/kilocode/pull/10674) [`41729dc`](https://github.com/Kilo-Org/kilocode/commit/41729dcb596dfa37c32bac1b9e9143197e862252) - Link to Kilo Gateway BYOK usage information from provider API key connection dialogs. + +### Patch Changes + +- [#10721](https://github.com/Kilo-Org/kilocode/pull/10721) [`2efa216`](https://github.com/Kilo-Org/kilocode/commit/2efa216ee5bfffa6e01f51ae5add7c5b9034833c) - Keep Agent Manager turns running while slow snapshot baselines initialize instead of stopping for an interactive question. + +- [#10686](https://github.com/Kilo-Org/kilocode/pull/10686) [`d3c5f28`](https://github.com/Kilo-Org/kilocode/commit/d3c5f2886f07dbcd7669ee691a6a2a0b72a6f6e1) - Correct screen reader and keyboard operation for session history rows and Local or Cloud history navigation. + +- [#10688](https://github.com/Kilo-Org/kilocode/pull/10688) [`38fcaa6`](https://github.com/Kilo-Org/kilocode/commit/38fcaa65e7320e3befa73066ee1a890057d7173b) - Make model selection in chat and settings operable with screen readers by announcing searchable options, keyboard navigation, selected values, and model-setting purpose. + +- [#10609](https://github.com/Kilo-Org/kilocode/pull/10609) [`4e6f366`](https://github.com/Kilo-Org/kilocode/commit/4e6f366a75c71b6c5a2e3499e116b61c21355fbe) - Preserve prior context in forked sessions while recognizing the selected direction and current worktree context. + +- [#10668](https://github.com/Kilo-Org/kilocode/pull/10668) [`ef2390d`](https://github.com/Kilo-Org/kilocode/commit/ef2390d7a4ffafc379d1e15db94d3a2cd6dcce9b) - Access semantic indexing without an experimental feature toggle while keeping indexing disabled until enabled globally or for a project. + +- [#10680](https://github.com/Kilo-Org/kilocode/pull/10680) [`63f39f6`](https://github.com/Kilo-Org/kilocode/commit/63f39f6ae49dd7f9d5a8115f3907d53a3b92a4dd) - Support accessibility regression checks and assistive-technology testing for VS Code webviews. + +## 7.3.16 + +### Patch Changes + +- [#9796](https://github.com/Kilo-Org/kilocode/pull/9796) [`663fbdb`](https://github.com/Kilo-Org/kilocode/commit/663fbdb58547ac5b946ba4610918239b8a7e336f) Thanks [@ale-saglia](https://github.com/ale-saglia)! - Support Italian as a display language in the VS Code extension. + +- Updated dependencies [[`663fbdb`](https://github.com/Kilo-Org/kilocode/commit/663fbdb58547ac5b946ba4610918239b8a7e336f)]: + - @opencode-ai/ui@7.3.16 + - @kilocode/kilo-ui@7.3.16 + - @kilocode/kilo-i18n@7.3.16 + +## 7.3.15 + +### Patch Changes + +- [#10637](https://github.com/Kilo-Org/kilocode/pull/10637) [`7d8ec09`](https://github.com/Kilo-Org/kilocode/commit/7d8ec095c0d7d05b4c3f91149b873f9944716b23) - Show DeepSeek in the Popular Providers list instead of GitHub Copilot. + +- [#10599](https://github.com/Kilo-Org/kilocode/pull/10599) [`46213dc`](https://github.com/Kilo-Org/kilocode/commit/46213dcebda653c1575b67ef93fc8aab065a9db7) Thanks [@Drixled](https://github.com/Drixled)! - Improve chat error styling in the VS Code extension. + +- Updated dependencies [[`46213dc`](https://github.com/Kilo-Org/kilocode/commit/46213dcebda653c1575b67ef93fc8aab065a9db7)]: + - @kilocode/kilo-ui@7.3.15 + +## 7.3.14 + +### Minor Changes + +- [#10650](https://github.com/Kilo-Org/kilocode/pull/10650) [`f18a452`](https://github.com/Kilo-Org/kilocode/commit/f18a452082c998aa9f699204cda1fbf49fb3486f) - Add a "Not set (use server default)" option to the autocomplete model picker so users can follow the recommended default automatically. Users who previously had the default model pinned only because it was the only thing visible in the dropdown are migrated to "Not set" once. + +- [#10621](https://github.com/Kilo-Org/kilocode/pull/10621) [`29c3798`](https://github.com/Kilo-Org/kilocode/commit/29c3798faae2b82cba8ce531304630fee10f23b3) - Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). Thanks [@tfiras](https://github.com/tfiras)! + +- [#10644](https://github.com/Kilo-Org/kilocode/pull/10644) [`db38888`](https://github.com/Kilo-Org/kilocode/commit/db388889e867021c6bae42cbd03df6b67941b208) - Support Mercury Next Edit through the Kilo Gateway. The new "Mercury Next Edit via Kilo Gateway" autocomplete model routes Next Edit predictions through your Kilo account (no separate Inception API key required). + +- [#10608](https://github.com/Kilo-Org/kilocode/pull/10608) [`3ffacc8`](https://github.com/Kilo-Org/kilocode/commit/3ffacc847b79c8cdd44c17c4d26476998f24c098) - Make the Agent Manager tool available by default in VS Code. + +- [#10641](https://github.com/Kilo-Org/kilocode/pull/10641) [`4869d87`](https://github.com/Kilo-Org/kilocode/commit/4869d8722b423815a29832c812cf8a766c965a94) - Allow renaming sessions with a consistent inline editor in the active chat header and History, using safe bounded titles. + +### Patch Changes + +- [#10643](https://github.com/Kilo-Org/kilocode/pull/10643) [`6d77d6b`](https://github.com/Kilo-Org/kilocode/commit/6d77d6bbf293ebca7f76d848264d48073d29a44f) - Center local session history delete buttons within their rows. + +- [#10646](https://github.com/Kilo-Org/kilocode/pull/10646) [`d5a8989`](https://github.com/Kilo-Org/kilocode/commit/d5a8989b81d2cb0dd3ea4f62f3ee4570a7725891) - Improve the size and readability of the local History session context menu. + +- [#10619](https://github.com/Kilo-Org/kilocode/pull/10619) [`117691e`](https://github.com/Kilo-Org/kilocode/commit/117691e4d6fe48f91223bb7d7e24103c67cde73f) - Use supported hosted model presets for Kilo indexing and clear obsolete model and dimension overrides. + +- [#10642](https://github.com/Kilo-Org/kilocode/pull/10642) [`5a8d6ae`](https://github.com/Kilo-Org/kilocode/commit/5a8d6ae5dc8ed5d22117da96e7ee713b1a6e567b) - Restore readable diff highlighting and collapsed unchanged sections in VS Code themes. + +- [#10656](https://github.com/Kilo-Org/kilocode/pull/10656) [`d25d5ff`](https://github.com/Kilo-Org/kilocode/commit/d25d5ff473cbac8e230042d746b440465a259f11) - Keep the VS Code chat position stable when reading earlier output during a streaming response. + +- [#10652](https://github.com/Kilo-Org/kilocode/pull/10652) [`3af4c7e`](https://github.com/Kilo-Org/kilocode/commit/3af4c7ebabc2b95ece1c60cabb07930f9d4f42e6) - Warn when a chat turn stops unexpectedly or ends while tracked to-dos remain unfinished. + +- Updated dependencies [[`117691e`](https://github.com/Kilo-Org/kilocode/commit/117691e4d6fe48f91223bb7d7e24103c67cde73f), [`db38888`](https://github.com/Kilo-Org/kilocode/commit/db388889e867021c6bae42cbd03df6b67941b208)]: + - @kilocode/kilo-indexing@7.3.13 + - @kilocode/sdk@7.3.13 + - @kilocode/kilo-gateway@7.4.0 + - @kilocode/kilo-ui@7.3.13 + - @opencode-ai/ui@7.3.13 + +## 7.3.11 + +### Minor Changes + +- [#10505](https://github.com/Kilo-Org/kilocode/pull/10505) [`19f166d`](https://github.com/Kilo-Org/kilocode/commit/19f166dc71085ad08de558648cc164101c6ea43b) - Support choosing agent model and variant overrides from dropdowns. + +### Patch Changes + +- [#10581](https://github.com/Kilo-Org/kilocode/pull/10581) [`722eb44`](https://github.com/Kilo-Org/kilocode/commit/722eb44d6383d4e2670661632152716630127d02) - Show voice transcription automatically when you are signed in with the Kilo provider. + +- [#10441](https://github.com/Kilo-Org/kilocode/pull/10441) [`f26d55f`](https://github.com/Kilo-Org/kilocode/commit/f26d55f2d3edc652408a424262b356dc902bd64d) - Keep inline subagent streaming responsive during tool-heavy sessions. + +- [#10443](https://github.com/Kilo-Org/kilocode/pull/10443) [`8e76807`](https://github.com/Kilo-Org/kilocode/commit/8e7680794da86c6d938d6626066157c9cd18adbb) - Support configuring the default task subagent model and reasoning effort while safely inheriting the calling agent model when the override is unavailable. + +- [#10559](https://github.com/Kilo-Org/kilocode/pull/10559) [`33eb706`](https://github.com/Kilo-Org/kilocode/commit/33eb706c580bd89d4c9bc408de62b8bdf65fd61d) - Add explicit Mistral and Inception autocomplete options that use connected provider API keys for FIM requests. + +- [#10439](https://github.com/Kilo-Org/kilocode/pull/10439) [`18a7367`](https://github.com/Kilo-Org/kilocode/commit/18a73672eac8b9faf064b86b2718bce1cd92331e) - Remove the duplicate Work badge from busy Agent Manager tab search results. + +- [#10483](https://github.com/Kilo-Org/kilocode/pull/10483) [`637527d`](https://github.com/Kilo-Org/kilocode/commit/637527d80edad5ee0816a32ee0bd94b1f753aab9) - Hide sub-agent sessions from recent session shortcuts in the extension. + +- [#10575](https://github.com/Kilo-Org/kilocode/pull/10575) [`6415432`](https://github.com/Kilo-Org/kilocode/commit/6415432d225d696f29e030a84780e91011c1cf25) - Restore readable diff add and remove highlights in VS Code across dark and light themes. + +- [#10573](https://github.com/Kilo-Org/kilocode/pull/10573) [`48af33f`](https://github.com/Kilo-Org/kilocode/commit/48af33f940c9a41ce0ca326e9ec3f0b1284f51cd) - Explain in disabled Speech to Text controls that voice input currently requires Kilo Gateway access. + +- [#10486](https://github.com/Kilo-Org/kilocode/pull/10486) [`0ec80b0`](https://github.com/Kilo-Org/kilocode/commit/0ec80b0c6f401253fc05a7bd5c876e94320dfaa1) - Keep streamed Markdown code blocks stable while assistant output is still arriving. + +- Updated dependencies [[`6415432`](https://github.com/Kilo-Org/kilocode/commit/6415432d225d696f29e030a84780e91011c1cf25), [`0ec80b0`](https://github.com/Kilo-Org/kilocode/commit/0ec80b0c6f401253fc05a7bd5c876e94320dfaa1)]: + - @kilocode/kilo-ui@7.3.11 + - @opencode-ai/ui@7.3.11 + +## 7.3.10 + +### Patch Changes + +- [#10509](https://github.com/Kilo-Org/kilocode/pull/10509) [`c944aac`](https://github.com/Kilo-Org/kilocode/commit/c944aacf9577a6dc51ed06abf33d4d66a2a5cf1f) Thanks [@johnnyeric](https://github.com/johnnyeric)! - Keep `.agents/skills` discovery enabled in VS Code when Claude Code Compatibility is disabled. + +## 7.3.9 + +### Minor Changes + +- [#10513](https://github.com/Kilo-Org/kilocode/pull/10513) [`cd009c3`](https://github.com/Kilo-Org/kilocode/commit/cd009c3d8f5cc1101b30d1967e48e565cbda6ae4) - Support tracked background processes in the CLI and VS Code so agents can start long-running dev servers and clean them up when sessions change or end. The CLI also includes process management UI, status, and logs. + +### Patch Changes + +- [#10513](https://github.com/Kilo-Org/kilocode/pull/10513) [`06a6bf7`](https://github.com/Kilo-Org/kilocode/commit/06a6bf715d4390daf28e82bd3953c4c5deb2bb87) - Show detected ports for tracked background processes in the TUI sidebar and process detail dialog. + ## 7.3.8 ### Patch Changes diff --git a/packages/kilo-vscode/LICENSE b/packages/kilo-vscode/LICENSE index 5cb50685f18..c5762eb3ad5 100644 --- a/packages/kilo-vscode/LICENSE +++ b/packages/kilo-vscode/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Kilo Code +Copyright (c) 2026 Kilo Code Copyright (c) 2025 opencode Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/packages/kilo-vscode/README.md b/packages/kilo-vscode/README.md index 328d3f5d623..24b3cc4fe89 100644 --- a/packages/kilo-vscode/README.md +++ b/packages/kilo-vscode/README.md @@ -6,22 +6,36 @@ Reddit

        -# 🚀 Kilo +

        + kilo-code-logo +

        -> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent. +

        + Kilo is the all-in-one agentic engineering platform.
        + Build, ship, and iterate faster with the most popular open source coding agent. +

        -- ✨ Generate code from natural language -- ✅ Checks its own work -- 🧪 Run terminal commands -- 🌐 Automate the browser -- ⚡ Inline autocomplete suggestions -- 🤖 Latest AI models -- 🎁 API keys optional +

        + Kilo Code running inside VS Code +

        -## Quick Links +

        + Website · + Install · + IDE · + CLI · + Docs · + Models · + Gateway · + Pricing · + Kilo Pass +

        + +

        + 500+ models. One open source agent in VS Code, JetBrains, CLI, Slack, and Cloud. +

        -- [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download) -- [Official Kilo.ai Home page](https://kilo.ai) (learn more) +> 🚀 **Coming from Roo Code?** Switch to Kilo and check out our [migration guide](https://kilo.ai/articles/roo-to-kilo-migration-guide)! ## Key Features diff --git a/packages/kilo-vscode/audio-wav/alert-01.wav b/packages/kilo-vscode/audio-wav/alert-01.wav new file mode 100644 index 00000000000..061fdb53c3a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-02.wav b/packages/kilo-vscode/audio-wav/alert-02.wav new file mode 100644 index 00000000000..b73daff94a8 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-03.wav b/packages/kilo-vscode/audio-wav/alert-03.wav new file mode 100644 index 00000000000..7ad929468c1 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-04.wav b/packages/kilo-vscode/audio-wav/alert-04.wav new file mode 100644 index 00000000000..b99b52738a4 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-05.wav b/packages/kilo-vscode/audio-wav/alert-05.wav new file mode 100644 index 00000000000..5580a3f3ed1 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-06.wav b/packages/kilo-vscode/audio-wav/alert-06.wav new file mode 100644 index 00000000000..48dbd0dc870 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-07.wav b/packages/kilo-vscode/audio-wav/alert-07.wav new file mode 100644 index 00000000000..ed04924bec9 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-08.wav b/packages/kilo-vscode/audio-wav/alert-08.wav new file mode 100644 index 00000000000..f9de3611dd4 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-08.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-09.wav b/packages/kilo-vscode/audio-wav/alert-09.wav new file mode 100644 index 00000000000..842417b962a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-09.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-10.wav b/packages/kilo-vscode/audio-wav/alert-10.wav new file mode 100644 index 00000000000..f9a80aaddb3 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-10.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-01.wav b/packages/kilo-vscode/audio-wav/bip-bop-01.wav new file mode 100644 index 00000000000..eb259650ce1 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-02.wav b/packages/kilo-vscode/audio-wav/bip-bop-02.wav new file mode 100644 index 00000000000..44c5ea0d5c5 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-03.wav b/packages/kilo-vscode/audio-wav/bip-bop-03.wav new file mode 100644 index 00000000000..88899675b73 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-04.wav b/packages/kilo-vscode/audio-wav/bip-bop-04.wav new file mode 100644 index 00000000000..319ab26d71f Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-05.wav b/packages/kilo-vscode/audio-wav/bip-bop-05.wav new file mode 100644 index 00000000000..f9db904297d Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-06.wav b/packages/kilo-vscode/audio-wav/bip-bop-06.wav new file mode 100644 index 00000000000..dd561c3e14d Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-07.wav b/packages/kilo-vscode/audio-wav/bip-bop-07.wav new file mode 100644 index 00000000000..9ccf4984680 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-08.wav b/packages/kilo-vscode/audio-wav/bip-bop-08.wav new file mode 100644 index 00000000000..ec87ee8ce17 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-08.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-09.wav b/packages/kilo-vscode/audio-wav/bip-bop-09.wav new file mode 100644 index 00000000000..1a11d73ecc9 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-09.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-10.wav b/packages/kilo-vscode/audio-wav/bip-bop-10.wav new file mode 100644 index 00000000000..b4d5e705a0c Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-10.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-01.wav b/packages/kilo-vscode/audio-wav/nope-01.wav new file mode 100644 index 00000000000..3fa989aaaa2 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-02.wav b/packages/kilo-vscode/audio-wav/nope-02.wav new file mode 100644 index 00000000000..10948cf9667 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-03.wav b/packages/kilo-vscode/audio-wav/nope-03.wav new file mode 100644 index 00000000000..dbfb6eeb6e5 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-04.wav b/packages/kilo-vscode/audio-wav/nope-04.wav new file mode 100644 index 00000000000..dd1893e4b2a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-05.wav b/packages/kilo-vscode/audio-wav/nope-05.wav new file mode 100644 index 00000000000..562e49f9576 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-06.wav b/packages/kilo-vscode/audio-wav/nope-06.wav new file mode 100644 index 00000000000..7192f7a31c8 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-07.wav b/packages/kilo-vscode/audio-wav/nope-07.wav new file mode 100644 index 00000000000..f7d760c2a61 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-08.wav b/packages/kilo-vscode/audio-wav/nope-08.wav new file mode 100644 index 00000000000..7ee5c72b48a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-08.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-09.wav b/packages/kilo-vscode/audio-wav/nope-09.wav new file mode 100644 index 00000000000..254a083320a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-09.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-10.wav b/packages/kilo-vscode/audio-wav/nope-10.wav new file mode 100644 index 00000000000..9c43e5765ed Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-10.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-11.wav b/packages/kilo-vscode/audio-wav/nope-11.wav new file mode 100644 index 00000000000..934bdd42e9a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-11.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-12.wav b/packages/kilo-vscode/audio-wav/nope-12.wav new file mode 100644 index 00000000000..ed0b49189a3 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-12.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-01.wav b/packages/kilo-vscode/audio-wav/staplebops-01.wav new file mode 100644 index 00000000000..4c429b74612 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-02.wav b/packages/kilo-vscode/audio-wav/staplebops-02.wav new file mode 100644 index 00000000000..6ccc3f4f469 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-03.wav b/packages/kilo-vscode/audio-wav/staplebops-03.wav new file mode 100644 index 00000000000..e9ce09bb54c Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-04.wav b/packages/kilo-vscode/audio-wav/staplebops-04.wav new file mode 100644 index 00000000000..042e3f93e97 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-05.wav b/packages/kilo-vscode/audio-wav/staplebops-05.wav new file mode 100644 index 00000000000..194f85d02c6 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-06.wav b/packages/kilo-vscode/audio-wav/staplebops-06.wav new file mode 100644 index 00000000000..0a7bca02a4b Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-07.wav b/packages/kilo-vscode/audio-wav/staplebops-07.wav new file mode 100644 index 00000000000..3fc74be97a4 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-01.wav b/packages/kilo-vscode/audio-wav/yup-01.wav new file mode 100644 index 00000000000..2361194deb2 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-02.wav b/packages/kilo-vscode/audio-wav/yup-02.wav new file mode 100644 index 00000000000..acf914a6491 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-03.wav b/packages/kilo-vscode/audio-wav/yup-03.wav new file mode 100644 index 00000000000..58865b47fc3 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-04.wav b/packages/kilo-vscode/audio-wav/yup-04.wav new file mode 100644 index 00000000000..08f474cc771 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-05.wav b/packages/kilo-vscode/audio-wav/yup-05.wav new file mode 100644 index 00000000000..8837d4040a8 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-06.wav b/packages/kilo-vscode/audio-wav/yup-06.wav new file mode 100644 index 00000000000..4c65ec82a51 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-06.wav differ diff --git a/packages/kilo-vscode/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html new file mode 100644 index 00000000000..5772426f254 --- /dev/null +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -0,0 +1,1107 @@ + + + + + Mercury Next Edit — Testing Playground (Kilo Code) + + + + +
        +

        Mercury Next Edit — Testing Playground

        +

        + A walk-through guide for Kilo Code reviewers to validate the new + Next Edit Suggestion integration powered by Mercury Edit 2 from Inception Labs. +

        + +
        +

        On this page

        + +
        + +

        What is Mercury Next Edit?

        +

        + Mercury Edit 2 is a code-edit model from Inception Labs. + Unlike FIM completion, it predicts the user's next multi-line edit given the current file, cursor + position, and recent edit history. It typically responds in under 250 ms. +

        +

        + This PR adds Mercury Next Edit as a new, opt-in autocomplete option in Kilo Code. It lives + alongside everything that's already shipping — Codestral FIM and Mercury Edit 2 via the Kilo gateway are + bit-for-bit unchanged. Selecting Mercury Next Edit (Inception) from the model dropdown switches + to a separate render pipeline: +

        +
          +
        • + Same-line predictions render as inline ghost text (just like FIM — + Tab accepts). +
        • +
        • + Off-cursor predictions render as a decoration (red strikethrough + green ghost annotation) at + the predicted edit location. First Tab teleports the cursor there; second Tab applies. +
        • +
        • + After any accept, the integration immediately re-triggers Mercury so the user can walk a refactor with + repeated Tab presses ("Tab-Tab-Tab"). +
        • +
        + +

        Install the PR locally

        +

        + You'll need to pull this PR's branch and run the extension in a development VSCode window ("Extension + Development Host"). The whole loop is about 3 minutes once you have the prerequisites. +

        + +

        Prerequisites

        +
          +
        • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
        • +
        • + Bun ≥ 1.3.14 (the build script checks the version) — install via + brew install bun or bun.sh +
        • +
        • GitHub CLI (gh) — optional but makes the PR checkout one command
        • +
        • + An Inception API key — create one at + platform.inceptionlabs.ai if you don't already have one +
        • +
        + +

        1. Check out the PR branch

        +

        From an empty directory:

        +
        gh repo clone Kilo-Org/kilocode
        +cd kilocode
        +gh pr checkout 10536
        +

        Or without gh:

        +
        git clone https://github.com/Kilo-Org/kilocode.git
        +cd kilocode
        +git fetch origin pull/10536/head:mercury-next-edit-integration
        +git checkout mercury-next-edit-integration
        + +

        2. Install dependencies

        +
        bun install
        +

        (First install pulls the full monorepo — takes 30–60 seconds.)

        + +

        3. Start the dev build

        +
        cd packages/kilo-vscode
        +bun run watch
        +

        + Leave that terminal running. It rebuilds the extension on every save and runs the TypeScript compiler in watch + mode. +

        + +

        4. Open kilocode in VSCode and launch the Extension Development Host

        +

        From a separate terminal (or your IDE launcher):

        +
        code /path/to/kilocode
        +

        + Inside that VSCode window, press F5 (or Run → Start Debugging). A second VSCode + window opens, titled [Extension Development Host]. That window has this PR's build of the + kilocode extension loaded. +

        + +
        +

        + Pre-push turbo typecheck may fail on packages that need Java (JetBrains plugin). That's + environment, not code — not relevant to the NES feature. Use --no-verify on any local + pushes if you hit it. +

        +
        + +

        5. Open the test playground

        +

        + In the Dev Host window: File → Open Folder… → choose + packages/kilo-vscode/docs/nes-examples/ inside this same repo. That gives you the 20 self-contained + test files described below. +

        + +

        6. Configure NES

        +

        Settings (Cmd+,) in the Dev Host, search kilo-code.new.autocomplete:

        +
          +
        • + modelMercury Next Edit (Inception)not "Mercury Edit 2", + which is the classic FIM-via-gateway option +
        • +
        • + nextEdit.apiKey → paste your sk_… Inception API key (or set + INCEPTION_API_KEY env before launching) +
        • +
        • enableAutoTrigger → ✓ (already the default)
        • +
        + +

        7. Watch the pipeline live

        +

        + In the Dev Host: View → Output → in the dropdown, select + "Kilo Code · Next Edit". Every request, response, and render decision is logged here with + timestamps. Keep this panel visible while testing — it's the single best diagnostic. +

        + +

        You're set. Skip to the test cases below.

        + +

        Enabling the feature (settings reference)

        +

        In VSCode Settings (Cmd+,), search kilo-code.new.autocomplete:

        + + + + + + + + + + + + + + + + + + + + + + + + + +
        SettingValue
        model + Mercury Next Edit (Inception)not "Mercury Edit 2", which is the original + FIM-via-gateway option +
        nextEdit.apiKeyyour Inception API key (sk_...); also accepts INCEPTION_API_KEY env var
        enableAutoTrigger✓ (default)
        nextEdit.baseUrl(optional) override the API base, defaults to https://api.inceptionlabs.ai/v1
        nextEdit.debug(optional) mirror diagnostic logs to DevTools console
        +

        + To watch the pipeline live: View → Output in the Dev Host, choose the + "Kilo Code · Next Edit" channel. +

        + +

        How the integration works

        +

        + The AutocompleteServiceManager instantiates both providers up front. Provider + registration with vscode.languages.registerInlineCompletionItemProvider is driven by the + configured model: +

        +
          +
        • inception/mercury-next-editNES provider (this PR's new pipeline)
        • +
        • anything else → classic FIM provider (unchanged)
        • +
        +

        The NES provider, per keystroke:

        +
          +
        1. Debounces 250 ms (skipped for explicit invocations).
        2. +
        3. + Builds a Mercury prompt: current file + cursor + an editable region [cursor − 5, cursor + 10] + + 3–5 recently-viewed-snippet ranges (from the shared RecentlyVisitedRangesService) + the last 5 + debounced unidiffs (from a new per-file EditHistoryTracker). +
        4. +
        5. + Sends a single role: "user" message to POST /v1/edit/completions with + max_tokens: 512. +
        6. +
        7. + Parses the triple-backtick fenced reply, strips Mercury's sentinel tokens, computes the minimal line-diff + against the current document. +
        8. +
        9. + Branches: same-line diff → InlineCompletionItem; off-cursor diff → + NextEditSuggestionManager with a decoration + Tab/Esc keybinding gated on a context flag + (kilo-code.nextEdit.hasPendingSuggestion). +
        10. +
        + +

        Test cases

        +

        + Each test below is a self-contained file at packages/kilo-vscode/docs/nes-examples/ in this repo. + Open that folder in the Extension Development Host (step 5 above), then work through the cases. Place your + cursor where indicated, wait ~300 ms idle, and observe. +

        +

        + Tip: keep this page open in a separate window from the Dev Host — the descriptions below would otherwise + leak into Mercury's prompt context and bias the test. +

        + +

        Render-path legend:

        +

        + same-line ghost appears as inline ghost text at the cursor (Tab + accepts) · off-cursor decoration renders away from the cursor; first + Tab jumps, second Tab applies · + suppressed negative case — nothing should render +

        + +

        Python — core tests

        + +
        +

        01 — Finish a recursive function body same-line

        +
        def factorial(n):
        +    if n <= 1:
        +        return 1
        +
        +
        +
        Cursor
        +

        The empty indented line at the end of factorial (column 4).

        +
        Expected
        +

        + Ghost text proposing the recursive case (e.g. return n * factorial(n - 1)). + Tab accepts. +

        +
        + +
        +

        02 — Pattern continuation same-line

        +
        COLOR_RED = "#ff0000"
        +COLOR_GREEN = "#00ff00"
        +COLOR_BLUE =
        +
        Cursor
        +

        End of line 3 (right after =).

        +
        Expected
        +

        Ghost text appending a hex color like "#0000ff".

        +
        + +
        +

        03 — Mid-identifier completion same-line

        +
        def calculate_total(items):
        +    total = 0
        +    for item in items:
        +        total += item.price
        +    return tot
        +
        Cursor
        +

        End of file (after return tot).

        +
        Expected
        +

        Ghost text completing the identifier (likely altotal).

        +
        + +
        +

        04 — Loop body inference same-line

        +
        def calculate_total(items):
        +    total = 0
        +    for item in items:
        +
        +    return total
        +
        Cursor
        +

        The empty indented line inside the for loop (column 8).

        +
        Expected
        +

        Ghost text proposing the accumulator update.

        +
        + +
        +

        05 — Sibling method body same-line

        +
        class Stack:
        +    def __init__(self):
        +        self.items = []
        +
        +    def push(self, item):
        +        self.items.append(item)
        +
        +    def pop(self):
        +
        +
        +    def peek(self):
        +        return self.items[-1] if self.items else None
        +
        Cursor
        +

        Empty indented line inside pop (column 8).

        +
        Expected
        +

        Ghost text proposing a body consistent with the symmetric push.

        +
        + +

        Python — advanced

        + +
        +

        07 — Multi-line rename refactor off-cursor

        +
        def compute_user_score(u, w):
        +    base = u * 10
        +    bonus = w * 5
        +    penalty = u - w
        +    return base + bonus - penalty
        +
        +
        +def compute_user_score(user_id, weight):
        +    base = u * 10
        +    bonus = w * 5
        +    penalty = u - w
        +    return base + bonus - penalty
        +
        Cursor
        +

        End of the renamed signature line (def compute_user_score(user_id, weight):).

        +
        Expected
        +

        + Strikethrough on the body lines below + ghost showing the renamed body. First Tab jumps, second + applies. +

        +
        + +
        +

        08 — Mixed insert + replace off-cursor

        +
        def sum_prices(items):
        +    total = 0
        +    for item in items:
        +    return total
        +
        Cursor
        +

        End of total = 0.

        +
        Expected
        +

        Decoration on the broken for-loop area showing the corrected body (insertion + replacement combined).

        +
        + +
        +

        10 — Mid-token completion same-line

        +
        def fibonacci(n):
        +    if n <= 1:
        +        return n
        +    return fibonacci(n - 1) + fibonacci(n - 2)
        +
        +
        +result = fib
        +
        Cursor
        +

        End of the file (after result = fib).

        +
        Expected
        +

        Ghost text extending the identifier and supplying a call, e.g. onacci(10).

        +
        + +
        +

        11 — Stub method with implemented siblings same-line

        +
        class Queue:
        +    def __init__(self):
        +        self.items = []
        +
        +    def enqueue(self, item):
        +        self.items.append(item)
        +
        +    def peek(self):
        +        return self.items[0] if self.items else None
        +
        +    def size(self):
        +        return len(self.items)
        +
        +    def is_empty(self):
        +        return not self.items
        +
        +    def dequeue(self):
        +
        +
        +    def clear(self):
        +        self.items.clear()
        +
        Cursor
        +

        Empty indented line inside dequeue (column 8).

        +
        Expected
        +

        Ghost text proposing a FIFO pop, e.g. return self.items.pop(0).

        +
        + +
        +

        + 12 — Type annotation insertion same-line / + off-cursor +

        +
        def multiply(a: int, b: int) -> int:
        +    return a * b
        +
        +
        +def subtract(a: int, b: int) -> int:
        +    return a - b
        +
        +
        +def add(a, b):
        +    return a + b
        +
        Cursor
        +

        End of def add(a, b): (the only un-annotated function).

        +
        Expected
        +

        + Strikethrough on the signature line + ghost showing the typed version (def add(a: int, b: int) -> int:). May render same-line or off-cursor depending on where on the line you clicked. +

        +
        + +
        +

        13 — Docstring generation same-line

        +
        import datetime
        +
        +
        +def parse_iso_datetime(s):
        +    """Parse an ISO 8601 datetime string into a datetime.datetime."""
        +    return datetime.datetime.fromisoformat(s)
        +
        +
        +def parse_iso_date(s):
        +
        +    return datetime.date.fromisoformat(s)
        +
        Cursor
        +

        Empty indented line under def parse_iso_date(s): (column 4).

        +
        Expected
        +

        Ghost text inserting a one-line docstring matching the sibling's style.

        +
        + +
        +

        14 — No-op suppression suppressed

        +
        def add(a: int, b: int) -> int:
        +    """Return the sum of two integers."""
        +    return a + b
        +
        +
        +def multiply(a: int, b: int) -> int:
        +    """Return the product of two integers."""
        +    return a * b
        +
        +
        +def subtract(a: int, b: int) -> int:
        +    """Return a minus b."""
        +    return a - b
        +
        Cursor
        +

        End of return a + b.

        +
        Expected
        +

        + Nothing. The code is already correct — either Mercury returns an identical reply or our + suppression branch drops the proposal. Channel should show "no-op" or skip lines, never a render. +

        +
        Failure mode
        +

        Any visible suggestion that just replays the existing code is a false positive worth reporting.

        +
        + +

        TypeScript

        + +
        +

        ts_07 — Array transform completion same-line

        +
        interface User {
        +    id: number;
        +    name: string;
        +    active: boolean;
        +}
        +
        +function getActiveUserNames(users: User[]): string[] {
        +    return users
        +}
        +
        +const sample: User[] = [
        +    { id: 1, name: "ada", active: true },
        +    { id: 2, name: "lin", active: false },
        +    { id: 3, name: "rin", active: true },
        +];
        +
        +console.log(getActiveUserNames(sample));
        +
        Cursor
        +

        End of return users inside getActiveUserNames.

        +
        Expected
        +

        Ghost text completing the chain, e.g. .filter(u => u.active).map(u => u.name).

        +
        + +
        +

        ts_08 — Param type annotations off-cursor

        +
        function double(x: number): number {
        +    return x * 2;
        +}
        +
        +function add(a, b) {
        +    return a + b;
        +}
        +
        +function negate(x: number): number {
        +    return -x;
        +}
        +
        +function main(): void {
        +    console.log(double(3));
        +    console.log(add(2, 4));
        +    console.log(negate(7));
        +}
        +
        +main();
        +
        Cursor
        +

        End of file (after main();).

        +
        Expected
        +

        Decoration on the add(a, b) signature proposing the typed version.

        +
        + +
        +

        ts_09 — React event handler same-line

        +
        declare const React: {
        +    useState: <T>(initial: T) => [T, (next: T) => void];
        +};
        +
        +function Counter(): JSX.Element {
        +    const [count, setCount] = React.useState<number>(0);
        +
        +    function handleClick() {
        +
        +    }
        +
        +    return (
        +        <div>
        +            <p>Count: {count}</p>
        +            <button onClick={handleClick}>Increment</button>
        +        </div>
        +    );
        +}
        +
        +export default Counter;
        +
        Cursor
        +

        Empty indented line inside handleClick (column 4).

        +
        Expected
        +

        Ghost text incrementing count via setCount.

        +
        + +

        Go

        + +
        +

        go_07 — Error handling block same-line

        +
        package main
        +
        +import (
        +	"fmt"
        +	"os"
        +)
        +
        +func loadConfig(path string) ([]byte, error) {
        +	data, err := os.ReadFile(path)
        +
        +	return data, nil
        +}
        +
        +func main() {
        +	cfg, err := loadConfig("config.json")
        +	if err != nil {
        +		fmt.Println("error:", err)
        +		return
        +	}
        +	fmt.Println(string(cfg))
        +}
        +
        Cursor
        +

        Empty line right after data, err := os.ReadFile(path).

        +
        Expected
        +

        Ghost text proposing the canonical if err != nil { return nil, err }.

        +
        + +
        +

        go_08 — Struct method body same-line

        +
        package main
        +
        +import "fmt"
        +
        +type Rectangle struct {
        +	Width  float64
        +	Height float64
        +}
        +
        +func (r Rectangle) Perimeter() float64 {
        +	return 2 * (r.Width + r.Height)
        +}
        +
        +func (r Rectangle) Area() float64 {
        +
        +}
        +
        +func main() {
        +	r := Rectangle{Width: 3, Height: 4}
        +	fmt.Println("perimeter:", r.Perimeter())
        +	fmt.Println("area:", r.Area())
        +}
        +
        Cursor
        +

        Empty indented line inside Area().

        +
        Expected
        +

        Ghost text computing area from Width and Height.

        +
        + +
        +

        go_09 — Goroutine + channel same-line

        +
        package main
        +
        +import "fmt"
        +
        +func main() {
        +	ch := make(chan int)
        +
        +	go func() {
        +
        +	}()
        +
        +	for v := range ch {
        +		fmt.Println("got:", v)
        +	}
        +}
        +
        Cursor
        +

        Empty indented line inside the goroutine.

        +
        Expected
        +

        Ghost text producing values onto the channel and closing it.

        +
        + +

        Rust

        + +
        +

        rs_07 — Match-arm completion same-line

        +
        enum Shape {
        +    Circle(f64),
        +    Square(f64),
        +    Rectangle(f64, f64),
        +    Triangle(f64, f64),
        +}
        +
        +fn area(s: &Shape) -> f64 {
        +    match s {
        +        Shape::Circle(r) => std::f64::consts::PI * r * r,
        +        Shape::Square(side) => side * side,
        +
        +    }
        +}
        +
        +fn main() {
        +    let shapes = vec![
        +        Shape::Circle(1.0),
        +        Shape::Rectangle(2.0, 3.0),
        +        Shape::Triangle(4.0, 5.0),
        +    ];
        +    for s in &shapes {
        +        println!("area = {}", area(s));
        +    }
        +}
        +
        Cursor
        +

        Empty indented line inside the match body, after the Square arm.

        +
        Expected
        +

        Ghost text adding the missing Rectangle and Triangle arms.

        +
        + +
        +

        rs_08 — Result/Option chaining same-line

        +
        fn parse_int(s: &str) -> Option<i32> {
        +    let n = s.trim()
        +    Some(n * 2)
        +}
        +
        +fn main() {
        +    let inputs = ["  21  ", "not-a-number", "10"];
        +    for s in &inputs {
        +        match parse_int(s) {
        +            Some(v) => println!("{} -> {}", s, v),
        +            None => println!("{} -> skipped", s),
        +        }
        +    }
        +}
        +
        Cursor
        +

        End of let n = s.trim() (no semicolon yet).

        +
        Expected
        +

        Ghost text continuing the chain into a parsed i32.

        +
        + +
        +

        rs_09 — Lifetime annotations off-cursor

        +
        fn longest(a: &str, b: &str) -> &str {
        +    if a.len() >= b.len() {
        +        a
        +    } else {
        +        b
        +    }
        +}
        +
        +fn main() {
        +    let s1 = String::from("hello world");
        +    let s2 = String::from("hi");
        +    let out = longest(&s1, &s2);
        +    println!("longest = {}", out);
        +}
        +
        Cursor
        +

        End of file.

        +
        Expected
        +

        Decoration on the fn longest signature proposing lifetime annotations.

        +
        + +

        JavaScript

        + +
        +

        js_07 — Async/await fetch same-line

        +
        async function fetchUser(id) {
        +    try {
        +
        +    } catch (err) {
        +        console.error("fetchUser failed", err);
        +        return null;
        +    }
        +}
        +
        +async function main() {
        +    const user = await fetchUser(42);
        +    console.log("user:", user);
        +}
        +
        +main();
        +
        Cursor
        +

        Empty indented line inside the try { block (column 8).

        +
        Expected
        +

        Ghost text completing the fetch + json parse.

        +
        + +
        +

        js_08 — Express GET handler same-line

        +
        const app = {
        +    get: (_path, _handler) => app,
        +    post: (_path, _handler) => app,
        +    listen: (_port, cb) => cb && cb(),
        +};
        +
        +const users = [
        +    { id: 1, name: "ada" },
        +    { id: 2, name: "lin" },
        +];
        +
        +app.get("/users/:id", (req, res) => {
        +
        +});
        +
        +app.post("/users", (req, res) => {
        +    const user = { id: users.length + 1, name: req.body.name };
        +    users.push(user);
        +    res.status(201).json(user);
        +});
        +
        +app.listen(3000, () => console.log("listening on :3000"));
        +
        Cursor
        +

        Empty indented line inside the GET handler (column 4).

        +
        Expected
        +

        Ghost text proposing a get-by-id (lookup, 404, json response).

        +
        + +

        SQL

        + +
        +

        sql_07 — Missing JOIN same-line

        +
        SELECT
        +    c.name,
        +    SUM(o.total) AS total_spent
        +FROM orders o
        +
        +WHERE o.created_at >= '2026-01-01'
        +GROUP BY c.name
        +ORDER BY total_spent DESC
        +LIMIT 10;
        +
        Cursor
        +

        End of the line FROM orders o.

        +
        Expected
        +

        Ghost text completing the JOIN against customers.

        +
        + +
        +

        sql_08 — WHERE filter same-line

        +
        SELECT id, email
        +FROM users
        +WHERE
        +ORDER BY last_login_at DESC;
        +
        Cursor
        +

        End of the bare WHERE line.

        +
        Expected
        +

        Ghost text proposing a predicate.

        +
        + +

        Markdown (negative case)

        + +
        +

        md_07 — Prose should stay quiet suppressed

        +
        # Mercury Edit 2 — Quick Notes
        +
        +Mercury Edit 2 is a small, fast model trained to predict the user's
        +next single edit given the current file, cursor position, and recent
        +edit history. It targets latency under 200 ms on typical files and
        +returns a unified-diff-like patch scoped to a window around the cursor.
        +
        +Unlike chat-style completions, the model is biased toward minimal,
        +local changes — finishing a function body, fixing a typo, propagating
        +a rename — rather than generating new files from scratch.
        +
        Cursor
        +

        End of the last sentence.

        +
        Expected
        +

        + Nothing. If Mercury does propose a prose continuation it counts as a soft fail — we + don't want a code model writing README content. +

        +
        + +

        Troubleshooting

        +
        +

        + If nothing happens when you type, open View → Output → "Kilo Code · Next Edit" and watch the + log. The pipeline is verbose enough that 90% of issues are obvious from the first few lines. +

        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        SymptomLikely causeFix
        No log lines at allWrong model selected, or Dev Host wasn't reloaded after rebuild + Cmd+R in the Dev Host; confirm model = Mercury Next Edit (Inception) +
        skip — no API key resolvedSetting not savedRe-paste the key in nextEdit.apiKey, press Enter, reload
        <- 401 UnauthorizedWrong key or wrong tierVerify the key at platform.inceptionlabs.ai
        <- 400 Bad RequestPrompt-shape regression (we shouldn't ship this, but if it happens during dev)Capture the response body from the channel and ping the integration owner
        Suggestion shown for a wrong-looking model + Selecting "Mercury Edit 2" routes through the classic FIM provider, not NES — that's by design (the + old behavior is preserved) + Switch to "Mercury Next Edit (Inception)" to use the new pipeline
        Inline ghost text never appears, but logs show RENDERAnother extension (Copilot, Tabnine) is winning the inline-completion raceTemporarily disable conflicting extensions in the Dev Host
        + +

        Feedback we'd love

        +
          +
        • + Where the prediction was wrong but the UX was correct. Note the file + cursor position + what + Mercury proposed. Helps us tune the model. +
        • +
        • + Where the UX got in the way. Tab semantics, decoration appearance, chained-prediction timing, + anything that felt clumsy compared to other NES products you've used. +
        • +
        • + Performance regressions in classic FIM autocomplete. The PR is supposed to leave the classic + path untouched — if Codestral or Mercury Edit 2 (FIM) feel different in this build, that's a regression + we want to know about. +
        • +
        • + Things you tried that aren't in this doc. The 20 tests are a starting point, not a contract. + Real codebases will be different. +
        • +
        + + +
        + + diff --git a/packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py b/packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py new file mode 100644 index 00000000000..939b1044889 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/01_finish_function_body.py @@ -0,0 +1,4 @@ +def factorial(n): + if n <= 1: + return 1 + diff --git a/packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py b/packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py new file mode 100644 index 00000000000..87d3616b278 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/02_pattern_continuation.py @@ -0,0 +1,3 @@ +COLOR_RED = "#ff0000" +COLOR_GREEN = "#00ff00" +COLOR_BLUE = diff --git a/packages/kilo-vscode/docs/nes-examples/03_typo_completion.py b/packages/kilo-vscode/docs/nes-examples/03_typo_completion.py new file mode 100644 index 00000000000..932190bfe8b --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/03_typo_completion.py @@ -0,0 +1,5 @@ +def calculate_total(items): + total = 0 + for item in items: + total += item.price + return tot diff --git a/packages/kilo-vscode/docs/nes-examples/04_loop_body.py b/packages/kilo-vscode/docs/nes-examples/04_loop_body.py new file mode 100644 index 00000000000..7d7c9629b8a --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/04_loop_body.py @@ -0,0 +1,5 @@ +def calculate_total(items): + total = 0 + for item in items: + + return total diff --git a/packages/kilo-vscode/docs/nes-examples/05_class_method.py b/packages/kilo-vscode/docs/nes-examples/05_class_method.py new file mode 100644 index 00000000000..2613e953eb5 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/05_class_method.py @@ -0,0 +1,12 @@ +class Stack: + def __init__(self): + self.items = [] + + def push(self, item): + self.items.append(item) + + def pop(self): + + + def peek(self): + return self.items[-1] if self.items else None diff --git a/packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py b/packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py new file mode 100644 index 00000000000..7846ab08f4b --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/07_multiline_rename_refactor.py @@ -0,0 +1,12 @@ +def compute_user_score(u, w): + base = u * 10 + bonus = w * 5 + penalty = u - w + return base + bonus - penalty + + +def compute_user_score(user_id, weight): + base = u * 10 + bonus = w * 5 + penalty = u - w + return base + bonus - penalty diff --git a/packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py b/packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py new file mode 100644 index 00000000000..94d9332e062 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/08_mixed_insert_and_replace.py @@ -0,0 +1,4 @@ +def sum_prices(items): + total = 0 + for item in items: + return total diff --git a/packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py b/packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py new file mode 100644 index 00000000000..5139ba755d3 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/10_mid_token_completion.py @@ -0,0 +1,7 @@ +def fibonacci(n): + if n <= 1: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + + +result = fib diff --git a/packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py b/packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py new file mode 100644 index 00000000000..f2c4845e20e --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/11_fill_sibling_method.py @@ -0,0 +1,21 @@ +class Queue: + def __init__(self): + self.items = [] + + def enqueue(self, item): + self.items.append(item) + + def peek(self): + return self.items[0] if self.items else None + + def size(self): + return len(self.items) + + def is_empty(self): + return not self.items + + def dequeue(self): + + + def clear(self): + self.items.clear() diff --git a/packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py b/packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py new file mode 100644 index 00000000000..8013be0a2a8 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/12_type_annotation_insertion.py @@ -0,0 +1,10 @@ +def multiply(a: int, b: int) -> int: + return a * b + + +def subtract(a: int, b: int) -> int: + return a - b + + +def add(a, b): + return a + b diff --git a/packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py b/packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py new file mode 100644 index 00000000000..928cbb322bc --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/13_docstring_generation.py @@ -0,0 +1,11 @@ +import datetime + + +def parse_iso_datetime(s): + """Parse an ISO 8601 datetime string into a datetime.datetime.""" + return datetime.datetime.fromisoformat(s) + + +def parse_iso_date(s): + + return datetime.date.fromisoformat(s) diff --git a/packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py b/packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py new file mode 100644 index 00000000000..81d91f5554a --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/14_no_op_suppression.py @@ -0,0 +1,13 @@ +def add(a: int, b: int) -> int: + """Return the sum of two integers.""" + return a + b + + +def multiply(a: int, b: int) -> int: + """Return the product of two integers.""" + return a * b + + +def subtract(a: int, b: int) -> int: + """Return a minus b.""" + return a - b diff --git a/packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md b/packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md new file mode 100644 index 00000000000..bc5f8822d0d --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/INSTRUCTIONS.md @@ -0,0 +1,201 @@ +# NES Test Playground — Instructions + +These tests are designed so that the source files contain **no hints** about what Mercury is supposed to predict. All cursor placements and expected behaviors live here. Don't open this file inside the Dev Host while testing — keep it in a separate window so the model can't see it. + +## One-time setup + +1. **`bun run watch`** is already running for the kilocode extension. +2. In the kilocode VSCode window, press **F5** → opens the **Extension Development Host**. +3. In the Dev Host: `File → Open Folder…` → `packages/kilo-vscode/docs/nes-examples/` inside this repo. +4. Open Settings (`Cmd+,`), confirm: + - `kilo-code.new.autocomplete.enableAutoTrigger` → ✓ (default true) + - `kilo-code.new.autocomplete.model` → **Mercury Next Edit (Inception)** ← *NOT* "Mercury Edit 2", which is the classic FIM option + - `kilo-code.new.autocomplete.nextEdit.apiKey` → your `sk_...` key + - VSCode global `editor.inlineSuggest.enabled` → ✓ +5. To watch the pipeline live: `View → Output` → pick the **"Kilo Code · Next Edit"** channel. + +## Conventions used below + +- **Cursor placement**: where to click in the file before waiting. +- **Expected (editor)**: what should appear on screen. +- **Expected (channel)**: a stripped-down line you should see in the Next Edit output channel. +- **Path**: which NES rendering path this exercises — same-line ghost / off-cursor replace / off-cursor insert / suppressed. + +After each test, **don't accept** if you want to re-run it — the suggestion will edit the file. Either `Cmd+Z` after accept, or just navigate to the next test file. + +--- + +## Core tests (Python) + +### 01 — Finish a function body *(path: same-line insert)* +- **File**: `01_finish_function_body.py` +- **Cursor**: the empty indented line at the end of `factorial` (column 4). +- **Expected editor**: ghost text proposing the recursive case. +- **Expected channel**: `diff at lines [N..N], cursor at line N`, then `RENDER`. + +### 02 — Pattern continuation *(path: same-line ghost)* +- **File**: `02_pattern_continuation.py` +- **Cursor**: end of the last line (after `COLOR_BLUE = `). +- **Expected editor**: ghost text appending a hex color. +- **Expected channel**: `diff at lines [N..N], cursor at line N`, then `RENDER`. + +### 03 — Mid-identifier completion *(path: same-line ghost)* +- **File**: `03_typo_completion.py` +- **Cursor**: end of the file (after `return tot`). +- **Expected editor**: ghost text completing the identifier. +- **Path**: same-line. + +### 04 — Loop body inference *(path: same-line insert)* +- **File**: `04_loop_body.py` +- **Cursor**: the empty indented line inside the `for` loop (column 8). +- **Expected editor**: ghost text proposing the accumulator update. + +### 05 — Sibling method body *(path: same-line insert)* +- **File**: `05_class_method.py` +- **Cursor**: empty indented line inside `pop` (column 8). +- **Expected editor**: ghost text proposing the pop body. + +--- + +## Advanced Python tests + +### 07 — Multi-line rename refactor *(path: off-cursor replace)* +- **File**: `07_multiline_rename_refactor.py` +- **Cursor**: end of the line with `def compute_user_score(user_id, weight):` (the renamed signature). The body below still uses the old `u` / `w` names. +- **Expected editor**: strikethrough on the body lines + ghost showing the renamed body. +- **Tab**: jump, then apply. + +### 08 — Mixed insert + replace *(path: off-cursor replace, multi-line)* +- **File**: `08_mixed_insert_and_replace.py` +- **Cursor**: end of line `total = 0`. +- **Expected editor**: a decoration spanning the for-loop area showing the corrected accumulator body. The proposed text is longer than the original. + +### 10 — Mid-token completion *(path: same-line ghost)* +- **File**: `10_mid_token_completion.py` +- **Cursor**: end of the file (after `result = fib`). +- **Expected editor**: ghost extending the identifier and supplying a call. + +### 11 — Stub method with implemented siblings *(path: same-line insert)* +- **File**: `11_fill_sibling_method.py` +- **Cursor**: empty indented line inside `dequeue` (column 8). +- **Expected editor**: ghost text filling in the FIFO body. + +### 12 — Type annotation insertion *(path: same-line replace OR off-cursor replace)* +- **File**: `12_type_annotation_insertion.py` +- **Cursor**: on line `def add(a, b):` (anywhere on that line works; end-of-line is easiest). +- **Expected editor**: strikethrough + ghost showing the typed signature. May render as inline ghost depending on where you place the cursor on the line. + +### 13 — Docstring generation *(path: same-line insert)* +- **File**: `13_docstring_generation.py` +- **Cursor**: empty indented line directly under `def parse_iso_date(s):` (column 4). +- **Expected editor**: ghost text starting with `"""` and a one-line description. + +### 14 — No-op suppression (NEGATIVE) *(path: suppressed)* +- **File**: `14_no_op_suppression.py` +- **Cursor**: end of `return a + b`. +- **Expected editor**: NOTHING. No ghost, no decoration. +- **Expected channel**: either `identical replacement — no-op` or no `RENDER` line. +- **Fail mode**: any visible suggestion is a false positive. + +--- + +## TypeScript + +### ts_07 — Array transform *(same-line)* +- **File**: `ts_07_array_transform.ts` +- **Cursor**: end of `return users` inside `getActiveUserNames`. +- **Expected editor**: ghost text completing a `.filter(...).map(...)` chain. + +### ts_08 — Param type annotations *(off-cursor replace)* +- **File**: `ts_08_param_types.ts` +- **Cursor**: end of file (after `main();`). +- **Expected editor**: strikethrough on `add(a, b)` signature + ghost showing the typed version. + +### ts_09 — React event handler *(same-line insert)* +- **File**: `ts_09_jsx_handler.tsx` +- **Cursor**: empty indented line inside `handleClick` (column 4). +- **Expected editor**: ghost text incrementing `count`. + +--- + +## Go + +### go_07 — Error handling *(same-line insert, multi-line)* +- **File**: `go_07_error_handling.go` +- **Cursor**: empty line right after `data, err := os.ReadFile(path)`. +- **Expected editor**: ghost text proposing the canonical `if err != nil { return nil, err }` block. + +### go_08 — Struct method body *(same-line insert)* +- **File**: `go_08_struct_method.go` +- **Cursor**: empty indented line inside `Area()`. +- **Expected editor**: ghost text computing area from `Width` and `Height`. + +### go_09 — Goroutine + channel *(same-line insert, multi-line)* +- **File**: `go_09_goroutine_channel.go` +- **Cursor**: empty indented line inside the goroutine. +- **Expected editor**: ghost text producing values onto the channel and closing it. + +--- + +## Rust + +### rs_07 — Match-arm completion *(same-line)* +- **File**: `rs_07_match_arms.rs` +- **Cursor**: empty indented line inside the `match s {` body, after the `Square` arm (column 8). +- **Expected editor**: ghost text proposing the missing `Rectangle` and `Triangle` arms. + +### rs_08 — Result chaining *(same-line ghost)* +- **File**: `rs_08_result_chain.rs` +- **Cursor**: end of the line `let n = s.trim()` (no semicolon yet). +- **Expected editor**: ghost text continuing the chain into a parsed `i32`. + +### rs_09 — Lifetime annotation *(off-cursor replace)* +- **File**: `rs_09_lifetimes.rs` +- **Cursor**: end of the file (after `main`'s closing `}`). +- **Expected editor**: strikethrough on the `fn longest(...)` signature + ghost showing the lifetime-annotated version. + +--- + +## JavaScript + +### js_07 — Async/await *(same-line insert, multi-line)* +- **File**: `js_07_async_await.js` +- **Cursor**: empty indented line inside the `try {` block (column 8). +- **Expected editor**: ghost text completing fetch + json parse. + +### js_08 — Express route handler *(same-line insert, multi-line)* +- **File**: `js_08_express_route.js` +- **Cursor**: empty indented line inside the GET handler (column 4). +- **Expected editor**: ghost text implementing get-by-id (lookup, 404, json response). + +--- + +## SQL + +### sql_07 — JOIN clause *(same-line ghost)* +- **File**: `sql_07_join.sql` +- **Cursor**: end of the line `FROM orders o`. +- **Expected editor**: ghost text completing the JOIN against `customers`. + +### sql_08 — WHERE filter *(same-line ghost)* +- **File**: `sql_08_where_filter.sql` +- **Cursor**: end of the bare `WHERE` line. +- **Expected editor**: ghost text proposing a predicate. + +--- + +## Markdown (negative) + +### md_07 — Prose, should stay quiet *(suppressed)* +- **File**: `md_07_prose_negative.md` +- **Cursor**: end of the last sentence. +- **Expected editor**: NOTHING (ideally). If Mercury does propose a continuation of the prose, note it as a soft fail — code models writing your README isn't the v0 product. + +--- + +## Troubleshooting + +- **No log lines appearing**: confirm the output channel is "Kilo Code · Next Edit". Also confirm you reloaded the Dev Host after rebuilding. +- **`[NES] skip — no API key resolved`**: setting wasn't saved. Re-paste the key, hit Enter, reload. +- **`[NES] <- 400`**: regression on prompt shape — capture the body in the channel and ping the integration owner. +- **Visible suggestion that's not in this doc**: write it down. Unexpected wins (or false positives) are the most useful signal. diff --git a/packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go b/packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go new file mode 100644 index 00000000000..96d50b1e931 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/go_07_error_handling.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" +) + +func loadConfig(path string) ([]byte, error) { + data, err := os.ReadFile(path) + + return data, nil +} + +func main() { + cfg, err := loadConfig("config.json") + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Println(string(cfg)) +} diff --git a/packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go b/packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go new file mode 100644 index 00000000000..dbfb598a331 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/go_08_struct_method.go @@ -0,0 +1,22 @@ +package main + +import "fmt" + +type Rectangle struct { + Width float64 + Height float64 +} + +func (r Rectangle) Perimeter() float64 { + return 2 * (r.Width + r.Height) +} + +func (r Rectangle) Area() float64 { + +} + +func main() { + r := Rectangle{Width: 3, Height: 4} + fmt.Println("perimeter:", r.Perimeter()) + fmt.Println("area:", r.Area()) +} diff --git a/packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go b/packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go new file mode 100644 index 00000000000..4fe79b9fe87 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/go_09_goroutine_channel.go @@ -0,0 +1,15 @@ +package main + +import "fmt" + +func main() { + ch := make(chan int) + + go func() { + + }() + + for v := range ch { + fmt.Println("got:", v) + } +} diff --git a/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js b/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js new file mode 100644 index 00000000000..f61b734ccb8 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/js_07_async_await.js @@ -0,0 +1,14 @@ +async function fetchUser(id) { + try { + } catch (err) { + console.error("fetchUser failed", err) + return null + } +} + +async function main() { + const user = await fetchUser(42) + console.log("user:", user) +} + +main() diff --git a/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js b/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js new file mode 100644 index 00000000000..83d4c9ce261 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/js_08_express_route.js @@ -0,0 +1,20 @@ +const app = { + get: (_path, _handler) => app, + post: (_path, _handler) => app, + listen: (_port, cb) => cb && cb(), +} + +const users = [ + { id: 1, name: "ada" }, + { id: 2, name: "lin" }, +] + +app.get("/users/:id", (req, res) => {}) + +app.post("/users", (req, res) => { + const user = { id: users.length + 1, name: req.body.name } + users.push(user) + res.status(201).json(user) +}) + +app.listen(3000, () => console.log("listening on :3000")) diff --git a/packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md b/packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md new file mode 100644 index 00000000000..3751a203cf2 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/md_07_prose_negative.md @@ -0,0 +1,10 @@ +# Mercury Edit 2 — Quick Notes + +Mercury Edit 2 is a small, fast model trained to predict the user's +next single edit given the current file, cursor position, and recent +edit history. It targets latency under 200 ms on typical files and +returns a unified-diff-like patch scoped to a window around the cursor. + +Unlike chat-style completions, the model is biased toward minimal, +local changes — finishing a function body, fixing a typo, propagating +a rename — rather than generating new files from scratch. diff --git a/packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs b/packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs new file mode 100644 index 00000000000..e666f246a03 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/rs_07_match_arms.rs @@ -0,0 +1,25 @@ +enum Shape { + Circle(f64), + Square(f64), + Rectangle(f64, f64), + Triangle(f64, f64), +} + +fn area(s: &Shape) -> f64 { + match s { + Shape::Circle(r) => std::f64::consts::PI * r * r, + Shape::Square(side) => side * side, + + } +} + +fn main() { + let shapes = vec![ + Shape::Circle(1.0), + Shape::Rectangle(2.0, 3.0), + Shape::Triangle(4.0, 5.0), + ]; + for s in &shapes { + println!("area = {}", area(s)); + } +} diff --git a/packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs b/packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs new file mode 100644 index 00000000000..2c51c0c6172 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/rs_08_result_chain.rs @@ -0,0 +1,14 @@ +fn parse_int(s: &str) -> Option { + let n = s.trim() + Some(n * 2) +} + +fn main() { + let inputs = [" 21 ", "not-a-number", "10"]; + for s in &inputs { + match parse_int(s) { + Some(v) => println!("{} -> {}", s, v), + None => println!("{} -> skipped", s), + } + } +} diff --git a/packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs b/packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs new file mode 100644 index 00000000000..86868749707 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/rs_09_lifetimes.rs @@ -0,0 +1,14 @@ +fn longest(a: &str, b: &str) -> &str { + if a.len() >= b.len() { + a + } else { + b + } +} + +fn main() { + let s1 = String::from("hello world"); + let s2 = String::from("hi"); + let out = longest(&s1, &s2); + println!("longest = {}", out); +} diff --git a/packages/kilo-vscode/docs/nes-examples/sql_07_join.sql b/packages/kilo-vscode/docs/nes-examples/sql_07_join.sql new file mode 100644 index 00000000000..b692494f243 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/sql_07_join.sql @@ -0,0 +1,9 @@ +SELECT + c.name, + SUM(o.total) AS total_spent +FROM orders o + +WHERE o.created_at >= '2026-01-01' +GROUP BY c.name +ORDER BY total_spent DESC +LIMIT 10; diff --git a/packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql b/packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql new file mode 100644 index 00000000000..ed2fd4a6a7f --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/sql_08_where_filter.sql @@ -0,0 +1,4 @@ +SELECT id, email +FROM users +WHERE +ORDER BY last_login_at DESC; diff --git a/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts b/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts new file mode 100644 index 00000000000..f939ce4cd25 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/ts_07_array_transform.ts @@ -0,0 +1,17 @@ +interface User { + id: number + name: string + active: boolean +} + +function getActiveUserNames(users: User[]): string[] { + return users +} + +const sample: User[] = [ + { id: 1, name: "ada", active: true }, + { id: 2, name: "lin", active: false }, + { id: 3, name: "rin", active: true }, +] + +console.log(getActiveUserNames(sample)) diff --git a/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts b/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts new file mode 100644 index 00000000000..5330b68f14a --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/ts_08_param_types.ts @@ -0,0 +1,19 @@ +function double(x: number): number { + return x * 2 +} + +function add(a, b) { + return a + b +} + +function negate(x: number): number { + return -x +} + +function main(): void { + console.log(double(3)) + console.log(add(2, 4)) + console.log(negate(7)) +} + +main() diff --git a/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx b/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx new file mode 100644 index 00000000000..5c5ed11bc20 --- /dev/null +++ b/packages/kilo-vscode/docs/nes-examples/ts_09_jsx_handler.tsx @@ -0,0 +1,18 @@ +declare const React: { + useState: (initial: T) => [T, (next: T) => void] +} + +function Counter(): JSX.Element { + const [count, setCount] = React.useState(0) + + function handleClick() {} + + return ( +
        +

        Count: {count}

        + +
        + ) +} + +export default Counter diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index 704953109de..0badbfebc49 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -56,45 +56,46 @@ const esbuildProblemMatcherPlugin = { } /** - * Stub the pierre worker module so the Diff/Code components work without - * web workers in the VS Code webview. The `@pierre/diffs` library handles - * undefined worker pools gracefully (renders without syntax highlighting). + * Route the shared `@opencode-ai/ui/pierre/worker` module (and its relative + * variants) to the Kilo implementation in `webview-ui/pierre-worker.ts`. * - * We stub the entire worker module rather than just the URL import because - * `new Worker('')` would throw at runtime. + * The upstream module loads Pierre's Shiki worker via a Vite-only + * `?worker&url` import that esbuild can't resolve. The Kilo replacement loads + * the worker from the bundled `dist/shiki-worker.js` asset instead, so syntax + * highlighting runs off the main thread. `@pierre/diffs/worker` (used by that + * replacement) is left alone. * * @type {import('esbuild').Plugin} */ -const pierreWorkerStubPlugin = { - name: "pierre-worker-stub", +const pierreWorkerAliasPlugin = { + name: "pierre-worker-alias", setup(build) { - // Stub the Vite-specific ?worker&url import - build.onResolve({ filter: /\?worker&url$/ }, (args) => ({ - path: args.path, - namespace: "worker-url-stub", - })) - build.onLoad({ filter: /.*/, namespace: "worker-url-stub" }, () => ({ - contents: "export default ''", - loader: "js", - })) - - // Stub the pierre worker module so getWorkerPool always returns undefined build.onResolve({ filter: /pierre\/worker$/ }, (args) => { - // Only stub the local UI worker module, not @pierre/diffs/worker if (args.path.includes("@pierre")) return - return { - path: args.path, - namespace: "pierre-worker-stub", - } + return { path: path.join(__dirname, "webview-ui", "pierre-worker.ts") } + }) + }, +} + +/** + * Resolve the synthetic `kilo-shiki-worker` entry point to Pierre's Shiki worker + * so esbuild can bundle it (and its inlined oniguruma WebAssembly) into a single + * `dist/shiki-worker.js` asset loaded by `webview-ui/pierre-worker.ts`. Switch to + * `worker-portable.js` to drop WebAssembly and use the JS regex engine instead. + * + * @type {import('esbuild').Plugin} + */ +const shikiWorkerEntryPlugin = { + name: "shiki-worker-entry", + setup(build) { + build.onResolve({ filter: /^kilo-shiki-worker$/ }, async () => { + const resolved = await build.resolve("@pierre/diffs/worker/worker.js", { + kind: "import-statement", + resolveDir: __dirname, + }) + if (resolved.errors.length > 0) return { errors: resolved.errors } + return { path: resolved.path } }) - build.onLoad({ filter: /.*/, namespace: "pierre-worker-stub" }, () => ({ - contents: ` - export function getWorkerPool() { return undefined } - export function getWorkerPools() { return { unified: undefined, split: undefined } } - export function workerFactory() { return undefined } - `, - loader: "js", - })) }, } @@ -157,7 +158,7 @@ function createBrowserWebviewContext(entryPoint, outfile) { }, plugins: [ solidDedupePlugin, - pierreWorkerStubPlugin, + pierreWorkerAliasPlugin, svgSpritePlugin, cssPackageResolvePlugin, solidPlugin(), @@ -166,6 +167,23 @@ function createBrowserWebviewContext(entryPoint, outfile) { }) } +// Bundle Pierre's Shiki worker into a single self-contained asset that the +// webviews load off the main thread for syntax highlighting. +function createShikiWorkerContext() { + return esbuild.context({ + entryPoints: ["kilo-shiki-worker"], + bundle: true, + format: "iife", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + outfile: "dist/shiki-worker.js", + logLevel: "silent", + plugins: [shikiWorkerEntryPlugin, esbuildProblemMatcherPlugin], + }) +} + async function main() { // Build extension const extensionCtx = await esbuild.context({ @@ -191,6 +209,9 @@ async function main() { // Build KiloClaw webview (SolidJS, standalone chat panel) const kiloClawCtx = await createBrowserWebviewContext("webview-ui/kiloclaw/index.tsx", "dist/kiloclaw.js") + // Build Marketplace webview (SolidJS, standalone catalog panel) + const marketplaceCtx = await createBrowserWebviewContext("webview-ui/marketplace/index.tsx", "dist/marketplace.js") + // Build Diff Viewer webview (SolidJS, reuses Agent Manager diff components) const diffViewerCtx = await createBrowserWebviewContext("webview-ui/diff-viewer/index.tsx", "dist/diff-viewer.js") @@ -200,6 +221,9 @@ async function main() { // Build webview const webviewCtx = await createBrowserWebviewContext("webview-ui/src/index.tsx", "dist/webview.js") + // Build the shared Shiki highlighting worker asset + const shikiWorkerCtx = await createShikiWorkerContext() + if (watch) { await Promise.all([ extensionCtx.watch(), @@ -208,6 +232,8 @@ async function main() { diffViewerCtx.watch(), diffVirtualCtx.watch(), kiloClawCtx.watch(), + marketplaceCtx.watch(), + shikiWorkerCtx.watch(), ]) } else { await Promise.all([ @@ -215,16 +241,20 @@ async function main() { webviewCtx.rebuild(), agentManagerCtx.rebuild(), kiloClawCtx.rebuild(), + marketplaceCtx.rebuild(), diffViewerCtx.rebuild(), diffVirtualCtx.rebuild(), + shikiWorkerCtx.rebuild(), ]) await Promise.all([ extensionCtx.dispose(), webviewCtx.dispose(), agentManagerCtx.dispose(), - kiloClawCtx.dispose(), diffViewerCtx.dispose(), diffVirtualCtx.dispose(), + kiloClawCtx.dispose(), + marketplaceCtx.dispose(), + shikiWorkerCtx.dispose(), ]) } } diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index 0914b0366f7..b36ae0fd196 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -34,11 +34,12 @@ export default [ }, // ── Complexity exceptions ───────────────────────────────────────── - // Existing violations capped at their current max. - // New code must stay ≤ 20. Do not raise these caps; refactor instead. + // Existing complexity violations are capped at their current max. + // New code must stay ≤ 20. Do not raise complexity caps; refactor instead. { files: ["src/KiloProvider.ts"], - rules: { complexity: ["error", 150], "max-lines": ["error", 3600] }, + // This is the extension integration surface; do not gate feature work on line-count churn. + rules: { complexity: ["error", 150], "max-lines": "off" }, }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], diff --git a/packages/kilo-vscode/knip.json b/packages/kilo-vscode/knip.json index 79b8eb7da65..78b5a311058 100644 --- a/packages/kilo-vscode/knip.json +++ b/packages/kilo-vscode/knip.json @@ -6,6 +6,8 @@ "webview-ui/diff-viewer/index.tsx", "webview-ui/diff-virtual/index.tsx", "webview-ui/kiloclaw/index.tsx", + "webview-ui/marketplace/index.tsx", + "webview-ui/pierre-worker.ts", "webview-ui/src/index.tsx", "src/**/__tests__/**/*.{ts,spec.ts}", "src/**/*.test.ts", diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 577935f8c75..0b3c113edb1 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.3.8", + "version": "7.3.54", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -37,11 +37,11 @@ "agent", "agentic", "coding", - "coding-agent", - "coding-assistant", + "coding agent", + "coding assistant", "autocomplete", - "code-completion", - "pair-programming", + "code completion", + "pair programming", "chat", "terminal", "chatgpt", @@ -49,10 +49,13 @@ "sonnet", "anthropic", "openai", - "zoo-code" + "zoo code", + "opencode", + "open code" ], "activationEvents": [ - "onStartupFinished" + "onStartupFinished", + "onUri" ], "main": "./dist/extension.js", "contributes": { @@ -99,36 +102,43 @@ { "command": "kilo-code.new.plusButtonClicked", "title": "New Task", + "category": "Kilo Code", "icon": "$(add)" }, { "command": "kilo-code.new.agentManagerOpen", "title": "Agent Manager", + "category": "Kilo Code", "icon": "$(organization)" }, { "command": "kilo-code.new.kiloClawOpen", "title": "KiloClaw", + "category": "Kilo Code", "icon": "$(comment-discussion)" }, { "command": "kilo-code.new.marketplaceButtonClicked", "title": "Marketplace", + "category": "Kilo Code", "icon": "$(extensions)" }, { "command": "kilo-code.new.historyButtonClicked", "title": "History", + "category": "Kilo Code", "icon": "$(history)" }, { "command": "kilo-code.new.profileButtonClicked", "title": "Profile", + "category": "Kilo Code", "icon": "$(account)" }, { "command": "kilo-code.new.settingsButtonClicked", "title": "Settings", + "category": "Kilo Code", "icon": "$(settings-gear)" }, { @@ -168,7 +178,8 @@ }, { "command": "kilo-code.new.openInTab", - "title": "Kilo Code", + "title": "Open in Tab", + "category": "Kilo Code", "icon": { "light": "assets/icons/kilo-light.svg", "dark": "assets/icons/kilo-dark.svg" @@ -194,6 +205,16 @@ "title": "Cancel Suggested Edits", "category": "Kilo Code" }, + { + "command": "kilo-code.new.autocomplete.nextEdit.acceptOrJump", + "title": "Next Edit: Accept or Jump to Suggested Edit", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.autocomplete.nextEdit.dismiss", + "title": "Next Edit: Dismiss Pending Suggestion", + "category": "Kilo Code" + }, { "command": "kilo-code.new.agentManager.previousSession", "title": "Agent Manager: Previous Session", @@ -214,6 +235,11 @@ "title": "Agent Manager: Next Tab", "category": "Kilo Code" }, + { + "command": "kilo-code.new.agentManager.search", + "title": "Agent Manager: Search Worktrees and Sessions", + "category": "Kilo Code" + }, { "command": "kilo-code.new.agentManager.showTerminal", "title": "Agent Manager: Focus Terminal", @@ -263,6 +289,11 @@ "title": "Agent Manager: Open Worktree", "category": "Kilo Code" }, + { + "command": "kilo-code.new.agentManager.openPR", + "title": "Agent Manager: Open Pull Request", + "category": "Kilo Code" + }, { "command": "kilo-code.new.agentManager.closeWorktree", "title": "Agent Manager: Close Worktree", @@ -608,6 +639,12 @@ "mac": "cmd+alt+right", "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" }, + { + "command": "kilo-code.new.agentManager.search", + "key": "ctrl+f", + "mac": "cmd+f", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !terminalFocus" + }, { "command": "kilo-code.new.agentManager.showTerminal", "key": "ctrl+/", @@ -662,6 +699,12 @@ "mac": "cmd+shift+o", "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" }, + { + "command": "kilo-code.new.agentManager.openPR", + "key": "ctrl+shift+r", + "mac": "cmd+shift+r", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, { "command": "kilo-code.new.agentManager.closeWorktree", "key": "ctrl+shift+w", @@ -756,6 +799,16 @@ "key": "ctrl+l", "mac": "cmd+l", "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilocode.autocomplete.enableSmartInlineTaskKeybinding && github.copilot.completions.enabled" + }, + { + "command": "kilo-code.new.autocomplete.nextEdit.acceptOrJump", + "key": "tab", + "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && !suggestWidgetVisible && kilo-code.nextEdit.hasPendingSuggestion" + }, + { + "command": "kilo-code.new.autocomplete.nextEdit.dismiss", + "key": "escape", + "when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilo-code.nextEdit.hasPendingSuggestion" } ], "configuration": { @@ -785,7 +838,8 @@ "bs", "tr", "nl", - "uk" + "uk", + "it" ], "enumDescriptions": [ "Auto (VS Code language)", @@ -807,7 +861,8 @@ "Bosanski", "Türkçe", "Nederlands", - "Українська" + "Українська", + "Italiano" ] }, "kilo-code.new.model.providerID": { @@ -824,13 +879,35 @@ "type": "string", "enum": [ "mistralai/codestral-2508", - "inception/mercury-edit-2" + "inception/mercury-edit-2", + "inception/mercury-next-edit", + "codestral-2508", + "mercury-edit-2", + "mercury-next-edit" + ], + "enumDescriptions": [ + "Codestral via Kilo Gateway", + "Mercury Edit 2 (FIM) via Kilo Gateway", + "Mercury Edit 2 (Next Edit), multi-line edit predictions with jump-to-edit UX, via Kilo Gateway", + "Codestral via your connected Mistral provider API key", + "Mercury Edit 2 (FIM) via your connected Inception provider API key", + "Mercury Edit 2 (Next Edit), multi-line edit predictions with jump-to-edit UX, via your connected Inception provider API key" + ], + "description": "Model to use for inline autocomplete suggestions. If unset, the recommended default is used." + }, + "kilo-code.new.autocomplete.provider": { + "type": "string", + "enum": [ + "kilo", + "mistral", + "inception" ], "enumDescriptions": [ - "Codestral by Mistral AI (default)", - "Mercury Edit 2 by Inception" + "Use autocomplete models through Kilo Gateway", + "Use autocomplete models through your connected Mistral provider API key", + "Use autocomplete models through your connected Inception provider API key" ], - "description": "Model to use for inline autocomplete suggestions" + "description": "Provider to use for inline autocomplete suggestions. If unset, Kilo Gateway is used." }, "kilo-code.new.autocomplete.enableAutoTrigger": { "type": "boolean", @@ -847,14 +924,11 @@ "default": false, "description": "Enable chat textarea autocomplete" }, - "kilo-code.new.speechToText.enabled": { + "kilo-code.new.indexing.showButtonWhenDisabled": { "type": "boolean", - "default": false, - "description": "Enable experimental speech-to-text voice input in prompt fields. Uses your Kilo account through the Kilo Gateway." - }, - "kilo-code.new.speechToText.model": { - "type": "string", - "description": "Model to use for experimental speech-to-text voice input. Requires Kilo Gateway." + "default": true, + "scope": "application", + "description": "Show the codebase indexing button below the prompt while indexing is disabled." }, "kilo-code.new.claudeCodeCompat": { "type": "boolean", @@ -893,53 +967,88 @@ "default": false, "description": "Run browser automation in headless mode (no visible window). When disabled, you can watch the agent interact with the browser." }, - "kilo-code.new.notifications.agent": { + "kilo-code.new.attention.enabled": { "type": "boolean", - "default": true, - "description": "Show notification when agent completes a task" - }, - "kilo-code.new.notifications.permissions": { - "type": "boolean", - "default": true, - "description": "Show notification on permission requests" - }, - "kilo-code.new.notifications.errors": { - "type": "boolean", - "default": true, - "description": "Show notification on errors" - }, - "kilo-code.new.sounds.agent": { - "type": "string", - "default": "default", - "enum": [ - "default", - "none" - ], - "description": "Sound to play when agent completes" - }, - "kilo-code.new.sounds.permissions": { - "type": "string", - "default": "default", - "enum": [ - "default", - "none" - ], - "description": "Sound to play on permission requests" + "default": false, + "description": "Play sounds when sessions complete, error, or need input" }, - "kilo-code.new.sounds.errors": { + "kilo-code.new.attention.sound": { "type": "string", "default": "default", "enum": [ "default", - "none" + "system", + "alert-01", + "alert-02", + "alert-03", + "alert-04", + "alert-05", + "alert-06", + "alert-07", + "alert-08", + "alert-09", + "alert-10", + "bip-bop-01", + "bip-bop-02", + "bip-bop-03", + "bip-bop-04", + "bip-bop-05", + "bip-bop-06", + "bip-bop-07", + "bip-bop-08", + "bip-bop-09", + "bip-bop-10", + "staplebops-01", + "staplebops-02", + "staplebops-03", + "staplebops-04", + "staplebops-05", + "staplebops-06", + "staplebops-07", + "nope-01", + "nope-02", + "nope-03", + "nope-04", + "nope-05", + "nope-06", + "nope-07", + "nope-08", + "nope-09", + "nope-10", + "nope-11", + "nope-12", + "yup-01", + "yup-02", + "yup-03", + "yup-04", + "yup-05", + "yup-06" ], - "description": "Sound to play on errors" + "description": "Use different sounds for completion, input, and errors, or choose one sound for every event" }, "kilo-code.new.showTaskTimeline": { "type": "boolean", "default": true, "description": "Show the task timeline graph in the chat header" }, + "kilo-code.new.agentWorkStyle": { + "type": "string", + "scope": "application", + "default": "unset", + "enum": [ + "unset", + "human-in-the-loop", + "autonomous", + "skipped" + ], + "enumDescriptions": [ + "Show work style onboarding so you can choose again", + "Human in the Loop: review agent actions as they happen", + "High autonomy: let Kilo take most actions with fewer interruptions", + "Onboarding was skipped without changing work style settings" + ], + "description": "Preferred agent work style. Set to Unset to show the onboarding flow again." + }, "kilo-code.new.diff.renderMarkdown": { "type": "boolean", "default": false, @@ -973,6 +1082,7 @@ "rebuild-sdk": "bun run --cwd ../sdk/js build", "storybook": "storybook dev -p 6007", "build-storybook": "storybook build -o storybook-static", + "test:a11y": "playwright test tests/accessibility.spec.ts", "test:visual": "playwright test", "test:visual:update": "playwright test --update-snapshots", "snapshot:build": "bun script/dev-snapshot.ts build", @@ -980,7 +1090,9 @@ "extension": "bun script/launch.ts" }, "devDependencies": { + "@axe-core/playwright": "4.11.3", "@playwright/test": "1.57.0", + "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@types/diff": "^6.0.0", "@types/mocha": "^10.0.10", @@ -994,6 +1106,7 @@ "esbuild-plugin-solid": "^0.6.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "happy-dom": "20.8.9", "knip": "5.85.0", "prettier": "3.6.2", "qrcode": "^1.5.4", @@ -1012,8 +1125,10 @@ "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-ui": "workspace:*", + "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@pierre/diffs": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "@xterm/addon-clipboard": "0.2.0", "@xterm/addon-fit": "0.11.0", @@ -1021,7 +1136,6 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/xterm": "6.0.0", "diff": "8.0.4", - "dotenv": "^16.4.7", "fastest-levenshtein": "^1.0.16", "friendly-words": "1.3.1", "ignore": "^7.0.3", diff --git a/packages/kilo-vscode/script/launch.ts b/packages/kilo-vscode/script/launch.ts index caa0a8b4936..b2bc8962b0a 100644 --- a/packages/kilo-vscode/script/launch.ts +++ b/packages/kilo-vscode/script/launch.ts @@ -14,6 +14,7 @@ * --wait Block until the VS Code window is closed * --clean Wipe the user-data and extensions dirs before launching * --preserve-settings Merge defaults into existing VS Code user settings + * --accessible Enable VS Code accessibility support for assistive-technology testing * * Environment: * VSCODE_EXEC_PATH Path to VS Code executable (same as --app-path) @@ -86,6 +87,7 @@ const explicit = opts["app-path"] as string | undefined const blocking = opts["wait"] === true const clean = opts["clean"] === true const preserve = opts["preserve-settings"] === true +const accessible = opts["accessible"] === true // --------------------------------------------------------------------------- // VS Code executable detection @@ -251,11 +253,12 @@ async function installVsix(path: string, app: string) { // Settings for isolated instance // --------------------------------------------------------------------------- -function settings(keep: boolean) { +function settings(keep: boolean, enabled: boolean) { const dir = join(userDir, "User") const file = join(dir, "settings.json") const defaults = { - "editor.accessibilitySupport": "off", + "chat.disableAIFeatures": true, + "editor.accessibilitySupport": enabled ? "on" : "off", "extensions.autoCheckUpdates": false, "extensions.autoUpdate": false, "extensions.ignoreRecommendations": true, @@ -269,7 +272,10 @@ function settings(keep: boolean) { } mkdirSync(dir, { recursive: true }) - const cfg = keep && existsSync(file) ? { ...defaults, ...load(file) } : defaults + const cfg = + keep && existsSync(file) + ? { ...defaults, ...load(file), ...(enabled ? { "editor.accessibilitySupport": "on" } : {}) } + : defaults writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n") } @@ -306,7 +312,7 @@ async function launch() { const app = detect() - settings(preserve) + settings(preserve, accessible) const args = [workspace, `--extensions-dir=${extDir}`, `--user-data-dir=${userDir}`, "--skip-release-notes"] @@ -338,6 +344,7 @@ async function launch() { console.log(`[launch] Executable: ${app}`) console.log(`[launch] Workspace: ${workspace}`) console.log(`[launch] State: ${base}`) + console.log(`[launch] Accessibility support: ${accessible ? "on" : "off"}`) if (blocking) { const result = Bun.spawnSync([app, ...args], { diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 951d0f8ce46..9547b67aaef 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -23,6 +23,7 @@ const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const opencodeDir = join(packagesDir, "opencode") const coreDir = join(packagesDir, "core") +const gatewayDir = join(packagesDir, "kilo-gateway") const indexingDir = join(packagesDir, "kilo-indexing") const targetBinDir = join(kiloVscodeDir, "bin") @@ -38,8 +39,12 @@ async function cliSourceHash(): Promise { try { const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() + const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${indexingResult.text().trim()}` || null + return ( + `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}` || + null + ) } catch { return null } @@ -49,10 +54,12 @@ async function isDirty(): Promise { try { const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() + const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() return ( opencodeResult.text().trim().length > 0 || coreResult.text().trim().length > 0 || + gatewayResult.text().trim().length > 0 || indexingResult.text().trim().length > 0 ) } catch { @@ -157,14 +164,42 @@ async function ensureBuiltBinary(): Promise { return built } +async function writeSourceWrapper() { + if (process.platform === "win32") { + throw new Error("Compiled CLI build failed and source wrapper fallback is not supported on Windows.") + } + + const bun = Bun.which("bun") ?? "bun" + await $`mkdir -p ${targetBinDir}` + await Bun.write( + targetBinPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `cd ${JSON.stringify(opencodeDir)}`, + `exec ${JSON.stringify(bun)} --conditions=browser src/index.ts "$@"`, + "", + ].join("\n"), + ) + chmodSync(targetBinPath, 0o755) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) + + const hash = await cliSourceHash() + if (hash) await Bun.write(versionFile, hash + "\n") + log( + `Compiled CLI build failed; wrote source wrapper at ${relative(kiloVscodeDir, targetBinPath)} for local development.`, + ) +} + async function main() { const targetFile = Bun.file(targetBinPath) const exists = await targetFile.exists() + const ready = exists - const stale = exists && !forceRebuild && (await isStale()) + const stale = ready && !forceRebuild && (await isStale()) const rebuild = forceRebuild || stale - if (exists && !rebuild) { + if (ready && !rebuild) { const st = statSync(targetBinPath) log( `CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`, @@ -173,14 +208,15 @@ async function main() { return } + if (forceRebuild && !exists) { + removeDist() + } + if (exists && rebuild) { - log(stale ? `CLI source has changed — rebuilding.` : `Removing existing binary (--force).`) + log(stale ? `CLI source has changed — rebuilding.` : `Refreshing existing CLI resources.`) rmSync(targetBinPath) - // Also remove the prebuilt dist so ensureBuiltBinary() triggers a fresh build - const distDir = join(opencodeDir, "dist") - if (existsSync(distDir)) { - rmSync(distDir, { recursive: true }) - log(`Removed ${relative(kiloVscodeDir, distDir)} to force rebuild.`) + if (forceRebuild || stale) { + removeDist() } } @@ -189,7 +225,12 @@ async function main() { throw new Error(`Expected opencode package at ${opencodeDir}, but it does not exist.`) } - const sourceBinPath = await ensureBuiltBinary() + const sourceBinPath = await ensureBuiltBinary().catch(async (err) => { + await writeSourceWrapper() + log(`Wrapper fallback reason: ${err instanceof Error ? err.message : String(err)}`) + return null + }) + if (!sourceBinPath) return await $`mkdir -p ${targetBinDir}` await $`cp ${sourceBinPath} ${targetBinPath}` await copyTreeSitterResources(sourceBinPath, targetBinPath) @@ -203,6 +244,14 @@ async function main() { log(`Copied CLI binary from ${relative(packagesDir, sourceBinPath)} -> ${relative(kiloVscodeDir, targetBinPath)}`) } +function removeDist() { + // Also remove the prebuilt dist so ensureBuiltBinary() triggers a fresh build + const distDir = join(opencodeDir, "dist") + if (!existsSync(distDir)) return + rmSync(distDir, { recursive: true }) + log(`Removed ${relative(kiloVscodeDir, distDir)} to force rebuild.`) +} + try { await main() } catch (err) { diff --git a/packages/kilo-vscode/script/watch-cli.ts b/packages/kilo-vscode/script/watch-cli.ts index 2a50cf8e7a2..cf90082f38d 100644 --- a/packages/kilo-vscode/script/watch-cli.ts +++ b/packages/kilo-vscode/script/watch-cli.ts @@ -82,9 +82,8 @@ let timer: ReturnType | null = null watch(opencodeSrcDir, { recursive: true }, (_event, filename) => { if (!filename) return - // Skip non-source files and build-generated files + // Skip test files if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return - if (filename.includes("models-snapshot")) return if (timer) clearTimeout(timer) timer = setTimeout(() => { diff --git a/packages/kilo-vscode/src/DiffVirtualProvider.ts b/packages/kilo-vscode/src/DiffVirtualProvider.ts index 90a170559dc..721cb71c3fd 100644 --- a/packages/kilo-vscode/src/DiffVirtualProvider.ts +++ b/packages/kilo-vscode/src/DiffVirtualProvider.ts @@ -113,6 +113,7 @@ export class DiffVirtualProvider implements vscode.Disposable { scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-virtual.js")), styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-virtual.css")), iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), title: "Diff Virtual", extraStyles: "#root { display: flex; flex-direction: column; height: 100%; }", }) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 6412e349229..18ef1427c09 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,22 +1,23 @@ import * as path from "path" import * as vscode from "vscode" -import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview" -import { isAbsolutePath } from "./path-utils" import type { KiloClient, Session, SessionStatus, Event, + GlobalEvent, TextPartInput, FilePartInput, Config, } from "@kilocode/sdk/v2/client" import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend" +import { previewSound } from "./services/attention" import type { EditorContext, IndexingStatus } from "./services/cli-backend/types" import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" import { buildWebviewHtml, getWebviewFontSize } from "./utils" import { saveImage } from "./kilo-provider/save-image" +import { handleEditorAction } from "./kilo-provider/editor-actions" import { exportTranscript } from "./kilo-provider/export-transcript" import { TelemetryProxy, @@ -26,9 +27,10 @@ import { } from "./services/telemetry" import { sessionToWebview, + applySessionPatch, + sessionPatchToWebview, indexProvidersById, filterVisibleAgents, - buildSettingPath, mapSSEEventToWebviewMessage, getErrorMessage, getConfigErrorDetails, @@ -42,21 +44,23 @@ import { resolveWorkspaceDirectory, sameDirectory, SessionStreamScheduler, + buildSettingPath, type SessionRefreshContext, } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" import { getWorkspaceRoot } from "./review-utils" -import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace" +import { createMarketplaceRemover, removeAgent, removeMcp } from "./kilo-provider/remove-config-item" import type { RemoteStatusService } from "./services/RemoteStatusService" import { resolveProjectDirectory } from "./project-directory" -import { getBusySessionCount, seedSessionStatuses } from "./session-status" +import { seedSessionStatuses } from "./session-status" import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error" import { retry } from "./services/cli-backend/retry" -import { slimPart, slimParts } from "./kilo-provider/slim-metadata" +import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" +import { renameSession } from "./kilo-provider/rename-session" import { handleFileSearch } from "./kilo-provider/file-search" import { watchFontSizeConfig } from "./kilo-provider/font-size" import { getTerminalContents } from "./services/terminal/context" @@ -66,8 +70,9 @@ import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/fo import { clearCommandsCache, loadCommands } from "./kilo-provider/commands" import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page" import { childID } from "./kilo-provider/task-session" +import { VisibleTaskStreams } from "./kilo-provider/visible-task-streams" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" -import { abortSession } from "./kilo-provider/abort" +import { SessionAbort } from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, validAutocompleteSetting, @@ -77,14 +82,20 @@ import { routeEarlyMessage } from "./kilo-provider/early-message" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" +import { + getWorkStylePayload, + handleWorkStyleMessage, + isWorkStyleSetting, + watchWorkStyleConfig, +} from "./kilo-provider/work-style" import * as McpOAuth from "./kilo-provider/mcp-oauth" import { retryable, backoff, MAX_RETRIES } from "./util/retry" import { hasGit } from "./kilo-provider/git-status" // legacy-migration start import { checkAndShowMigrationWizard, - handleRequestLegacyMigrationData, - handleStartLegacyMigration, + handleRequestMigrationData, + handleStartMigration, handleFinalizeLegacyMigration, handleSkipLegacyMigration, handleClearLegacyData, @@ -116,6 +127,7 @@ import { } from "./kilo-provider/handlers/question" 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 { buildActionContext, @@ -128,7 +140,9 @@ import { completeProviderOAuth as completeOAuthAction, disconnectProvider as disconnectProviderAction, saveCustomProvider as saveCustomProviderAction, + resolveStoredKey, } from "./provider-actions" +import type { StoredProviderKey } from "./provider-actions" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" @@ -136,6 +150,11 @@ import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" import { stopSessionProcesses } from "./kilo-provider/background-process" +import { + buildIndexingSettingsMessage, + validIndexingSetting, + watchIndexingConfig, +} from "./kilo-provider/indexing-settings" type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" type ContextMessage = { contextDirectory?: unknown } @@ -157,6 +176,114 @@ const mapAgent = (a: Agent) => ({ const SESSION_SCOPED_PART_EVENTS = new Set(["message.part.updated", "message.part.delta", "message.part.removed"]) const isSessionScopedPartEvent = (type: string) => SESSION_SCOPED_PART_EVENTS.has(type) +type SyncPayload = Extract +type RawSyncPayload = { + type: "sync" + syncEvent: { + type: SyncPayload["name"] + id: string + seq: number + aggregateID: string + data: unknown + } +} +type LegacySyncEvent = + | { + id: string + type: "message.updated" + properties: Extract["data"] + } + | { + id: string + type: "message.removed" + properties: Extract["data"] + } + | { + id: string + type: "message.part.updated" + properties: Extract["data"] + } + | { + id: string + type: "message.part.removed" + properties: Extract["data"] + } + | { + id: string + type: "session.created" + properties: Extract["data"] + } + | { + source: "sync" + id: string + seq: number + type: "session.updated" + properties: Extract["data"] + } + | { + id: string + type: "session.deleted" + properties: Extract["data"] + } + +type FullSessionUpdatedEvent = { + id: string + type: "session.updated" + properties: { sessionID: string; info: Session } +} + +type ProviderEvent = Event | LegacySyncEvent | FullSessionUpdatedEvent + +function isLegacySyncEvent(event: ProviderEvent): event is LegacySyncEvent { + if (event.type === "session.updated") return "source" in event && event.source === "sync" + return ( + event.type === "message.updated" || + event.type === "message.removed" || + event.type === "message.part.updated" || + event.type === "message.part.removed" || + event.type === "session.created" || + event.type === "session.deleted" + ) +} + +function isFullSessionUpdatedEvent(event: ProviderEvent): event is FullSessionUpdatedEvent { + return event.type === "session.updated" && !isLegacySyncEvent(event) +} + +export function unwrapSyncEvent(event: GlobalEvent["payload"] | RawSyncPayload): ProviderEvent | undefined { + if (event.type !== "sync") return event + const payload = + "syncEvent" in event + ? ({ + type: "sync", + name: event.syncEvent.type, + id: event.syncEvent.id, + seq: event.syncEvent.seq, + aggregateID: event.syncEvent.aggregateID, + data: event.syncEvent.data, + } as SyncPayload) + : event + + switch (payload.name) { + case "message.updated.1": + return { id: payload.id, type: "message.updated", properties: payload.data } + case "message.removed.1": + return { id: payload.id, type: "message.removed", properties: payload.data } + case "message.part.updated.1": + return { id: payload.id, type: "message.part.updated", properties: payload.data } + case "message.part.removed.1": + return { id: payload.id, type: "message.part.removed", properties: payload.data } + case "session.created.1": + return { id: payload.id, type: "session.created", properties: payload.data } + case "session.updated.1": + return { source: "sync", id: payload.id, seq: payload.seq, type: "session.updated", properties: payload.data } + case "session.deleted.1": + return { id: payload.id, type: "session.deleted", properties: payload.data } + default: + return undefined + } +} + export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider { public static readonly viewType = "kilo-code.SidebarProvider" private readonly instanceId = crypto.randomUUID() @@ -171,6 +298,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly extensionVersion = vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown" private cachedProvidersMessage: unknown = null + /** + * Provider API keys retained extension-side for authenticated model + * fetches (#10139). Keys are stripped before provider data reaches the + * webview, so fetch requests for an existing provider carry a providerID + * and the key is resolved here. Refreshed on every provider fetch. + */ + private storedProviderKeys: Record = {} /** Coalesce provider refreshes — at most one follow-up rerun when a request lands mid-flight. */ private providersRefresh: Promise | null = null private providersQueued = false @@ -194,32 +328,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private configWarningsShown = false /** Cached notificationsLoaded payload */ private cachedNotificationsMessage: unknown = null + private pendingKiloModel: { modelID?: string; agent?: string } | null = null private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = [] private readyResolvers: (() => void)[] = [] private promptRecoveryQueued = false private promptRecovery: Promise | null = null private trackedSessionIds: Set = new Set() private syncedChildSessions: Set = new Set() - /** Tracks the latest status for each session, used to warn before destructive config operations. */ - private sessionStatusMap = new Map() - /** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */ - private sessionDirectories = new Map() - private permissionDirectories = new Map() - /** Project ID for the current workspace, used to filter out sessions from other repositories. */ - private projectID: string | undefined - /** Abort controller for the current loadMessages request; aborted when a new session is selected. */ - private loadMessagesAbort: AbortController | null = null - /** Per-session last focus-mode reconcile timestamp — throttles rapid tab switching. */ - private lastReconciledAt = new Map() - /** Set when refreshSessions() is called before the client is ready. - * Cleared and retried once the connection transitions to "connected". */ - private pendingSessionRefresh = false + private readonly checkpoints = new Map>() + private readonly revisions = new Map() + private readonly refreshes = new Map() + private sessionStatusMap = new Map() // Latest status used for destructive config warnings. + private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. + private readonly aborts = new SessionAbort() + private projectID: string | undefined // Current workspace project ID used to filter sessions. + private loadMessagesAbort: AbortController | null = null // Current load request cancellation. + private lastReconciledAt = new Map() // Per-session focus-mode reconcile timestamp. + private pendingSessionRefresh = false // Refresh requested before the client is ready. private readonly streams = new SessionStreamScheduler((msg) => this.postMessage(msg)) + private readonly visibleTaskStreams = new VisibleTaskStreams((id, visible) => this.streams.setVisible(id, visible)) private readonly confirmations = new MessageConfirmation() private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null - /** Cached legacy migration data so migrate() doesn't re-read from disk/SecretStorage. */ // legacy-migration - private cachedLegacyData: import("./legacy-migration/legacy-types").LegacyMigrationData | null = null // legacy-migration + /** Cached migration data so migration doesn't re-read from disk/SecretStorage. */ // legacy-migration + private migrationCache: MigrationContext["migrationCache"] = new Map() /** Guard to prevent checkAndShowMigrationWizard running concurrently. */ // legacy-migration private migrationCheckInFlight = false // legacy-migration private unsubscribeNotificationDismiss: (() => void) | null = null @@ -232,14 +364,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private initConnectionPromise: Promise | null = null private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null + private indexingConfigDisposable: vscode.Disposable | null = null private telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null + private readonly marketplaceRemove = createMarketplaceRemover() private ignoreController: FileIgnoreController | null = null private ignoreControllerDir: string | null = null - private marketplace: MarketplaceService | null = null private chatAutocomplete: ChatTextAreaAutocomplete | null = null private projectDirectory: string | null | undefined private slimEditMetadata = true @@ -287,10 +420,27 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private setCurrentSession(session: Session | null): void { + const ids = new Set([this.currentSession?.id, session?.id]) + for (const id of ids) { + if (id) this.refreshes.set(id, (this.refreshes.get(id) ?? 0) + 1) + } this.currentSession = session this.opts.tabTitle?.(nativeTitle(session)) } + private checkpoint(sid: string, run: () => Promise): void { + const prior = this.checkpoints.get(sid) ?? Promise.resolve() + const pending = prior.catch(() => undefined).then(run) + const cleanup = () => { + if (this.checkpoints.get(sid) === pending) this.checkpoints.delete(sid) + } + this.checkpoints.set(sid, pending) + void pending.then(cleanup, (error) => { + console.error("[Kilo New] checkpoint mutation failed:", error) + cleanup() + }) + } + private stopCurrentSessionProcesses(next?: string): void { const sid = this.contextSessionID ?? this.currentSession?.id if (!sid || sid === next) return @@ -308,6 +458,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper else this.connectionService.unregisterFocused(this.instanceId) } + public setStreamVisibility(active: boolean): void { + this.visibleTaskStreams.setActive(active) + } + public setProjectDirectory(directory: string | null): void { if (this.projectDirectory === directory) return this.projectDirectory = directory @@ -342,8 +496,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - // Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy - // sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts. + private postConnectionState(error = this.connectionService.getConnectionError()): void { + this.postMessage({ + type: "connectionState", + state: this.connectionState, + ...(this.connectionState === "error" && { + error: getErrorMessage(error) || "Connection to CLI backend lost. Retry to reconnect.", + }), + }) + } + + // Strip metadata unused by the webview to keep session switches fast. + // Logic in kilo-provider/slim-metadata.ts. + private slimInfo(info: T): T { + if (!this.slimEditMetadata) return info + return slimInfo(info) + } + private slimPart(part: T): T { if (!this.slimEditMetadata) return part return slimPart(part) @@ -365,6 +534,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private get removeConfigItemCtx() { + return { + connection: this.connectionService, + project: () => this.getProjectDirectory(this.currentSession?.id), + directory: () => this.getWorkspaceDirectory(), + remove: this.marketplaceRemove, + refresh: async () => { + this.cachedAgentsMessage = null + this.cachedConfigMessage = null + await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()]) + }, + storage: this.extensionContext?.globalStorageUri, + } + } + private async syncWebviewState(reason: string): Promise { const serverInfo = this.connectionService.getServerInfo() console.log("[Kilo New] KiloProvider: 🔄 syncWebviewState()", { @@ -381,7 +565,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Always push connection state first so the UI can render appropriately. - this.postMessage({ type: "connectionState", state: this.connectionState }) + this.postConnectionState() pushTelemetryState((m) => this.postMessage(m)) // Re-send ready so the webview can recover after refresh. @@ -410,6 +594,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper data: profileData, }) + if (this.currentSession) { + this.refreshSessionDetails(this.currentSession.id, this.getWorkspaceDirectory(this.currentSession.id)) + } + // Re-send cached worktree stats and git status after webview reload. if (this.cachedStats) this.postMessage(this.cachedStats) this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) @@ -444,11 +632,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ) { - // Store the webview references this.isWebviewReady = false this.webview = webviewView.webview - // Set up webview options webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri], @@ -465,19 +651,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsPoller.setEnabled(webviewView.visible) this.statsPoller.setVisible(webviewView.visible) } - this.focusSession(webviewView.visible ? this.currentSession?.id : undefined) + this.focusSession(webviewView.visible ? this.contextSessionID : undefined) }) this.initializeConnection() } private setSidebarVisible(visible: boolean): void { + this.setStreamVisibility(visible) vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible) - this.opts.onSidebarVisibilityChange?.(visible) } - /** - * Resolve a WebviewPanel for displaying the Kilo webview in an editor tab. - */ + /** Resolve a WebviewPanel for displaying Kilo in an editor tab. */ public resolveWebviewPanel(panel: vscode.WebviewPanel): void { // WebviewPanel can be restored/reloaded; ensure we don't treat it as ready prematurely. this.isWebviewReady = false @@ -492,7 +676,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.setupWebviewMessageHandler(panel.webview) this.viewStateDisposable?.dispose() - this.viewStateDisposable = panel.onDidChangeViewState(() => + this.viewStateDisposable = this.visibleTaskStreams.bindPanel(panel, () => this.focusSession(panel.active ? this.currentSession?.id : undefined), ) this.initializeConnection() @@ -516,8 +700,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } public loadMessages(sessionID: string): Promise { - // Sub-agent viewer: full transcript (no "load earlier" UI, no pagination). - return this.handleLoadMessages(sessionID, { limit: 0 }) + // Sub-agent viewers share the normal paginated transcript and preserve + // live deltas that arrive while the initial page is loading. + return this.handleLoadMessages(sessionID, { preserveStream: true }) } /** @@ -525,10 +710,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * When set, all operations for this session use this directory instead of the workspace root. */ public setSessionDirectory(sessionId: string, directory: string): void { + this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) this.sessionDirectories.set(sessionId, directory) } public clearSessionDirectory(sessionId: string): void { + this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) this.sessionDirectories.delete(sessionId) } @@ -584,6 +771,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "openCloudSession", sessionId }) } + public selectKiloModel(modelID?: string, agent?: string): void { + if (!modelID && !agent) return + this.pendingKiloModel = { ...(modelID && { modelID }), ...(agent && { agent }) } + this.flushPendingKiloModel() + } + public setContinueInWorktreeHandler( handler: (sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise, ): void { @@ -609,6 +802,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) + this.indexingConfigDisposable?.dispose() + this.indexingConfigDisposable = watchIndexingConfig((msg) => this.postMessage(msg)) this.telemetryStateDisposable?.dispose() this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { @@ -634,6 +829,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (this.handleEditorOpenMessage(message)) return + if ( + await handleWorkStyleMessage({ + message, + connection: this.connectionService, + directory: this.getWorkspaceDirectory(this.currentSession?.id), + post: (msg) => this.postMessage(msg), + }) + ) + return if ( await handleSidebarWorktreeMessage(message, { post: (msg) => this.postMessage(msg), @@ -650,10 +854,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) { return } + this.visibleTaskStreams.handle(message) switch (message.type) { case "webviewReady": console.log("[Kilo New] KiloProvider: ✅ webviewReady received") this.isWebviewReady = true + this.visibleTaskStreams.clear() + this.flushPendingKiloModel() await this.syncWebviewState("webviewReady") this.flushPendingReviewComments() this.recoverPendingPrompts() @@ -671,6 +878,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.agent, message.variant, parseMessageFiles(message.files), + parseReview(message.review, message.text), typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined, typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined, ) @@ -699,14 +907,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.handleAbort(message.sessionID) break case "revertSession": - this.handleRevertSession(message.sessionID, message.messageID, message.partID).catch((e) => - console.error("[Kilo New] handleRevertSession failed:", e), + this.checkpoint(message.sessionID, () => + this.handleRevertSession(message.sessionID, message.messageID, message.partID), ) break case "unrevertSession": - this.handleUnrevertSession(message.sessionID).catch((e) => - console.error("[Kilo New] handleUnrevertSession failed:", e), - ) + this.checkpoint(message.sessionID, () => this.handleUnrevertSession(message.sessionID)) break case "permissionResponse": await handlePermissionResponse( @@ -764,9 +970,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "refreshProfile": await handleRefreshProfile(this.authCtx) break - case "openExternal": - this.openExternal(message.url) - break case "openSettingsPanel": vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab) break @@ -779,9 +982,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "openMarketplacePanel": vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory) break - case "openDiffVirtual": - this.openDiffVirtual(message.diff, message.initialDiffStyle) - break case "forkSession": handleForkSession(this.forkCtx, message.sessionId, message.messageId).catch((e) => console.error("[Kilo New] handleForkSession failed:", e), @@ -796,9 +996,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "openSubAgentViewer": vscode.commands.executeCommand("kilo-code.new.openSubAgentViewer", message.sessionID, message.title) break - case "previewImage": - this.handlePreviewImage(message.dataUrl, message.filename) - break case "saveImage": return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message) case "requestProviders": @@ -833,8 +1030,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] removeSkill failed:", e), ) break - case "removeMode": - this.handleRemoveMode(message.name).catch((e) => console.error("[Kilo New] handleRemoveMode failed:", e)) + case "removeAgent": + this.handleRemoveAgent(message.name).catch((e) => console.error("[Kilo New] handleRemoveAgent failed:", e)) break case "removeMcp": this.handleRemoveMcp(message.name).catch((e) => console.error("[Kilo New] handleRemoveMcp failed:", e)) @@ -891,13 +1088,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] fetchAndSendIndexingStatus failed:", e), ) break + case "requestIndexingSettings": + this.postMessage(buildIndexingSettingsMessage()) + break case "requestKiloEmbeddingModels": this.fetchAndSendKiloEmbeddingModels().catch((e) => console.error("[Kilo New] fetchAndSendKiloEmbeddingModels failed:", e), ) break case "updateConfig": - await this.handleUpdateConfig(message.config, message.projectConfig) + await this.handleUpdateConfig( + message.config, + message.projectConfig, + message.globalUnset, + message.projectUnset, + ) break case "openSettingsTab": if (message.tab === "indexing") { @@ -968,6 +1173,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestNotificationSettings": this.sendNotificationSettings() break + case "testNotification": + previewSound(message.sound) + break case "requestTimelineSetting": this.sendTimelineSetting() break @@ -999,6 +1207,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.agent, message.variant, files, + parseReview(message.review, message.text), typeof message.command === "string" ? message.command : undefined, typeof message.commandArgs === "string" ? message.commandArgs : undefined, ) @@ -1052,11 +1261,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break } // legacy-migration start - case "requestLegacyMigrationData": - void handleRequestLegacyMigrationData(this.migrationCtx) + case "requestMigrationData": + void handleRequestMigrationData(this.migrationCtx, message.source, message.operationId) break - case "startLegacyMigration": - void handleStartLegacyMigration(this.migrationCtx, message.selections) + case "startMigration": + void handleStartMigration(this.migrationCtx, message.source, message.operationId, message.selections) break case "skipLegacyMigration": void handleSkipLegacyMigration(this.migrationCtx) @@ -1096,63 +1305,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) break } - case "fetchMarketplaceData": { - await this.handleFetchMarketplaceData() - break - } - case "filterMarketplaceItems": { - // Client-side filtering — no server action needed - break - } - case "installMarketplaceItem": { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const scope = message.mpInstallOptions?.target ?? "project" - const result = await this.getMarketplace().install(message.mpItem, message.mpInstallOptions, workspace) - if (result.success) { - await this.invalidateAfterMarketplaceChange(scope) - } - this.postMessage({ - type: "marketplaceInstallResult", - success: result.success, - slug: result.slug, - error: result.error, - }) - break - } - case "removeInstalledMarketplaceItem": { - const scope = message.mpInstallOptions?.target ?? "project" - const result = await this.removeMarketplaceItem(message.mpItem, scope) - this.postMessage({ - type: "marketplaceRemoveResult", - success: result.success, - slug: result.slug, - error: result.error, - }) - break - } } }) this.webviewMessageDisposable = watchFontSizeConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) + this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) } - private openExternal(url: unknown): void { - if (typeof url !== "string") return - void vscode.env.openExternal(vscode.Uri.parse(url)) - } - - private openDiffVirtual(diff: unknown, initialDiffStyle?: unknown): void { - if (!this.diffVirtualProvider || !diff) return - const d = diff as import("./DiffVirtualProvider").DiffVirtualFile - d.initialDiffStyle = initialDiffStyle === "split" ? "split" : "unified" - this.diffVirtualProvider.open(d) - } - - private async handleFetchMarketplaceData(): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const mp = this.getMarketplace() - const skills = await this.fetchCliSkills() - const data = await mp.fetchData(workspace, skills) - this.postMessage({ type: "marketplaceData", ...data }) + private handleEditorOpenMessage(message: Parameters[0]): boolean { + return handleEditorAction(message, { + dir: () => this.getWorkspaceDirectory(this.currentSession?.id), + diff: this.diffVirtualProvider, + storage: this.extensionContext?.globalStorageUri, + }) } /** @@ -1190,13 +1354,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Connect the shared service (no-op if already connected) await this.connectionService.connect(workspaceDir) + this.flushPendingKiloModel() // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( - (event) => { + (payload, directory) => { + const event = unwrapSyncEvent(payload) + if (!event) return false + // Remote status events are global and should always pass through if (event.type === "kilo-sessions.remote-status-changed") return true - const sessionId = this.connectionService.resolveEventSessionId(event) + const sessionId = this.resolveEventSessionId(event) // message.part.* events are always session-scoped; drop if session unknown. if (!sessionId) return !isSessionScopedPartEvent(event.type) @@ -1213,17 +1381,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return this.trackedSessionIds.has(sessionId) }, - (event, directory) => { - this.handleEvent(event, directory) + (payload, directory) => { + const event = unwrapSyncEvent(payload) + if (event) this.handleEvent(event, directory) }, ) // Subscribe to connection state changes - this.unsubscribeState = this.connectionService.onStateChange(async (state) => { + this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => { this.connectionState = state - this.postMessage({ type: "connectionState", state }) + this.postConnectionState(error) if (state === "connected") { + this.flushPendingKiloModel() // Fire config warnings independently so a failure in the // sequential await chain doesn't prevent warnings from being shown void this.checkConfigWarnings("state") @@ -1270,7 +1440,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // legacy-migration start // Subscribe to migration-complete broadcast from any KiloProvider instance this.unsubscribeMigrationComplete = this.connectionService.onMigrationComplete(() => { - this.postMessage({ type: "migrationState", needed: false }) + this.postMessage({ type: "migrationState", needed: false, source: "legacy" }) }) // legacy-migration end @@ -1300,7 +1470,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper workspaceDirectory: this.getProjectDirectory(this.currentSession?.id), }) } - this.postMessage({ type: "connectionState", state: this.connectionState }) + this.postConnectionState() // connect() can resolve after SSE reaches "connected" but before this // provider subscribes to onStateChange(). In that case the initial @@ -1370,6 +1540,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.stopCurrentSessionProcesses(session.id) this.setCurrentSession(session) this.contextSessionID = session.id + this.focusSession(session.id) this.trackDirectory(session.id, workspaceDir) this.trackedSessionIds.add(session.id) @@ -1390,13 +1561,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Non-blocking: refresh session metadata + status for the webview after switching. */ private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { if (!this.client) return + const revision = this.revisions.get(sessionID) + const refresh = (this.refreshes.get(sessionID) ?? 0) + 1 + this.refreshes.set(sessionID, refresh) this.client.session .get({ sessionID, directory: dir }) .then((r) => { - if (r.data && !signal?.aborted && this.contextSessionID === sessionID) { - this.setCurrentSession(r.data) - this.contextSessionID = r.data.id + if (!r.data || signal?.aborted || this.contextSessionID !== sessionID) return + if (this.refreshes.get(sessionID) !== refresh) { + if (this.revisions.get(sessionID) !== revision) this.refreshSessionDetails(sessionID, dir, signal) + return } + if (this.revisions.get(sessionID) !== revision) { + this.refreshSessionDetails(sessionID, dir, signal) + return + } + this.setCurrentSession(r.data) + this.contextSessionID = r.data.id + this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(r.data) }) }) .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e)) this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) }) @@ -1419,7 +1601,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async handleLoadMessages( sessionID: string, - options: { mode?: MessageLoadMode; before?: string; limit?: number } = {}, + options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {}, ): Promise { const mode = options.mode ?? "replace" if (mode === "replace" || mode === "focus") { @@ -1461,16 +1643,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // no abort controller, so this guard prevents ghost entries. if (!this.trackedSessionIds.has(sessionID)) return const messages = page.items.map((m) => ({ - ...m.info, + ...this.slimInfo(m.info), parts: this.slimParts(m.parts), createdAt: new Date(m.info.time.created).toISOString(), })) for (const message of messages) { this.connectionService.recordMessageSessionId(message.id, message.sessionID) } - // Authoritative snapshot: drop queued deltas. Prepend is older history - // and must not clobber live deltas. - if (mode === "replace" || mode === "reconcile") this.streams.drop(sessionID) + // Authoritative snapshots normally supersede buffered deltas. A newly + // opened sub-agent viewer has no earlier renderer state, so its buffered + // updates arrived during this fetch and must follow the snapshot. + if ((mode === "replace" || mode === "reconcile") && !options.preserveStream) this.streams.drop(sessionID) if (mode === "reconcile") this.lastReconciledAt.set(sessionID, Date.now()) this.postMessage({ type: "messagesLoaded", @@ -1481,6 +1664,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper hasMore: Boolean(page.cursor), since, }) + if (options.preserveStream) this.streams.flush(sessionID) // Recover any prompts missed while the webview was loading or during an SSE reconnection. this.recoverPendingPrompts() } catch (error) { @@ -1519,7 +1703,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) const messages = messagesData.map((m) => ({ - ...m.info, + ...this.slimInfo(m.info), parts: this.slimParts(m.parts), createdAt: new Date(m.info.time.created).toISOString(), })) @@ -1637,9 +1821,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true }) this.trackedSessionIds.delete(sessionID) this.streams.drop(sessionID) + this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) + this.aborts.delete(sessionID) this.lastReconciledAt.delete(sessionID) + this.checkpoints.delete(sessionID) + this.revisions.delete(sessionID) + this.refreshes.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { this.contextSessionID = undefined @@ -1660,27 +1849,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle renaming a session. */ private async handleRenameSession(sessionID: string, title: string): Promise { - if (!this.client) { - this.postMessage({ type: "error", message: "Not connected to CLI backend" }) - return - } - try { - const workspaceDir = this.getWorkspaceDirectory(sessionID) - const { data: updated } = await this.client.session.update( - { sessionID, directory: workspaceDir, title }, - { throwOnError: true }, - ) - if (this.currentSession?.id === sessionID) { - this.setCurrentSession(updated) - } + const updated = await renameSession({ + client: this.client, + sessionID, + title, + directory: this.getWorkspaceDirectory(sessionID), + }) + if (this.currentSession?.id === sessionID) this.setCurrentSession(updated) this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(updated) }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to rename session:", error) - this.postMessage({ - type: "error", - message: getErrorMessage(error) || "Failed to rename session", - }) + this.postMessage({ type: "error", message: getErrorMessage(error) || "Failed to rename session" }) } } @@ -1727,12 +1907,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } try { - const { response, authMethods, authStates } = await fetchProviderData(client, this.getWorkspaceDirectory()) + const { response, authMethods, authStates, storedKeys } = await fetchProviderData( + client, + this.getWorkspaceDirectory(), + ) if (generation !== this.providersGeneration || client !== this.client) { if (!this.providersQueued) return generation = this.providersGeneration continue } + this.storedProviderKeys = storedKeys const settings = vscode.workspace.getConfiguration("kilo-code.new.model") const message = { type: "providersLoaded", @@ -1819,7 +2003,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const rid = typeof msg.requestId === "string" ? msg.requestId : "" const url = typeof msg.baseURL === "string" ? msg.baseURL : "" if (!rid || !url) return - const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined + const key = + typeof msg.apiKey === "string" ? msg.apiKey : resolveStoredKey(this.storedProviderKeys, msg.providerID, url) const headers = msg.headers && typeof msg.headers === "object" ? (msg.headers as Record) : undefined try { const models = await fetchOpenAIModels({ baseURL: url, apiKey: key, headers }) @@ -1912,18 +2097,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async fetchCliSkills(): Promise | undefined> { - if (!this.client) return undefined - try { - const dir = this.getWorkspaceDirectory() - const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true })) - return data - } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error) - return undefined - } - } - /** * Remove a skill via the CLI backend (deletes from disk + clears cache), then refresh. * Returns true on success, false on failure. @@ -1954,94 +2127,31 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return true } - /** - * Remove a custom mode via the CLI backend (deletes from disk + refreshes state). - * The webview optimistically removes the mode from its list before this runs. - * On failure, re-fetches agents so the webview reverts to the authoritative state. - */ - private async handleRemoveMode(name: string): Promise { + /** Remove an agent via CLI, falling back to kilo.json removal. */ + private async handleRemoveAgent(name: string): Promise { if (!this.client) return - - // 1. Try CLI removal (handles .md files and legacy .kilocodemodes) try { - const dir = this.getWorkspaceDirectory() - const result = await this.client.kilocode.removeAgent({ name, directory: dir }) + const result = await this.client.kilocode.removeAgent({ name, directory: this.getWorkspaceDirectory() }) if (!result.error) { this.cachedAgentsMessage = null await this.fetchAndSendAgents() return } } catch { - // CLI removal failed — agent may be in kilo.json instead + // fall through to kilo.json removal } - - // 2. Try removing from kilo.json (handles marketplace-installed modes) - const stub = { id: name, type: "mode" as const, name, description: "", content: "" } - const removed = await this.removeMarketplaceItemFromAllScopes(stub) - if (!removed) { - console.error("[Kilo New] KiloProvider: Failed to remove mode:", name) + if (!(await removeAgent(this.removeConfigItemCtx, name))) { + console.error("[Kilo New] KiloProvider: Failed to remove agent:", name) } } private async handleRemoveMcp(name: string): Promise { - // Remove from legacy files first so that the subsequent invalidation - // causes the CLI to re-read config without the legacy entry. - await this.removeLegacyMcp(name) - - const stub = { id: name, type: "mcp" as const, name, description: "", url: "", content: "" } - const removed = await this.removeMarketplaceItemFromAllScopes(stub) + const removed = await removeMcp(this.removeConfigItemCtx, name) if (!removed) { console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name) } } - /** - * Remove an MCP server from legacy config files (.kilo/mcp.json, .kilocode/mcp.json, - * and the VS Code global storage mcp_settings.json). These files are read by the - * CLI-side McpMigrator and merged into config at the lowest precedence level. - * Returns true if the entry was found and removed from at least one file. - */ - private async removeLegacyMcp(name: string): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const files: vscode.Uri[] = [] - - // Project-level legacy files - if (workspace) { - files.push(vscode.Uri.file(path.join(workspace, ".kilo", "mcp.json"))) - files.push(vscode.Uri.file(path.join(workspace, ".kilocode", "mcp.json"))) - } - - // Global legacy file (VS Code extension global storage) - const storage = this.extensionContext?.globalStorageUri - if (storage) { - files.push(vscode.Uri.joinPath(storage, "settings", "mcp_settings.json")) - } - - let removed = false - for (const uri of files) { - const bytes = await vscode.workspace.fs.readFile(uri).then( - (b) => b, - () => null, - ) - if (!bytes) continue - - try { - const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record - const servers = parsed.mcpServers as Record | undefined - if (!servers?.[name]) continue - - delete servers[name] - const content = Buffer.from(JSON.stringify(parsed, null, 2), "utf8") - await vscode.workspace.fs.writeFile(uri, content) - removed = true - } catch (err) { - console.warn("[Kilo New] KiloProvider: Failed to remove legacy MCP from", uri.fsPath, err) - } - } - - return removed - } - private async fetchAndSendMcpStatus(): Promise { if (!this.client) { if (this.cachedMcpStatusMessage) { @@ -2063,75 +2173,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Remove a marketplace item from a single scope and invalidate CLI caches. - */ - private async removeMarketplaceItem(item: MarketplaceItem, scope: "project" | "global"): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const result = await this.getMarketplace().remove(item, scope, workspace) - if (result.success) { - await this.invalidateAfterMarketplaceChange(scope) - } - return result - } - - /** - * Remove a marketplace item from both project and global scopes. - * mp.remove returns success even when the entry doesn't exist (no-op), - * so we must attempt both scopes to cover dual-scope installations. - * Returns true if at least one scope removal succeeded. - */ - private async removeMarketplaceItemFromAllScopes(item: MarketplaceItem): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const mp = this.getMarketplace() - const project = await mp.remove(item, "project", workspace) - const global = await mp.remove(item, "global", workspace) - - if (project.success || global.success) { - const scope = global.success ? "global" : "project" - await this.invalidateAfterMarketplaceChange(scope) - return true - } - return false - } - - /** - * Invalidate CLI caches and refresh the webview after a marketplace install/remove. - * - * For global scope: uses global.config.update with the freshly-written config file - * contents rather than global.dispose. This goes through Config.updateGlobal() which - * calls Config.global.reset() to invalidate the lazy-cached global config, ensuring - * the newly installed/removed MCP entry is visible on the next config.get call. - * (global.dispose alone is not sufficient on older CLI versions that lack the - * Config.global.reset() call in the dispose handler.) - * - * For project scope: instance.dispose is sufficient because the per-instance - * Config.state is cleared and re-reads all files (including global) on next access. - */ - private async invalidateAfterMarketplaceChange(scope: "project" | "global"): Promise { - if (!this.client) return - if (scope === "global") { - // Use global.config.update with an empty config to trigger Config.updateGlobal() - // which calls Config.global.reset(). This invalidates the lazy-cached global - // config in the CLI process so it re-reads kilo.json from disk. - // An empty object merge is a no-op for the file content but resets the cache. - // (global.dispose alone is insufficient on older CLI versions that lack - // the Config.global.reset() call in the dispose handler.) - await this.client.global.config.update({ config: {} }).catch((e: unknown) => { - console.warn("[Kilo New] global.config.update after marketplace change failed:", e) - }) - } - // Always dispose the per-project instance so it rebuilds state from - // the (possibly updated) global + project config on the next request. - const dir = this.getWorkspaceDirectory() - await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => { - console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e) - }) - this.cachedAgentsMessage = null - this.cachedConfigMessage = null - await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()]) - } - /** * Fetch backend config and send to webview. */ @@ -2151,16 +2192,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: config } = await retry(() => - this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }), - ) - const { data: global } = await this.client.global.config.get({ throwOnError: true }) + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })), + this.client.global.config.get({ throwOnError: true }), + this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }), + ]) this.cachedGlobalConfig = global ?? null const message = { type: "configLoaded", config, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2245,16 +2288,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || this.connectionState !== "connected") return try { const dir = this.getWorkspaceDirectory() - const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - const { data: global } = await this.client.global.config.get({ throwOnError: true }) + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), + this.client.global.config.get({ throwOnError: true }), + this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) this.cachedGlobalConfig = global ?? null this.cachedConfigMessage = { type: "configLoaded", config, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(config), } - this.postMessage({ type: "configUpdated", config, globalConfig: global, features: configFeatures(config) }) + this.postMessage({ + type: "configUpdated", + config, + globalConfig: global, + projectConfig: overlay?.project, + features: configFeatures(config), + }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -2378,21 +2431,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.notifyNotificationDismissed(notificationId) } - /** - * Read notification/sound settings from VS Code config and push to webview. - */ + /** Read attention settings from VS Code config and push to webview. */ private sendNotificationSettings(): void { - const notifications = vscode.workspace.getConfiguration("kilo-code.new.notifications") - const sounds = vscode.workspace.getConfiguration("kilo-code.new.sounds") + const attention = vscode.workspace.getConfiguration("kilo-code.new.attention") this.postMessage({ type: "notificationSettingsLoaded", settings: { - notifyAgent: notifications.get("agent", true), - notifyPermissions: notifications.get("permissions", true), - notifyErrors: notifications.get("errors", true), - soundAgent: sounds.get("agent", "default"), - soundPermissions: sounds.get("permissions", "default"), - soundErrors: sounds.get("errors", "default"), + attentionEnabled: attention.get("enabled", false), + attentionSound: attention.get("sound", "default"), }, }) } @@ -2405,12 +2451,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } - /** Returns the number of sessions currently in "busy" state. */ - private getBusySessionCount(): number { - return getBusySessionCount(this.sessionStatusMap) + private sendWorkStyle(): void { + this.postMessage(getWorkStylePayload()) } - private async handleUpdateConfig(partial: Partial, project: Partial = {}): Promise { + private async handleUpdateConfig( + partial: Partial, + project: Partial = {}, + globalUnset: string[][] = [], + projectUnset: string[][] = [], + ): Promise { if (!this.client || this.connectionState !== "connected") { this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" }) return @@ -2419,22 +2469,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const refreshProviders = partial.provider !== undefined || partial.disabled_providers !== undefined || - partial.enabled_providers !== undefined + partial.enabled_providers !== undefined || + partial.hide_prompt_training_models !== undefined const refreshAgents = partial.default_agent !== undefined || partial.agent !== undefined || project.default_agent !== undefined || project.agent !== undefined - const hasGlobal = Object.keys(partial).length > 0 - const hasProject = Object.keys(project).length > 0 + const hasGlobal = Object.keys(partial).length > 0 || globalUnset.length > 0 + const hasProject = Object.keys(project).length > 0 || projectUnset.length > 0 this.pending++ const dir = this.getWorkspaceDirectory() try { await this.connectionService.drainPendingPrompts() - if (hasGlobal) await this.client.global.config.update({ config: partial }, { throwOnError: true }) - if (hasProject) await this.client.config.update({ config: project, directory: dir }, { throwOnError: true }) + if (hasGlobal) { + await this.client.config.overlayUpdate( + { scope: "global", set: partial, unset: globalUnset, directory: dir }, + { throwOnError: true }, + ) + } + if (hasProject) { + await this.client.config.overlayUpdate( + { scope: "project", set: project, unset: projectUnset, directory: dir }, + { throwOnError: true }, + ) + } } catch (error) { this.postConfigFailure(error) this.pending-- @@ -2442,19 +2503,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - const { data: global } = await this.client.global.config.get({ throwOnError: true }) + const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), + this.client.global.config.get({ throwOnError: true }), + this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) this.cachedGlobalConfig = global ?? null this.cachedConfigMessage = { type: "configLoaded", config: merged, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(merged), } this.postMessage({ type: "configUpdated", config: merged, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(merged), }) await Promise.all([ @@ -2510,6 +2576,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.stopCurrentSessionProcesses(session.id) this.setCurrentSession(session) this.contextSessionID = session.id + this.focusSession(session.id) this.trackDirectory(session.id, dir) this.trackedSessionIds.add(session.id) this.postMessage({ @@ -2610,6 +2677,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper agent?: string, variant?: string, files?: MessageFile[], + review?: ReviewMessageData, context?: string, contextDirectory?: string, ): Promise { @@ -2622,6 +2690,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper draftID, messageID, files, + review, }) return } @@ -2636,7 +2705,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source }) } } - parts.push({ type: "text", text }) + parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined }) const sid = resolved!.sid const dir = resolved!.dir @@ -2646,6 +2715,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.recordMessageSessionId(messageID, sid) } + await this.checkpoints.get(sid) await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Message request", () => this.withRetry( () => @@ -2658,6 +2728,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper agent, variant, editorContext, + snapshotInitialization: this.opts.snapshotInitialization, }), sid, messageID, @@ -2673,6 +2744,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper draftID, messageID, files, + review, }) } } @@ -2722,6 +2794,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const sid = resolved!.sid const dir = resolved!.dir + await this.checkpoints.get(sid) await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () => this.withRetry( () => @@ -2735,6 +2808,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper agent, variant, parts, + snapshotInitialization: this.opts.snapshotInitialization, }), sid, messageID, @@ -2755,24 +2829,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private async handleAbort(sessionID?: string): Promise { - if (!this.client) { - return - } - - const targetSessionID = sessionID || this.currentSession?.id - if (!targetSessionID) { - return - } - - try { - await abortSession({ - client: this.client, - sessionID: targetSessionID, - dir: this.getWorkspaceDirectory(targetSessionID), - }) - } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to abort session:", error) - } + const sid = sessionID || this.currentSession?.id + if (!this.client || !sid || !(await this.aborts.stop(this.client, sid, this.getWorkspaceDirectory(sid)))) return + this.sessionStatusMap.set(sid, "idle") + this.streams.flush(sid) + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } private async handleRevertSession(sessionID: string, messageID: string, partID?: string): Promise { @@ -2782,9 +2843,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (error) { console.error("[Kilo New] KiloProvider: Failed to revert session:", error) this.postMessage({ type: "error", message: "Failed to revert session", sessionID }) - return + throw error } - if (data) this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) }) + if (!data) throw new Error("Revert returned no session") + this.refreshes.set(sessionID, (this.refreshes.get(sessionID) ?? 0) + 1) + if (this.currentSession?.id === sessionID) this.setCurrentSession(data) + this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) }) } private async handleUnrevertSession(sessionID: string): Promise { @@ -2794,9 +2858,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (error) { console.error("[Kilo New] KiloProvider: Failed to unrevert session:", error) this.postMessage({ type: "error", message: "Failed to redo session", sessionID }) - return + throw error } - if (data) this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) }) + if (!data) throw new Error("Redo returned no session") + this.refreshes.set(sessionID, (this.refreshes.get(sessionID) ?? 0) + 1) + if (this.currentSession?.id === sessionID) this.setCurrentSession(data) + this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) }) } /** @@ -2849,16 +2916,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper currentSessionId: this.currentSession?.id, trackedSessionIds: this.trackedSessionIds, sessionDirectories: this.sessionDirectories, + extraDirectories: this.opts.worktreeDirectories, postMessage: (msg) => this.postMessage(msg), getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid), - recordPermissionDirectory: (id, dir) => this.permissionDirectories.set(id, dir), - getPermissionDirectory: (id) => this.permissionDirectories.get(id), - clearPermissionDirectory: (id) => this.permissionDirectories.delete(id), - prunePermissionDirectories: (active) => { - for (const key of this.permissionDirectories.keys()) { - if (!active.has(key)) this.permissionDirectories.delete(key) - } - }, + recordPermissionDirectory: (id, dir) => this.connectionService.recordPermissionDirectory(id, dir), + getPermissionDirectory: (id) => this.connectionService.getPermissionDirectory(id), + clearPermissionDirectory: (id) => this.connectionService.clearPermissionDirectory(id), + prunePermissionDirectories: (active, dirs) => this.connectionService.prunePermissionDirectories(active, dirs), } } @@ -2868,8 +2932,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper currentSessionId: this.currentSession?.id, trackedSessionIds: this.trackedSessionIds, sessionDirectories: this.sessionDirectories, + extraDirectories: this.opts.worktreeDirectories, postMessage: (msg: unknown) => this.postMessage(msg), getWorkspaceDirectory: (sid?: string) => this.getWorkspaceDirectory(sid), + recordQuestionDirectory: (id: string, dir: string) => this.connectionService.recordQuestionDirectory(id, dir), + getQuestionDirectory: (id: string) => this.connectionService.getQuestionDirectory(id), + clearQuestionDirectory: (id: string) => this.connectionService.clearQuestionDirectory(id), + getQuestionRevision: () => this.connectionService.getQuestionRevision(), + pruneQuestionDirectories: (active: Set, dirs: Set) => + this.connectionService.pruneQuestionDirectories(active, dirs), } } @@ -2931,106 +3002,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private handlePreviewImage(dataUrl: string, filename: string): void { - const dir = this.extensionContext?.globalStorageUri - if (!dir) return - - const img = parseImage(dataUrl, filename) - if (!img) return - - const root = vscode.Uri.joinPath(dir, getPreviewDir()) - const uri = vscode.Uri.joinPath(dir, buildPreviewPath(img.name, Date.now())) - const clean = () => - vscode.workspace.fs.readDirectory(root).then( - (items) => { - const stale = trimEntries(items.map(([name]) => ({ path: name }))) - return Promise.all( - stale.map((name) => - Promise.resolve(vscode.workspace.fs.delete(vscode.Uri.joinPath(root, name), { recursive: true })).then( - undefined, - (err: unknown) => { - console.warn("[Kilo New] KiloProvider: Failed to delete stale preview:", err) - }, - ), - ), - ) - }, - () => [], - ) - const open = () => - vscode.commands - .executeCommand(...getPreviewCommand(uri)) - .then(undefined, () => vscode.commands.executeCommand("vscode.open", uri)) - - void vscode.workspace.fs - .createDirectory(root) - .then(() => vscode.workspace.fs.writeFile(uri, img.data)) - .then(() => clean()) - .then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err)) - } - - private handleEditorOpenMessage(message: { - type?: string - filePath?: string - line?: number - column?: number - content?: string - language?: string - }): boolean { - if (message.type === "openFile") { - if (message.filePath) this.handleOpenFile(message.filePath, message.line, message.column) - return true - } - if (message.type === "openContent") { - if (message.content) this.handleOpenContent(message.content, message.language) - return true - } - return false - } - - /** - * Handle openContent request - open arbitrary text in an untitled VS Code editor tab. - */ - private handleOpenContent(content: string, language?: string): void { - vscode.workspace.openTextDocument({ content, language: language || "log" }).then( - (doc) => vscode.window.showTextDocument(doc, { preview: true }), - (err) => console.error("[Kilo New] KiloProvider: Failed to open content:", err), - ) - } - - /** - * Handle openFile request from the webview — open a file in the VS Code editor. - * Resolves relative paths against the current session's directory (which may be - * a worktree path registered via setSessionDirectory), falling back to workspace root. - * Absolute paths (Unix `/…` or Windows `C:\…`) are used as-is. - */ - private handleOpenFile(filePath: string, line?: number, column?: number): void { - const uri = isAbsolutePath(filePath) - ? vscode.Uri.file(filePath) - : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath) - vscode.workspace.fs.stat(uri).then( - (stat) => { - if (stat.type & vscode.FileType.Directory) { - vscode.commands.executeCommand("revealInExplorer", uri) - return - } - vscode.workspace.openTextDocument(uri).then( - (doc) => { - const options: vscode.TextDocumentShowOptions = { preview: true } - if (line !== undefined && line > 0) { - const col = column !== undefined && column > 0 ? column - 1 : 0 - const pos = new vscode.Position(line - 1, col) - options.selection = new vscode.Range(pos, pos) - } - vscode.window.showTextDocument(doc, options) - }, - (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), - ) - }, - (err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err), - ) - } - /** * Handle a generic setting update from the webview. * The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled"). @@ -3038,8 +3009,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async handleUpdateSetting(key: string, value: unknown): Promise { const { section, leaf } = buildSettingPath(key) if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return + if (section === "indexing" && !validIndexingSetting(leaf, value)) return const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) - await config.update(leaf, value, vscode.ConfigurationTarget.Global) + // Normalize a webview-side clear to `undefined` so VS Code removes the + // key from settings.json rather than persisting a literal `null`. This + // lets the runtime fall back to the resolved default. + const next = value === null ? undefined : value + await config.update(leaf, next, vscode.ConfigurationTarget.Global) + if (isWorkStyleSetting(key)) this.sendWorkStyle() } /** @@ -3075,12 +3052,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.extensionContext?.globalState.update("variantSelections", undefined) await this.extensionContext?.globalState.update("recentModels", undefined) await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined) + await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", undefined) // Re-send all settings to the webview so the UI reflects the reset this.postMessage(buildAutocompleteSettingsMessage()) + this.postMessage(buildIndexingSettingsMessage()) this.sendBrowserSettings() this.sendNotificationSettings() this.sendTimelineSetting() + this.sendWorkStyle() await ModelState.reset(this.client, (msg) => this.postMessage(msg)) // Re-send globalState items to the webview @@ -3132,11 +3112,79 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ]) } + private mapSyncEventToWebviewMessage(event: LegacySyncEvent) { + switch (event.type) { + case "message.updated": { + const info = event.properties.info + return { + type: "messageCreated" as const, + message: { + ...info, + createdAt: new Date(info.time.created).toISOString(), + }, + } + } + case "message.removed": + return { + type: "messageRemoved" as const, + sessionID: event.properties.sessionID, + messageID: event.properties.messageID, + } + case "message.part.updated": + return { + type: "partUpdated" as const, + sessionID: event.properties.sessionID, + messageID: event.properties.part.messageID, + part: event.properties.part, + } + case "message.part.removed": + return { + type: "partRemoved" as const, + sessionID: event.properties.sessionID, + messageID: event.properties.messageID, + partID: event.properties.partID, + } + case "session.created": + return { + type: "sessionCreated" as const, + session: this.sessionToWebview(event.properties.info), + } + case "session.updated": + return { + type: "sessionUpdated" as const, + session: + this.currentSession?.id === event.properties.sessionID + ? this.sessionToWebview(this.currentSession) + : sessionPatchToWebview(event.properties.sessionID, event.properties.info), + } + case "session.deleted": + return null + } + } + + private resolveEventSessionId(event: ProviderEvent): string | undefined { + switch (event.type) { + case "session.created": + case "session.updated": + case "session.deleted": + return event.properties.sessionID + case "message.updated": + this.connectionService.recordMessageSessionId(event.properties.info.id, event.properties.sessionID) + return event.properties.sessionID + case "message.removed": + case "message.part.updated": + case "message.part.removed": + return event.properties.sessionID + default: + return this.connectionService.resolveEventSessionId(event) + } + } + /** * Handle SSE events from the CLI backend. * Filters events by project ID and tracked session IDs so each webview only sees its own sessions. */ - private handleEvent(event: Event, directory?: string): void { + private handleEvent(event: ProviderEvent, directory?: string): void { if (event.type === "kilo-sessions.remote-status-changed") { this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected }) return @@ -3145,13 +3193,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // 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. - if (isEventFromForeignProject(event, this.projectID)) return - - if (event.type === "permission.asked" && directory) { - this.permissionDirectories.set(event.properties.id, directory) - } - if (event.type === "permission.replied") { - this.permissionDirectories.delete(event.properties.requestID) + if ( + !isLegacySyncEvent(event) && + !isFullSessionUpdatedEvent(event) && + isEventFromForeignProject(event, this.projectID) + ) + return + if ( + this.projectID && + (event.type === "session.created" || event.type === "session.updated") && + event.properties.info.projectID !== undefined && + event.properties.info.projectID !== null && + event.properties.info.projectID !== this.projectID + ) { + return } if (event.type === "mcp.browser.open.failed") { @@ -3171,6 +3226,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (event.type === "session.status") { const sid = event.properties.sessionID this.sessionStatusMap.set(sid, event.properties.status.type) + this.aborts.observe(sid, event.properties.status.type, directory) const msg = mapSSEEventToWebviewMessage(event, sid) if (msg) { this.streams.flush(sid) @@ -3184,7 +3240,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } - const sessionID = this.connectionService.resolveEventSessionId(event) + const sessionID = this.resolveEventSessionId(event) // Events without sessionID (server.connected, server.heartbeat, indexing.status) → always forward // Events with sessionID → only forward if this webview tracks that session @@ -3194,6 +3250,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + if (event.type === "session.updated") { + // Full bus snapshots duplicate sync patches with the same event ID but no sequence metadata. + if (isFullSessionUpdatedEvent(event)) return + const sid = event.properties.sessionID + const revision = this.revisions.get(sid) + const versioned = event.seq > 0 || (revision?.seq ?? 0) > 0 + if (revision && (versioned ? event.seq <= revision.seq : event.id <= revision.id)) return + this.revisions.set(sid, { id: event.id, seq: event.seq }) + } + // Refresh provider and agent lists when the server signals a state disposal if (event.type === "global.disposed") { void this.reloadAfterAuthChange() @@ -3203,6 +3269,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (event.type === "server.instance.disposed") { const props = event.properties as Record | null const dir = typeof props?.directory === "string" ? props.directory : undefined + if (dir) for (const sid of this.aborts.dispose(dir)) this.sessionStatusMap.set(sid, "idle") if (dir && !sameDirectory(dir, this.getWorkspaceDirectory())) return void this.reloadAfterAuthChange() return @@ -3223,9 +3290,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.contextSessionID = event.properties.info.id this.trackedSessionIds.add(event.properties.info.id) } - if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) { - this.setCurrentSession(event.properties.info) - this.contextSessionID = event.properties.info.id + if (event.type === "session.updated" && this.currentSession?.id === event.properties.sessionID) { + this.setCurrentSession(applySessionPatch(this.currentSession, event.properties.info)) + this.contextSessionID = event.properties.sessionID } // Auto-adopt child sessions as soon as the task tool part reveals their ID. @@ -3247,23 +3314,38 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - handleNetworkEvent(event.type as string, event.properties as any, this.client, (s) => this.getWorkspaceDirectory(s)) + if (!isLegacySyncEvent(event)) { + const props = event.properties + handleNetworkEvent( + event.type, + { + id: "id" in props && typeof props.id === "string" ? props.id : undefined, + sessionID: "sessionID" in props && typeof props.sessionID === "string" ? props.sessionID : undefined, + requestID: "requestID" in props && typeof props.requestID === "string" ? props.requestID : undefined, + }, + this.client, + (s) => this.getWorkspaceDirectory(s), + ) + } if (event.type === "indexing.status" && directory) { if (!sameDirectory(directory, this.getWorkspaceDirectory(this.currentSession?.id))) return } - const msg = mapSSEEventToWebviewMessage(event, sessionID) + const msg = isLegacySyncEvent(event) + ? this.mapSyncEventToWebviewMessage(event) + : mapSSEEventToWebviewMessage(event, sessionID) if (!msg) return if (msg.type === "partUpdated") { this.streams.push({ ...msg, part: this.slimPart(msg.part) }) return } - if (msg.type === "indexingStatusLoaded") { - this.cachedIndexingStatusMessage = msg + const next = msg.type === "messageCreated" ? { ...msg, message: this.slimInfo(msg.message) } : msg + if (next.type === "indexingStatusLoaded") { + this.cachedIndexingStatusMessage = next } this.streams.flush(sessionID) - this.postMessage(msg) + this.postMessage(next) } /** Wait until the webview has sent "webviewReady". Resolves immediately when already ready. */ @@ -3289,6 +3371,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + private flushPendingKiloModel(): void { + if (!this.webview || !this.isWebviewReady || !this.client || !this.pendingKiloModel) return + + const pending = this.pendingKiloModel + this.pendingKiloModel = null + this.postMessage({ type: "selectKiloModel", ...pending }) + } + public async appendReviewComments(comments: unknown[], autoSend = false): Promise { this.pendingReviewComments.push({ comments, autoSend }) @@ -3490,6 +3580,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")), styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")), iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), title: "Kilo Code", port: this.connectionService.getServerInfo()?.port, extraStyles: `.container { height: 100%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid var(--border-weak-base); }`, @@ -3505,12 +3596,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper client: this.client, extensionContext: this.extensionContext, postMessage: (msg) => this.postMessage(msg), - get cachedLegacyData() { - return self.cachedLegacyData - }, - set cachedLegacyData(data) { - self.cachedLegacyData = data - }, + migrationCache: self.migrationCache, get migrationCheckInFlight() { return self.migrationCheckInFlight }, @@ -3525,12 +3611,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // legacy-migration end --------------------------------------------------------- - private getMarketplace(): MarketplaceService { - if (this.marketplace) return this.marketplace - this.marketplace = new MarketplaceService() - return this.marketplace - } - // ── Worktree stats polling (sidebar diff badge) ────────────────── private startStatsPolling(): void { this.statsPoller?.stop() @@ -3582,8 +3662,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() + this.indexingConfigDisposable?.dispose() this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() + this.visibleTaskStreams.clear() this.streams.dispose() this.isWebviewReady = false this.promptRecoveryQueued = false @@ -3591,10 +3673,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() this.syncedChildSessions.clear() this.sessionDirectories.clear() - this.permissionDirectories.clear() + this.aborts.clear() this.sessionStatusMap.clear() this.ignoreController?.dispose() this.chatAutocomplete?.dispose() - ;(this.marketplace?.dispose(), disposeGitChangesTarget()) + disposeGitChangesTarget() } } diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts new file mode 100644 index 00000000000..cbdd5f53de4 --- /dev/null +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -0,0 +1,291 @@ +import * as os from "os" +import * as vscode from "vscode" +import type { GlobalEvent, SessionStatus } from "@kilocode/sdk/v2/client" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" +import { watchFontSizeConfig } from "./kilo-provider/font-size" +import { mapSSEEventToWebviewMessage } from "./kilo-provider-utils" +import { resolvePanelProjectDirectory } from "./project-directory" +import { seedSessionStatuses } from "./session-status" +import type { KiloConnectionService } from "./services/cli-backend" +import { MarketplaceService } from "./services/marketplace" +import { + fetchMarketplaceData, + installMarketplaceItem, + removeMarketplaceItem, + type MarketplaceActionContext, +} from "./services/marketplace/actions" +import type { InstallMarketplaceItemOptions, MarketplaceItem } from "./services/marketplace/types" +import { TelemetryProxy } from "./services/telemetry" +import { TelemetryEventName } from "./services/telemetry/types" + +interface MarketplaceMessage { + type?: string + mpItem?: MarketplaceItem + mpInstallOptions?: InstallMarketplaceItemOptions + url?: unknown + event?: string + properties?: Record +} + +export class MarketplacePanelProvider implements vscode.Disposable { + public static readonly viewType = "kilo-code.new.marketplacePanel" + + private panel: vscode.WebviewPanel | undefined + private project: string | null = null + private ready = false + private statuses = new Map() + private disposables: vscode.Disposable[] = [] + private subscriptions: Array<() => void> = [] + private readonly marketplace = new MarketplaceService() + private readonly extensionVersion = + vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown" + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly connection: KiloConnectionService, + private readonly context: vscode.ExtensionContext, + ) {} + + private get marketplaceCtx(): MarketplaceActionContext { + return { connection: this.connection, marketplace: this.marketplace, storage: this.context.globalStorageUri } + } + + /** + * `undefined` infers the project from the active editor or workspace, + * while `null` intentionally disables project-scoped operations when no directory can be + * selected safely, such as in an ambiguous multi-root workspace. + */ + openPanel(directory?: string | null): void { + const project = directory === undefined ? this.resolveProject() : directory + if (this.panel) { + this.setProjectDirectory(project) + this.panel.reveal(vscode.ViewColumn.One) + return + } + + const panel = vscode.window.createWebviewPanel( + MarketplacePanelProvider.viewType, + "Kilo Marketplace", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }, + ) + this.attach(panel, project) + } + + deserializePanel(panel: vscode.WebviewPanel): void { + this.attach(panel, this.resolveProject()) + } + + dispose(): void { + this.panel?.dispose() + this.cleanup() + this.marketplace.dispose() + } + + private attach(panel: vscode.WebviewPanel, project: string | null): void { + this.cleanup() + this.panel = panel + this.project = project + this.ready = false + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), + } + panel.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + } + panel.webview.html = this.getHtml(panel.webview) + + this.disposables.push( + panel.webview.onDidReceiveMessage((msg) => void this.handle(msg as MarketplaceMessage)), + panel.onDidDispose(() => this.cleanup()), + watchFontSizeConfig((msg) => this.post(msg)), + ) + this.subscriptions.push( + this.connection.onStateChange((state, err) => { + this.post({ type: "connectionState", state, ...(err ? { error: err.message } : {}) }) + if (state === "connected") void this.sync(false) + }), + this.connection.onLanguageChanged((locale) => this.post({ type: "languageChanged", locale })), + this.connection.onEventFiltered( + (event) => event.type === "session.status", + (event) => { + if (event.type === "session.status") this.handleStatus(event) + }, + ), + ) + void this.connect() + } + + private cleanup(): void { + for (const disposable of this.disposables) disposable.dispose() + for (const unsubscribe of this.subscriptions) unsubscribe() + this.disposables = [] + this.subscriptions = [] + this.panel = undefined + this.ready = false + this.statuses.clear() + } + + private async connect(): Promise { + try { + await this.connection.connect(this.directory()) + await this.sync(this.statuses.size === 0) + } catch (err) { + this.post({ type: "connectionState", state: "error", error: err instanceof Error ? err.message : String(err) }) + } + } + + private async sync(reconcile: boolean): Promise { + if (!this.ready) return + const info = this.connection.getServerInfo() + if (info) { + const cfg = vscode.workspace.getConfiguration("kilo-code.new") + this.post({ + type: "ready", + serverInfo: info, + extensionVersion: this.extensionVersion, + vscodeLanguage: vscode.env.language, + languageOverride: cfg.get("language"), + fontSize: getWebviewFontSize(), + workspaceDirectory: this.project ?? "", + }) + } + this.post({ type: "connectionState", state: this.connection.getConnectionState() }) + + try { + const client = this.connection.getClient() + await seedSessionStatuses(client, this.directory(), this.statuses, (msg) => this.post(msg), reconcile) + } catch (err) { + console.warn("[Kilo New] Marketplace session status sync failed:", err) + } + } + + private async handle(msg: MarketplaceMessage): Promise { + switch (msg.type) { + case "webviewReady": + this.ready = true + if (this.connection.getConnectionState() === "connected") await this.sync(true) + else await this.connect() + await this.fetchData() + return + case "retryConnection": + await this.connect() + return + case "fetchMarketplaceData": + await this.fetchData() + return + case "installMarketplaceItem": + if (msg.mpItem && msg.mpInstallOptions) await this.install(msg.mpItem, msg.mpInstallOptions) + return + case "removeInstalledMarketplaceItem": + if (msg.mpItem) await this.remove(msg.mpItem, msg.mpInstallOptions?.target ?? "project") + return + case "dismissAgentMigrationBanner": + await this.context.globalState.update("kilo.agentMigrationBannerDismissed", true) + return + case "openExternal": + this.openExternal(msg.url) + return + case "telemetry": + if (msg.event) TelemetryProxy.capture(msg.event as TelemetryEventName, msg.properties) + return + } + } + + private async fetchData(): Promise { + try { + const project = this.project ?? undefined + const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory()) + const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false + this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] Marketplace data fetch failed:", err) + this.post({ + type: "marketplaceData", + marketplaceItems: [], + marketplaceInstalledMetadata: { project: {}, global: {} }, + errors: [error], + }) + } + } + + private async install(item: MarketplaceItem, opts: InstallMarketplaceItemOptions): Promise { + const result = await installMarketplaceItem( + this.marketplaceCtx, + item, + opts, + this.project ?? undefined, + this.directory(), + ) + this.post({ type: "marketplaceInstallResult", ...result }) + } + + private async remove(item: MarketplaceItem, scope: "project" | "global"): Promise { + const result = await removeMarketplaceItem( + this.marketplaceCtx, + item, + scope, + this.project ?? undefined, + this.directory(), + ) + this.post({ type: "marketplaceRemoveResult", ...result }) + } + + private handleStatus(event: Extract): void { + const sid = event.properties.sessionID + this.statuses.set(sid, event.properties.status.type) + const msg = mapSSEEventToWebviewMessage(event, sid) + if (msg) this.post(msg) + } + + private setProjectDirectory(project: string | null): void { + if (this.project === project) return + this.project = project + this.post({ type: "workspaceDirectoryChanged", directory: project ?? "" }) + } + + private resolveProject(): string | null { + const editor = vscode.window.activeTextEditor + const active = + editor?.document.uri.scheme === "file" + ? vscode.workspace.getWorkspaceFolder(editor.document.uri)?.uri.fsPath + : undefined + return resolvePanelProjectDirectory(active, vscode.workspace.workspaceFolders) + } + + private directory(): string { + return this.project ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir() + } + + private openExternal(raw: unknown): void { + if (typeof raw !== "string") return + const uri = vscode.Uri.parse(raw) + if (uri.scheme !== "http" && uri.scheme !== "https") return + void vscode.env.openExternal(uri) + } + + private post(msg: unknown): void { + if (!this.panel || !this.ready) return + void this.panel.webview.postMessage(msg).then(undefined, (err) => { + console.warn("[Kilo New] Marketplace panel postMessage failed:", err) + }) + } + + private getHtml(webview: vscode.Webview): string { + return buildWebviewHtml(webview, { + scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "marketplace.js")), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "marketplace.css")), + iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), + title: "Kilo Marketplace", + port: this.connection.getServerInfo()?.port, + }) + } +} diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index a03637d0110..94d2536dfd2 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -4,17 +4,16 @@ import { resolvePanelProjectDirectory } from "./project-directory" import type { KiloConnectionService } from "./services/cli-backend" import type { RemoteStatusService } from "./services/RemoteStatusService" -type PanelView = "settings" | "profile" | "marketplace" | "indexing" +type PanelView = "settings" | "profile" | "indexing" const PANEL_TITLES: Record = { settings: "Kilo Settings", profile: "Kilo Profile", - marketplace: "Kilo Marketplace", indexing: "Codebase Indexing", } /** - * Opens Settings, Profile, or Marketplace as an editor-area WebviewPanel, + * Opens Settings or Profile as an editor-area WebviewPanel, * keeping the sidebar chat undisturbed. * * Each view type is a singleton panel — calling openPanel() again @@ -54,10 +53,10 @@ export class SettingsEditorProvider implements vscode.Disposable { return view } - openPanel(view: PanelView, tab?: string, directory?: string | null): void { + openPanel(view: PanelView, tab?: string): void { if (tab) this.tabs.set(view, tab) - const projectDirectory = directory ?? this.getProjectDirectory() + const projectDirectory = this.getProjectDirectory() const existing = this.panels.get(view) if (existing) { this.providers.get(view)?.setProjectDirectory(projectDirectory) diff --git a/packages/kilo-vscode/src/SubAgentViewerProvider.ts b/packages/kilo-vscode/src/SubAgentViewerProvider.ts index 277a2b606b7..e1693cc18bb 100644 --- a/packages/kilo-vscode/src/SubAgentViewerProvider.ts +++ b/packages/kilo-vscode/src/SubAgentViewerProvider.ts @@ -42,32 +42,30 @@ export class SubAgentViewerProvider implements vscode.Disposable { } const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context) + // Start accepting this session's SSE events as soon as the panel subscribes. + // Reasoning deltas are not persisted until the reasoning part finishes. + provider.trackSession(sessionID) provider.resolveWebviewPanel(panel) - // Once the webview is ready, fetch the session and display it in read-only mode. - const readyDisposable = panel.webview.onDidReceiveMessage(async (msg) => { + // Navigate immediately when the webview is ready, then load metadata and + // the same paginated, row-virtualized transcript used by normal sessions. + const readyDisposable = panel.webview.onDidReceiveMessage((msg) => { if (msg.type !== "webviewReady") return readyDisposable.dispose() - // Small delay to let KiloProvider's own webviewReady handler finish first - await new Promise((resolve) => setTimeout(resolve, 50)) + provider.postMessage({ type: "viewSubAgentSession", sessionID }) + void provider.loadMessages(sessionID) try { const client = this.connectionService.getClient() - const { data: session } = await client.session.get({ sessionID }, { throwOnError: true }) - - // Register the session on the provider — this adds it to - // trackedSessionIds for live SSE updates and sends - // sessionCreated to the webview. - provider.registerSession(session) - - // Fetch the newest page before navigating so the tab opens on the latest turn. - await provider.loadMessages(sessionID) - - // Navigate to the sub-agent viewer - provider.postMessage({ type: "viewSubAgentSession", sessionID }) + void client.session + .get({ sessionID }, { throwOnError: true }) + .then(({ data: session }) => provider.registerSession(session)) + .catch((err: unknown) => { + console.error("[Kilo New] SubAgentViewerProvider: Failed to load session metadata:", err) + }) } catch (err) { - console.error("[Kilo New] SubAgentViewerProvider: Failed to load session:", err) + console.error("[Kilo New] SubAgentViewerProvider: Failed to load session metadata:", err) } }) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d14dcbaf8ec..81dbc54dabf 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -31,10 +31,12 @@ import { WorktreeDiffController } from "./worktree-diff-controller" import { WorktreeImporter } from "./worktree-importer" import { recordPromotionHandoff } from "./promotion-handoff" import { restoreWorktrees } from "./state-recovery" -import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff" +import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff" import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" import { stopSessionProcesses } from "../kilo-provider/background-process" +import { startSession } from "./mcp-warmup" +import { readTerminalFont, watchTerminalFont } from "./terminal-font" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { Semaphore } from "./semaphore" @@ -68,9 +70,11 @@ export class AgentManagerProvider implements Disposable { private gitOps: GitOps private diffs: WorktreeDiffController private staleWorktreeIds = new Set() + private toolRequests = new Set() private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined private unsubTool: (() => void) | undefined + private unsubFont: (() => void) | undefined private closing: Promise | undefined private onVisibilityChange: ((visible: boolean) => void) | undefined @@ -94,6 +98,10 @@ export class AgentManagerProvider implements Disposable { getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path, log: (...args) => this.log("[XTerm]", ...args), post: (msg) => this.postToWebview(msg), + getTerminalFont: () => readTerminalFont(), + }) + this.unsubFont = watchTerminalFont((font) => { + this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) }) this.run = new RunController({ root: () => this.getRoot(), @@ -118,13 +126,14 @@ export class AgentManagerProvider implements Disposable { }) const semaphore = new Semaphore(3) this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) + const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, git: this.gitOps, - localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), - localDiffFile: (dir, base, file) => localDiffFile(this.gitOps, dir, base, file, (...args) => this.log(...args)), + localDiff: local.summary, + localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), }) @@ -240,6 +249,9 @@ export class AgentManagerProvider implements Disposable { this.statsPoller.stop() this.prBridge.poller.stop() this.diffs.stop() + this.activeSessionId = undefined + this.connectionService.unregisterFocused("agent-manager") + this.connectionService.registerOpen("agent-manager", []) this.panel = undefined this.onVisibilityChange?.(false) } @@ -810,9 +822,11 @@ export class AgentManagerProvider implements Disposable { }) try { - const { data: session } = await client.session.create( - { directory: worktreePath, platform: PLATFORM }, - { throwOnError: true }, + const { data: session } = await startSession( + client, + worktreePath, + () => client.session.create({ directory: worktreePath, platform: PLATFORM }, { throwOnError: true }), + (...args) => this.log(...args), ) return session } catch (error) { @@ -910,6 +924,13 @@ export class AgentManagerProvider implements Disposable { openPanel: (preserveFocus) => this.openPanel(preserveFocus), waitReady: (context) => this.waitForStateReady(context), createWorktree: (opts) => this.createWorktreeOnDisk(opts), + claimRequest: (id) => { + if (this.toolRequests.has(id)) return false + const oldest = this.toolRequests.size >= 100 ? this.toolRequests.values().next().value : undefined + if (oldest) this.toolRequests.delete(oldest) + this.toolRequests.add(id) + return true + }, cleanupWorktree: async (wid, dir) => { this.getStateManager()?.removeWorktree(wid) await this.getWorktreeManager()?.removeWorktree(dir) @@ -1152,6 +1173,7 @@ export class AgentManagerProvider implements Disposable { { getClient: () => this.connectionService.getClient(), state: this.getStateManager(), + directory: this.getRoot(), postError: (msg) => this.postToWebview({ type: "error", message: msg }), registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), pushState: () => this.pushState(), @@ -1727,6 +1749,18 @@ export class AgentManagerProvider implements Disposable { getClient: () => this.connectionService.getClient(), createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id), + cleanupWorktree: async (id) => { + await this.onDeleteWorktree(id) + }, + notifyError: (error, result, id) => { + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "error", + message: error, + branch: result.branch, + worktreeId: id, + }) + }, getStateManager: () => this.getStateManager(), registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), registerSession: (session) => this.panel?.sessions.registerSession(session), @@ -1779,6 +1813,7 @@ export class AgentManagerProvider implements Disposable { await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err)) await this.state?.flush().catch((err) => this.log("dispose: state flush failed:", err)) this.unsubTool?.() + this.unsubFont?.() this.connectionService.unregisterFocused("agent-manager") this.connectionService.registerOpen("agent-manager", []) this.diffs.stop() diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index c490a576411..92a2984b5a9 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -48,6 +48,12 @@ export interface ExecResult { stderr: string } +export interface ExecBufferResult { + code: number + stdout: Buffer + stderr: string +} + /** * Fixed SSH command injected by {@link nonInteractiveEnv} when the user has * not already configured their own. Exported so callers can check whether a @@ -532,12 +538,21 @@ export class GitOps { return this.exec(args, cwd, options) } - private exec(args: string[], cwd: string, options?: ExecOptions): Promise { + execGitBuffer(args: string[], cwd: string): Promise { + return this.execBuffer(args, cwd) + } + + private async exec(args: string[], cwd: string, options?: ExecOptions): Promise { + const result = await this.execBuffer(args, cwd, options) + return { code: result.code, stdout: result.stdout.toString("utf8"), stderr: result.stderr } + } + + private execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { if (this.controller.signal.aborted) { - return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" }) + return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } const invoke = () => - new Promise((resolve) => { + new Promise((resolve) => { const child = spawn("git", args, { cwd, env: options?.env, @@ -547,7 +562,7 @@ export class GitOps { if (options?.stdin !== undefined) { if (!child.stdin) { - resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" }) + resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "stdin not available for git process" }) return } child.stdin.end(options.stdin) @@ -559,12 +574,12 @@ export class GitOps { child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) child.on("error", (error) => { - resolve({ code: 1, stdout: "", stderr: error.message }) + resolve({ code: 1, stdout: Buffer.alloc(0), stderr: error.message }) }) child.on("close", (code) => { resolve({ code: code ?? 1, - stdout: Buffer.concat(out).toString("utf8"), + stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString("utf8"), }) }) diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 189045d5a76..dbe7aa4867f 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -203,8 +203,10 @@ export class SessionTerminalManager { return result } + const disposable = this.tryRegisterCommand(id, handler) + if (!disposable) return this.commandHandlers.set(id, handler) - this.commandDisposables.set(id, this.host.registerCommand(id, handler)) + this.commandDisposables.set(id, disposable) } private async runOriginalCommand(id: string, args: unknown[]): Promise { @@ -218,9 +220,20 @@ export class SessionTerminalManager { return await this.host.executeCommand(id, ...args) } finally { const handler = this.commandHandlers.get(id) - if (!handler) return - const replacement = this.host.registerCommand(id, handler) - this.commandDisposables.set(id, replacement) + if (handler) { + const replacement = this.tryRegisterCommand(id, handler) + if (replacement) this.commandDisposables.set(id, replacement) + } + } + } + + private tryRegisterCommand(id: string, handler: (...args: unknown[]) => Promise): Disposable | undefined { + try { + return this.host.registerCommand(id, handler) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + this.log(`panel command registration skipped for ${id}: ${msg}`) + return undefined } } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index ae1d77e05d6..2dac4738c93 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -222,32 +222,11 @@ export class WorktreeManager { parentRemote = startPoint.remote } - const sanitized = params.branchName ? sanitizeBranchName(params.branchName) : undefined - let branch: string - if (params.existingBranch) { - branch = params.existingBranch - } else if (sanitized) { - branch = sanitized - } else { - const existing = await this.git - .branch() - .then((b) => b.all) - .catch(() => [] as string[]) - branch = generateBranchName(params.prompt || "agent-task", existing) - } - - if (params.existingBranch) { - const exists = await this.branchExists(branch) - if (!exists) throw new Error(`Branch "${branch}" does not exist`) - } - + let branch = await this.resolveBranch(params) const dirName = branch.replace(/\//g, "-") let worktreePath = path.join(this.dir, dirName) - if (fs.existsSync(worktreePath)) { - this.log(`Worktree directory exists, cleaning up before re-creation: ${worktreePath}`) - await this.removeWorktreeImpl(worktreePath) - } + await this.prepareWorktreePath(worktreePath, !!params.existingBranch) params.onProgress?.("creating", `Creating worktree for ${branch}...`) @@ -270,8 +249,8 @@ export class WorktreeManager { if (!msg.includes("already exists") || params.existingBranch) { throw new Error(`Failed to create worktree: ${msg}`) } - // Branch name collision -- retry with unique suffix - branch = `${branch}-${Date.now()}` + // Another process may create the branch after resolveBranch checks it. + branch = await this.resolveBranch(params) const retryDir = branch.replace(/\//g, "-") worktreePath = path.join(this.dir, retryDir) const retryArgs = params.existingBranch @@ -293,6 +272,46 @@ export class WorktreeManager { } } + private async prepareWorktreePath(worktreePath: string, reuse: boolean): Promise { + if (!fs.existsSync(worktreePath)) return + if (!reuse) throw new Error(`Worktree path already exists: ${worktreePath}`) + this.log(`Worktree directory exists, cleaning up before re-creation: ${worktreePath}`) + await this.removeWorktreeImpl(worktreePath) + } + + private async resolveBranch(params: { + prompt?: string + existingBranch?: string + branchName?: string + }): Promise { + if (params.existingBranch) { + const exists = await this.branchExists(params.existingBranch) + if (!exists) throw new Error(`Branch "${params.existingBranch}" does not exist`) + return params.existingBranch + } + + const existing = await this.git + .branch() + .then((result) => result.all) + .catch(() => [] as string[]) + const sanitized = params.branchName ? sanitizeBranchName(params.branchName) : undefined + const branch = sanitized || generateBranchName(params.prompt || "agent-task", existing) + return this.availableBranch(branch, existing) + } + + private availableBranch(base: string, existing: string[]): string { + const branches = new Set(existing) + const available = (branch: string) => { + const dir = path.join(this.dir, branch.replace(/\//g, "-")) + return !branches.has(branch) && !fs.existsSync(dir) + } + if (available(base)) return base + for (let suffix = 2; ; suffix++) { + const branch = `${base}-${suffix}` + if (available(branch)) return branch + } + } + /** * Run `git worktree add` with post-checkout hook tolerance. * diff --git a/packages/kilo-vscode/src/agent-manager/constants.ts b/packages/kilo-vscode/src/agent-manager/constants.ts index 771ce4172f2..a9592acc85a 100644 --- a/packages/kilo-vscode/src/agent-manager/constants.ts +++ b/packages/kilo-vscode/src/agent-manager/constants.ts @@ -13,6 +13,9 @@ export const MAX_MULTI_VERSIONS = 4 /** Telemetry source identifier for all Agent Manager events. */ export const PLATFORM = "agent-manager" as const +/** Keep baseline snapshots without interrupting concurrently started agents. */ +export const SNAPSHOT_INITIALIZATION = "wait" as const + /** Kilo config directory name (project-level and inside worktrees). */ export const KILO_DIR = ".kilo" diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index e25cb355588..ef4fcdd9226 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -4,6 +4,7 @@ import type { WorktreeStateManager } from "./WorktreeStateManager" import { capture as captureGitState, apply as applyGitState, type GitSnapshot } from "./git-transfer" import { getErrorMessage } from "../kilo-provider-utils" import { PLATFORM } from "./constants" +import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string @@ -13,6 +14,8 @@ export interface ContinueContext { result: CreateWorktreeResult } | null> runSetupScript: (path: string, branch: string, worktreeId: string) => Promise + cleanupWorktree: (worktreeId: string) => Promise + notifyError: (error: string, result: CreateWorktreeResult, worktreeId: string) => void getStateManager: () => WorktreeStateManager | undefined registerWorktreeSession: (sessionId: string, directory: string) => void registerSession: (session: Session) => void @@ -71,6 +74,23 @@ export async function transferState( return { ok: true, value: undefined } } +async function rollback( + ctx: ContinueContext, + prepared: { worktreeId: string; result: CreateWorktreeResult }, + error: string, + progress: (status: string, detail?: string, error?: string) => void, +): Promise { + await ctx.cleanupWorktree(prepared.worktreeId).catch((err) => { + ctx.log("Failed to clean up worktree after continue error:", getErrorMessage(err)) + }) + try { + ctx.notifyError(error, prepared.result, prepared.worktreeId) + } catch (err) { + ctx.log("Failed to notify Agent Manager about continue error:", getErrorMessage(err)) + } + progress("error", undefined, error) +} + /** Fork the session into the worktree directory. */ export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise> { let client: KiloClient @@ -82,6 +102,9 @@ export async function forkSession(ctx: ContinueContext, sessionId: string, dir: } try { const { data } = await client.session.fork({ sessionID: sessionId, directory: dir }, { throwOnError: true }) + await recordForkHandoff({ client, sessionId: data.id, directory: dir }).catch((err) => { + ctx.log("Failed to record fork handoff:", getErrorMessage(err)) + }) return { ok: true, value: data } } catch (err) { return { ok: false, error: `Failed to fork session: ${getErrorMessage(err)}` } @@ -132,7 +155,7 @@ export async function continueInWorktree( progress("transferring", "Transferring changes...") const transferred = await transferState(ctx, captured.value, prepared.value.result.path) - if (!transferred.ok) return progress("error", undefined, transferred.error) + if (!transferred.ok) return rollback(ctx, prepared.value, transferred.error, progress) progress("forking", "Starting session...") const forked = await forkSession(ctx, sessionId, prepared.value.result.path) diff --git a/packages/kilo-vscode/src/agent-manager/fork-handoff.ts b/packages/kilo-vscode/src/agent-manager/fork-handoff.ts new file mode 100644 index 00000000000..efbf36361c9 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/fork-handoff.ts @@ -0,0 +1,41 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export interface ForkHandoffInput { + client: KiloClient + sessionId: string + directory?: string +} + +export function forkText(input: Pick): string { + return [ + "", + "This session was forked from an existing session in the current repository or worktree.", + ...(input.directory + ? [ + `Use this as the current working directory: ${input.directory}`, + "For this fork, this location supersedes any earlier repository or worktree location retained in the copied context.", + ] + : []), + "The prior conversation context was retained intentionally.", + "The user may continue the same task, explore an alternative approach, or provide new instructions.", + "Follow the user's next instruction as the direction for this fork, using retained context when relevant.", + "", + ].join("\n") +} + +export async function recordForkHandoff(input: ForkHandoffInput): Promise { + const payload = { + sessionID: input.sessionId, + ...(input.directory ? { directory: input.directory } : {}), + noReply: true, + parts: [ + { + type: "text", + text: forkText(input), + synthetic: true, + }, + ], + } as Parameters[0] + + await input.client.session.promptAsync(payload, { throwOnError: true }) +} diff --git a/packages/kilo-vscode/src/agent-manager/fork-session.ts b/packages/kilo-vscode/src/agent-manager/fork-session.ts index c78e8086b14..d6d4118c6aa 100644 --- a/packages/kilo-vscode/src/agent-manager/fork-session.ts +++ b/packages/kilo-vscode/src/agent-manager/fork-session.ts @@ -3,10 +3,12 @@ import { getErrorMessage } from "../kilo-provider-utils" import { TelemetryProxy, TelemetryEventName } from "../services/telemetry" import type { WorktreeStateManager } from "./WorktreeStateManager" import { PLATFORM } from "./constants" +import { recordForkHandoff } from "./fork-handoff" export interface ForkContext { getClient: () => KiloClient state: WorktreeStateManager | undefined + directory: string | undefined postError: (message: string) => void registerWorktreeSession: (sessionId: string, directory: string) => void pushState: () => void @@ -37,8 +39,8 @@ export async function forkSession( } const directory = (() => { - if (!worktreeId || !ctx.state) return undefined - return ctx.state.getWorktree(worktreeId)?.path + if (!worktreeId || !ctx.state) return ctx.directory + return ctx.state.getWorktree(worktreeId)?.path ?? ctx.directory })() let forked: Session @@ -63,6 +65,10 @@ export async function forkSession( if (directory) ctx.registerWorktreeSession(forked.id, directory) } + await recordForkHandoff({ client, sessionId: forked.id, directory }).catch((err) => { + ctx.log("forkSession: failed to record fork handoff:", getErrorMessage(err)) + }) + ctx.pushState() ctx.notifyForked(forked, sessionId, worktreeId) ctx.registerSession(forked) diff --git a/packages/kilo-vscode/src/agent-manager/format-keybinding.ts b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts index daa15a3af59..c6a4b05f150 100644 --- a/packages/kilo-vscode/src/agent-manager/format-keybinding.ts +++ b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts @@ -67,6 +67,7 @@ export function buildKeybindingMap( // Ensure fallback bindings are always present (may be missing from // cached packageJSON if the extension hasn't been fully reloaded) + if (!bindings.search) bindings.search = formatKeybinding(mac ? "cmd+f" : "ctrl+f", mac) if (!bindings.runScript) bindings.runScript = formatKeybinding(mac ? "cmd+e" : "ctrl+e", mac) if (!bindings.toggleDiff) bindings.toggleDiff = formatKeybinding(mac ? "cmd+d" : "ctrl+d", mac) if (!bindings.showShortcuts) bindings.showShortcuts = formatKeybinding(mac ? "cmd+shift+/" : "ctrl+shift+/", mac) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index c58189dace2..e643e866270 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -1,5 +1,7 @@ import * as fs from "fs/promises" -import * as path from "path" +import { binaryFile } from "../diff/shared/binary" +import { imageMime, loadImage, readImageFile } from "../diff/shared/image" +import { resolveInside } from "../diff/shared/path" import type { GitOps } from "./GitOps" import type { WorktreeDiffEntry } from "./types" @@ -12,6 +14,7 @@ type Meta = { status: Status tracked: boolean generatedLike: boolean + binary: boolean stamp: string } @@ -118,7 +121,7 @@ async function numstat(git: GitOps, dir: string, base: string, file?: string) { const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] if (file) args.push("--", file) const result = await git.execGit(args, dir) - const map = new Map() + const map = new Map() if (result.code !== 0) return map for (const line of result.stdout.trim().split("\n")) { if (!line) continue @@ -130,22 +133,27 @@ async function numstat(git: GitOps, dir: string, base: string, file?: string) { map.set(name, { additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, + binary: add === "-" || del === "-", }) } return map } async function statStamp(dir: string, file: string): Promise { - const stat = await fs.stat(path.join(dir, file)).catch(() => undefined) + const full = resolveInside(dir, file) + if (!full) return `missing:${file}` + const stat = await fs.lstat(full).catch(() => undefined) if (!stat) return `missing:${file}` return `${stat.size}:${stat.mtimeMs}` } async function lineCount(file: string): Promise { - const stat = await fs.stat(file).catch(() => undefined) + const stat = await fs.lstat(file).catch(() => undefined) if (!stat || stat.size === 0) return 0 if (stat.size > MAX_UNTRACKED_BYTES) return 0 - const content = await fs.readFile(file, "utf-8").catch(() => "") + const content = stat.isSymbolicLink() + ? await fs.readlink(file).catch(() => "") + : await fs.readFile(file, "utf-8").catch(() => "") if (!content) return 0 if (content.endsWith("\n")) return content.split("\n").length - 1 return content.split("\n").length @@ -179,7 +187,7 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise undefined) + const full = resolveInside(dir, file) + if (!full) continue + const exists = await fs.lstat(full).catch(() => undefined) if (!exists) continue + const binary = await binaryFile(full) result.push({ file, - additions: await lineCount(full), + additions: binary ? 0 : await lineCount(full), deletions: 0, status: "added", tracked: false, generatedLike: generatedLike(file), + binary, stamp: await statStamp(dir, file), }) } @@ -220,6 +233,7 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise }>() + + return { + summary: async (dir: string, base: string): Promise => { + const id = `${dir}\0${base}` + const anc = await ancestor(git, dir, base, log) + if (!anc) { + states.delete(id) + return [] + } + + const items = await list(git, dir, anc, log) + states.delete(id) + states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) }) + if (states.size > 8) states.delete(states.keys().next().value!) + return items.map(summarize) + }, + file: async (dir: string, base: string, file: string): Promise => { + const state = states.get(`${dir}\0${base}`) + if (!state) return diffFile(git, dir, base, file, log) + const meta = state.metas.get(file) + if (!meta) return null + return materialize(git, dir, state.anc, meta, log) + }, + } +} + async function detailMeta(git: GitOps, dir: string, anc: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return undefined const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir) if (tracked.code !== 0) { - const full = path.join(dir, file) - const exists = await fs.stat(full).catch(() => undefined) + const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard", "--", file], dir) + if (untracked.code !== 0 || !untracked.stdout.split("\n").includes(file)) return undefined + const exists = await fs.lstat(full).catch(() => undefined) if (!exists) return undefined + const binary = await binaryFile(full) return { file, - additions: await lineCount(full), + additions: binary ? 0 : await lineCount(full), deletions: 0, status: "added", tracked: false, generatedLike: generatedLike(file), + binary, stamp: await statStamp(dir, file), } } @@ -278,7 +326,7 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): if (!code) return undefined const counts = await numstat(git, dir, anc, file) - const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0 } + const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0, binary: false } const status = statusFromCode(code) return { file: pathPart, @@ -287,7 +335,11 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): status, tracked: true, generatedLike: generatedLike(pathPart), - stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, pathPart), + binary: stat.binary, + stamp: + status === "deleted" + ? `deleted:${anc}` + : `${imageMime(pathPart) ? `${anc}:` : ""}${await statStamp(dir, pathPart)}`, } } @@ -298,10 +350,25 @@ async function blobSize(git: GitOps, dir: string, anc: string, file: string): Pr } async function fileSize(dir: string, file: string): Promise { - const stat = await fs.stat(path.join(dir, file)).catch(() => undefined) + const full = resolveInside(dir, file) + if (!full) return 0 + const stat = await fs.lstat(full).catch(() => undefined) return stat?.size ?? 0 } +async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise { + const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir) + return result.code === 0 ? result.stdout : undefined +} + +async function readFile(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return undefined + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat?.isFile()) return undefined + return readImageFile(full) +} + async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { if (status === "added") return "" const result = await git.execGit(["show", `${anc}:${file}`], dir) @@ -310,9 +377,12 @@ async function readBefore(git: GitOps, dir: string, anc: string, file: string, s async function readAfter(dir: string, file: string, status: Status): Promise { if (status === "deleted") return "" - const full = path.join(dir, file) - const exists = await fs.stat(full).catch(() => undefined) - if (!exists) return "" + const full = resolveInside(dir, file) + if (!full) return "" + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) return "" + if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "") + if (!stat.isFile()) return "" return fs.readFile(full, "utf-8").catch(() => "") } @@ -345,12 +415,25 @@ export async function diffFile( if (!anc) return null const meta = await detailMeta(git, dir, anc, file) if (!meta) return null + return materialize(git, dir, anc, meta, log) +} +async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise { + const mime = imageMime(meta.file) + if (meta.binary && !mime) return summarize(meta) + const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) + const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) + if (mime) { + const image = await loadImage( + meta.file, + meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) }, + meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) }, + ) + return { ...summarize(meta), summarized: false, image } + } // Cheap size probe before materializing content — protects the extension // host from OOM on huge tracked files. `git cat-file -s` returns the blob // size without streaming its contents, and `fs.stat` is a plain syscall. - const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) - const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { log?.("diffFile: file too large for detail view, returning summarized entry", { file: meta.file, diff --git a/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts b/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts new file mode 100644 index 00000000000..60f95f836e6 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts @@ -0,0 +1,15 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +type Client = Pick +type Log = (...args: unknown[]) => void + +async function warm(client: Client, dir: string, log: Log): Promise { + log(`[MCPWarmup] Starting for ${dir}`) + await client.mcp.status({ directory: dir }, { throwOnError: true }) + log(`[MCPWarmup] Completed for ${dir}`) +} + +export function startSession(client: Client, dir: string, create: () => Promise, log: Log): Promise { + void warm(client, dir, log).catch((err) => log(`[MCPWarmup] Failed for ${dir}:`, err)) + return create() +} diff --git a/packages/kilo-vscode/src/agent-manager/terminal-font.ts b/packages/kilo-vscode/src/agent-manager/terminal-font.ts new file mode 100644 index 00000000000..474997eaa12 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/terminal-font.ts @@ -0,0 +1,57 @@ +/** + * Helpers to read and watch the user's integrated-terminal font settings. + * + * VS Code's integrated terminal falls back to the editor font family when + * its own family is unset. Font size has a separate platform default. We + * replicate those settings for Agent Manager xterm instances. + */ + +import * as vscode from "vscode" + +export interface TerminalFont { + fontFamily: string + fontSize: number +} + +const FALLBACK = "Menlo, Monaco, 'Courier New', monospace" +const SIZE = process.platform === "darwin" ? 12 : 14 + +export function resolveTerminalFont( + family: string | undefined, + size: number | undefined, + editor: string | undefined, +): TerminalFont { + return { + fontFamily: family?.trim() || editor?.trim() || FALLBACK, + fontSize: size ?? SIZE, + } +} + +/** Resolve the user's integrated-terminal font, mirroring VS Code's own + * family fallback while preserving the terminal's independent size. */ +export function readTerminalFont(): TerminalFont { + const term = vscode.workspace.getConfiguration("terminal.integrated") + const editor = vscode.workspace.getConfiguration("editor") + return resolveTerminalFont( + term.get("fontFamily"), + term.get("fontSize"), + editor.get("fontFamily"), + ) +} + +/** True when a config change affects the effective terminal family or size. */ +export function affectsTerminalFont(e: vscode.ConfigurationChangeEvent): boolean { + return ( + e.affectsConfiguration("terminal.integrated.fontFamily") || + e.affectsConfiguration("terminal.integrated.fontSize") || + e.affectsConfiguration("editor.fontFamily") + ) +} + +/** Subscribe to terminal-font config changes. Returns a cleanup function. */ +export function watchTerminalFont(callback: (font: TerminalFont) => void): () => void { + const sub = vscode.workspace.onDidChangeConfiguration((e) => { + if (affectsTerminalFont(e)) callback(readTerminalFont()) + }) + return () => sub.dispose() +} diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 38082ab51ff..7c8c7e46cc2 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -15,7 +15,7 @@ */ import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types" +import type { AgentManagerInMessage, AgentManagerOutMessage, TerminalFont } from "./types" import { TerminalManager } from "./terminal-manager" interface ServerConfig { @@ -36,6 +36,8 @@ export interface TerminalRoutingDeps { log(...args: unknown[]): void /** Send a message back to the webview. */ post(message: AgentManagerOutMessage): void + /** Return the current terminal font settings. */ + getTerminalFont(): TerminalFont } /** True iff the message belongs to the terminal-tab subsystem. */ @@ -106,6 +108,7 @@ export class TerminalRouter { terminalId: created.terminalId, title: created.title, wsUrl: created.wsUrl, + font: this.deps.getTerminalFont(), }) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index 51b9b0bdcb4..5c7fbb51ca4 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -3,7 +3,7 @@ import { sanitizeBranchName, versionedName } from "./branch-name" import type { CreateWorktreeResult } from "./WorktreeManager" import type { WorktreeStateManager } from "./WorktreeStateManager" import type { PanelContext } from "./host" -import { PLATFORM } from "./constants" +import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" const LABEL_MAX = 28 const PREFIX = new Set(["feat", "fix", "chore", "bug", "issue", "task", "branch"]) @@ -41,6 +41,7 @@ export interface ToolDeps { name?: string label?: string }) => Promise + claimRequest?: (requestID: string) => boolean cleanupWorktree: (wid: string, dir: string) => Promise setup: (dir: string, branch?: string, id?: string) => Promise createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise @@ -103,6 +104,7 @@ async function prompt(client: KiloClient, sid: string, dir: string, task: ToolTa sessionID: sid, directory: dir, parts: [{ type: "text", text: body }], + snapshotInitialization: SNAPSHOT_INITIALIZATION, }, { throwOnError: true }, ) @@ -190,6 +192,11 @@ async function worktree( } export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise { + if (deps.claimRequest && !deps.claimRequest(req.requestID)) { + deps.log(`Agent Manager tool skipped duplicate request ${req.requestID}`) + return + } + deps.openPanel(true) await deps.getPanel()?.waitForReady() await deps.waitReady("startFromTool") diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index f6f312fe466..ed766104333 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -8,12 +8,16 @@ */ import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import type { DiffImage } from "../diff/types" import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager" import type { WorktreeStats, LocalStats } from "./GitStatsPoller" import type { ApplyConflict } from "./GitOps" import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" import type { ExternalWorktreeItem } from "./WorktreeManager" import type { RunStatus } from "./run/manager" +import type { TerminalFont } from "./terminal-font" + +export type { TerminalFont } // --------------------------------------------------------------------------- // Shared payload types @@ -30,6 +34,8 @@ export type WorktreeDiffEntry = SnapshotFileDiff & { generatedLike?: boolean summarized?: boolean stamp?: string + kind?: "image" + image?: DiffImage } // --------------------------------------------------------------------------- @@ -147,6 +153,7 @@ interface TerminalCreatedMessage { terminalId: string title: string wsUrl: string + font: TerminalFont } interface TerminalClosedMessage { @@ -160,6 +167,11 @@ interface TerminalErrorMessage { message: string } +interface TerminalFontChangedMessage { + type: "agentManager.terminal.fontChanged" + font: TerminalFont +} + interface ErrorOutMessage { type: "error" message: string @@ -314,6 +326,7 @@ export type AgentManagerOutMessage = | TerminalCreatedMessage | TerminalClosedMessage | TerminalErrorMessage + | TerminalFontChangedMessage // --------------------------------------------------------------------------- // Webview → Extension messages (onMessage) diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 54e2b6ce5b3..8517fc4a0a4 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -9,12 +9,13 @@ import * as vscode from "vscode" import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host" import type { KiloConnectionService } from "../services/cli-backend" import { KiloProvider } from "../KiloProvider" -import { PLATFORM } from "./constants" +import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" import { DiffVirtualProvider } from "../DiffVirtualProvider" import { buildWebviewHtml } from "../utils" import { openFileInEditor, getWorkspaceRoot } from "../review-utils" import { TelemetryProxy, type TelemetryEventName } from "../services/telemetry" import type { AutoApproveController } from "../commands/toggle-auto-approve" +import type { RemoteStatusService } from "../services/RemoteStatusService" export class VscodeHost implements Host { private diffVirtual: DiffVirtualProvider | undefined @@ -24,6 +25,7 @@ export class VscodeHost implements Host { private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, private readonly context: vscode.ExtensionContext, + private readonly remoteService: RemoteStatusService, ) {} setDiffVirtualProvider(provider: DiffVirtualProvider): void { @@ -84,21 +86,28 @@ export class VscodeHost implements Host { scriptUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "agent-manager.js")), styleUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "agent-manager.css")), iconsBaseUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), title: "Agent Manager", port, }) const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { platform: PLATFORM, + snapshotInitialization: SNAPSHOT_INITIALIZATION, slimEditMetadata: true, worktreeDirectories: () => opts.worktreeDirectories?.() ?? [], }) if (this.diffVirtual) { provider.setDiffVirtualProvider(this.diffVirtual) } + provider.setRemoteService(this.remoteService) provider.attachToWebview(panel.webview, { onBeforeMessage: opts.onBeforeMessage, }) + provider.setStreamVisibility(panel.active && panel.visible) + const streams = panel.onDidChangeViewState((event) => + provider.setStreamVisibility(event.webviewPanel.active && event.webviewPanel.visible), + ) if (this.autoApprove) provider.setAutoApproveController(this.autoApprove) const sessions: SessionProvider = { @@ -147,6 +156,7 @@ export class VscodeHost implements Host { return panel.onDidDispose(cb) }, dispose() { + streams.dispose() provider.dispose() panel.dispose() }, diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 79694070a7e..75d538cc35c 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -5,6 +5,7 @@ import type { DiffFile } from "../diff/types" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" +import { Semaphore } from "./semaphore" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" @@ -33,6 +34,7 @@ export interface WorktreeDiffControllerContext { export class WorktreeDiffController { private readonly controller: SourceController + private readonly details = new Semaphore(3) private target: Target | undefined private applying: string | undefined @@ -251,15 +253,17 @@ export class WorktreeDiffController { private async fetchFile(sessionId: string, file: string): Promise { await this.ready("stateReady rejected, continuing diff detail resolve:") - const target = await this.ensureTarget(sessionId) - if (!target) return null - - try { - return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) - return null - } + return this.details.run(async () => { + const target = await this.ensureTarget(sessionId) + if (!target) return null + + try { + return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null + } catch (error) { + this.ctx.log("Failed to fetch worktree diff file:", error) + return null + } + }) } private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { diff --git a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts index f553c430341..a94873fc219 100644 --- a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts +++ b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import type { KiloClient, Event } from "@kilocode/sdk/v2/client" +import type { Event, KiloClient } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend/connection-service" /** @@ -13,9 +13,11 @@ export type DirectoryResolver = (sessionId?: string) => string * (workspace root + all registered worktree paths). */ export type AllDirectories = () => string[] +type Asked = Extract export interface AutoApproveController { active(): boolean + approve(event: Asked, directory?: string): Promise toggle(): Promise onChange(listener: (active: boolean) => void): { dispose(): void } } @@ -26,9 +28,9 @@ const KEY = "enabled" /** * Runtime auto-accept toggle for permissions. * - * Instead of writing to the config file, we intercept `permission.asked` SSE - * events and auto-reply "once" to each. This avoids config-layer issues - * (merged vs global, sparse defaults) and works even when the sidebar is closed. + * Instead of writing to the CLI config, the attention coordinator delegates + * `permission.asked` events here and auto-replies "once". This avoids config-layer + * issues (merged vs global, sparse defaults) and works even when the sidebar is closed. */ export function registerToggleAutoApprove( context: vscode.ExtensionContext, @@ -71,9 +73,11 @@ export function registerToggleAutoApprove( const { data: pending } = await client.permission.list({ directory: dir }, { throwOnError: true }) for (const req of pending) { if (generation !== snapshot) break - await client.permission.reply({ requestID: req.id, directory: dir, reply: "once" }).catch((err) => { - console.error("[Kilo New] toggleAutoApprove: failed to drain pending:", err) - }) + await client.permission + .reply({ requestID: req.id, directory: dir, reply: "once" }, { throwOnError: true }) + .catch((err) => { + console.error("[Kilo New] toggleAutoApprove: failed to drain pending:", err) + }) } } catch (err) { console.error("[Kilo New] toggleAutoApprove: failed to list pending permissions:", err) @@ -83,18 +87,23 @@ export function registerToggleAutoApprove( return active } - const unsubscribe = connectionService.onEvent((event: Event) => { - if (!active) return - if (event.type !== "permission.asked") return + const approve = async (event: Asked, directory?: string) => { + if (!active) return false const client = tryGetClient(connectionService) - if (!client) return - const dir = resolve(event.properties.sessionID) - client.permission.reply({ requestID: event.properties.id, directory: dir, reply: "once" }).catch((err) => { - console.error("[Kilo New] toggleAutoApprove: failed to auto-reply:", err) - }) - }) - - context.subscriptions.push({ dispose: unsubscribe }) + if (!client) return false + const dir = + directory ?? connectionService.getPermissionDirectory(event.properties.id) ?? resolve(event.properties.sessionID) + return client.permission + .reply({ requestID: event.properties.id, directory: dir, reply: "once" }, { throwOnError: true }) + .then( + () => true, + (err) => { + console.error("[Kilo New] toggleAutoApprove: failed to auto-reply:", err) + return false + }, + ) + } + context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { if (!event.affectsConfiguration(`${CONFIG}.${KEY}`)) return @@ -110,6 +119,7 @@ export function registerToggleAutoApprove( return { active: () => active, + approve, toggle, onChange(listener) { listeners.add(listener) diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts index 476cdb92034..7f773cafcfa 100644 --- a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -238,6 +238,7 @@ export class DiffViewerProvider implements vscode.Disposable { scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.js")), styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.css")), iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), title: "Changes", port: this.connection.getServerInfo()?.port, extraStyles: "#root { display: flex; flex-direction: column; }", diff --git a/packages/kilo-vscode/src/diff/shared/binary.ts b/packages/kilo-vscode/src/diff/shared/binary.ts new file mode 100644 index 00000000000..43875e35af6 --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/binary.ts @@ -0,0 +1,29 @@ +import * as fs from "fs/promises" + +const SAMPLE_BYTES = 8_192 + +function binary(bytes: Uint8Array): boolean { + if (bytes.length === 0) return false + + let controls = 0 + for (const byte of bytes) { + if (byte === 0) return true + if (byte < 9 || (byte > 13 && byte < 32)) controls++ + } + return controls / bytes.length > 0.3 +} + +export async function binaryFile(file: string): Promise { + const stat = await fs.lstat(file).catch(() => undefined) + if (!stat?.isFile()) return false + + const handle = await fs.open(file, "r").catch(() => undefined) + if (!handle) return false + const sample = Buffer.alloc(SAMPLE_BYTES) + const read = await handle + .read(sample, 0, sample.length, 0) + .catch(() => undefined) + .finally(() => handle.close()) + if (!read) return false + return binary(sample.subarray(0, read.bytesRead)) +} diff --git a/packages/kilo-vscode/src/diff/shared/image.ts b/packages/kilo-vscode/src/diff/shared/image.ts new file mode 100644 index 00000000000..6b3a887f152 --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/image.ts @@ -0,0 +1,69 @@ +import { createReadStream } from "fs" +import * as path from "path" +import type { DiffImage, DiffImageSide } from "../types" + +const MIMES: Record = { + ".avif": "image/avif", + ".bmp": "image/bmp", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpe": "image/jpeg", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + +export const MAX_IMAGE_BYTES = 5_000_000 + +export interface DiffImageSource { + bytes: number + read: () => Promise +} + +export function imageMime(file: string): string | undefined { + return MIMES[path.extname(file).toLowerCase()] +} + +export function encodeImageSide(mime: string, data: Buffer | undefined, bytes = data?.byteLength ?? 0): DiffImageSide { + if (bytes > MAX_IMAGE_BYTES) return { mime, bytes, error: "too-large" } + if (!data || data.byteLength === 0) return { mime, bytes, error: "unreadable" } + if (data.byteLength > MAX_IMAGE_BYTES) return { mime, bytes: data.byteLength, error: "too-large" } + return { mime, bytes: data.byteLength, data: data.toString("base64") } +} + +export function readImageFile(file: string): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = [] + let bytes = 0 + const stream = createReadStream(file, { end: MAX_IMAGE_BYTES, highWaterMark: 64 * 1024 }) + stream.on("data", (chunk) => { + const data = typeof chunk === "string" ? Buffer.from(chunk) : chunk + chunks.push(data) + bytes += data.byteLength + }) + stream.on("error", () => resolve(undefined)) + stream.on("end", () => resolve(Buffer.concat(chunks, bytes))) + }) +} + +async function load(mime: string, source: DiffImageSource): Promise { + if (source.bytes > MAX_IMAGE_BYTES) return { mime, bytes: source.bytes, error: "too-large" } + const data = await source.read().catch(() => undefined) + return encodeImageSide(mime, data, source.bytes) +} + +export async function loadImage( + file: string, + before?: DiffImageSource, + after?: DiffImageSource, +): Promise { + const mime = imageMime(file) + if (!mime) return undefined + const [left, right] = await Promise.all([ + before ? load(mime, before) : undefined, + after ? load(mime, after) : undefined, + ]) + return { before: left, after: right } +} diff --git a/packages/kilo-vscode/src/diff/shared/path.ts b/packages/kilo-vscode/src/diff/shared/path.ts new file mode 100644 index 00000000000..3d0f9d7e0df --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/path.ts @@ -0,0 +1,9 @@ +import * as path from "path" + +export function resolveInside(dir: string, file: string): string | undefined { + if (path.isAbsolute(file)) return undefined + const full = path.resolve(dir, file) + const base = path.resolve(dir) + if (full !== base && !full.startsWith(base + path.sep)) return undefined + return full +} diff --git a/packages/kilo-vscode/src/diff/sources/git-status.ts b/packages/kilo-vscode/src/diff/sources/git-status.ts index f2463cf0606..b6844af9aa1 100644 --- a/packages/kilo-vscode/src/diff/sources/git-status.ts +++ b/packages/kilo-vscode/src/diff/sources/git-status.ts @@ -2,9 +2,10 @@ // off `git diff` variants and need the same name-status + numstat stitching. import * as fs from "fs/promises" -import * as path from "path" import type { GitOps } from "../../agent-manager/GitOps" import { generatedLike } from "../../agent-manager/local-diff" +import { imageMime, readImageFile } from "../shared/image" +import { resolveInside } from "../shared/path" import type { DiffFile } from "../types" export { MAX_DETAIL_BYTES } from "../../agent-manager/local-diff" @@ -17,6 +18,7 @@ export interface FileEntry { additions: number deletions: number tracked: boolean + binary: boolean stamp?: string } @@ -35,16 +37,30 @@ export function parseNameStatus(stdout: string): { file: string; status: Status } /** Parse `git diff --numstat` output into a per-file `{additions, deletions}` map. */ -export function parseNumstat(stdout: string): Map { - const map = new Map() +export function parseNumstat(stdout: string): Map { + const map = new Map() for (const line of stdout.split("\n")) { if (!line.trim()) continue const parts = line.split("\t") if (parts.length < 3) continue - const additions = parts[0] === "-" ? 0 : parseInt(parts[0]!, 10) || 0 - const deletions = parts[1] === "-" ? 0 : parseInt(parts[1]!, 10) || 0 + const binary = parts[0] === "-" || parts[1] === "-" + const additions = binary ? 0 : parseInt(parts[0]!, 10) || 0 + const deletions = binary ? 0 : parseInt(parts[1]!, 10) || 0 const file = parts.slice(2).join("\t") - if (file) map.set(file, { additions, deletions }) + if (file) map.set(file, { additions, deletions, binary }) + } + return map +} + +export function parseRawOids(stdout: string): Map { + const map = new Map() + for (const line of stdout.split("\n")) { + const tab = line.indexOf("\t") + if (!line.startsWith(":") || tab < 0) continue + const meta = line.slice(1, tab).split(" ") + const file = line.slice(tab + 1) + if (!file || !meta[2] || !meta[3]) continue + map.set(file, { before: meta[2], after: meta[3] }) } return map } @@ -60,6 +76,7 @@ function statusFromCode(code: string): Status { * are left empty — the controller fetches detail lazily through `fetchFile`. */ export function summarize(entry: FileEntry): DiffFile { + const image = imageMime(entry.file) !== undefined return { file: entry.file, before: "", @@ -69,13 +86,16 @@ export function summarize(entry: FileEntry): DiffFile { status: entry.status, tracked: entry.tracked, generatedLike: generatedLike(entry.file), - summarized: true, + // Binary metadata is complete because no deferred text body exists. + // Images are the exception: their encoded sides load lazily on expansion. + summarized: image || !entry.binary, // Synthetic stamp keyed on the stats we actually polled: any change to // the file's diff produces new additions/deletions, which invalidates // the webview-side cached detail via mergeWorktreeDiffs. Callers can // supply a custom stamp (see `FileEntry.stamp`) when additions/deletions // aren't a reliable change signal — notably untracked files. stamp: entry.stamp ?? `${entry.status}:${entry.additions}:${entry.deletions}`, + kind: image ? "image" : undefined, } } @@ -92,6 +112,11 @@ export async function showBlob(git: GitOps, dir: string, ref: string, file: stri return result.code === 0 ? result.stdout : "" } +export async function showBlobBytes(git: GitOps, dir: string, ref: string, file: string): Promise { + const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir) + return result.code === 0 ? result.stdout : undefined +} + /** * Read a working-tree file off disk, matching git's blob semantics for * symlinks: when the entry is a symlink, return its target string (what git @@ -113,6 +138,14 @@ export async function readDisk(dir: string, file: string): Promise { return fs.readFile(full, "utf-8").catch(() => "") } +export async function readDiskBytes(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return undefined + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat?.isFile()) return undefined + return readImageFile(full) +} + // Used to size-cap detail reads without materializing the blob. Mirrors // local-diff.ts's `blobSize` helper. export async function blobSize(git: GitOps, dir: string, ref: string, file: string): Promise { @@ -121,6 +154,19 @@ export async function blobSize(git: GitOps, dir: string, ref: string, file: stri return parseInt(result.stdout.trim(), 10) || 0 } +export async function blobOid(git: GitOps, dir: string, ref: string, file: string): Promise { + const result = await git.execGit(["rev-parse", "--verify", `${ref}:${file}`], dir) + return result.code === 0 ? result.stdout.trim() : "missing" +} + +export async function diskStamp(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return "missing" + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) return "missing" + return `${stat.size}:${stat.mtimeMs}` +} + /** * Size of the working-tree entry at `file`. Uses `lstat` so symlinks report * the link's own size (length of the target string) instead of resolving to @@ -132,13 +178,3 @@ export async function fileSize(dir: string, file: string): Promise { const stat = await fs.lstat(full).catch(() => undefined) return stat?.size ?? 0 } - -// Rejects absolute paths and any `..` traversal that would escape `dir`. -// Returns the resolved path when safe, `undefined` otherwise. -export function resolveInside(dir: string, file: string): string | undefined { - if (path.isAbsolute(file)) return undefined - const full = path.resolve(dir, file) - const base = path.resolve(dir) - if (full !== base && !full.startsWith(base + path.sep)) return undefined - return full -} diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts index cf10f0e2a2a..58fc6d8bdd4 100644 --- a/packages/kilo-vscode/src/diff/sources/session.ts +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -1,5 +1,7 @@ +import { createHash } from "crypto" import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" import { normalize, text } from "@kilocode/kilo-ui/session-diff" +import { encodeImageSide, imageMime } from "../shared/image" import type { DiffFile } from "../types" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" @@ -36,6 +38,7 @@ export function createSessionDiffSource( ): DiffSource { // Cached across fetches so subsequent polling ticks skip the config lookup. let snapshotsDisabled = false + let cache: { key: string; diffs: DiffFile[] } | undefined return { descriptor: sessionDescriptor(sessionId), @@ -54,28 +57,60 @@ export function createSessionDiffSource( } const raw = await fetch({ sessionID: sessionId, directory: workspaceRoot }) - return { diffs: raw.map(toSessionDiffFile) } + const key = raw.map(fingerprint).join("|") + if (cache?.key === key) return { diffs: cache.diffs } + const diffs = raw.map(toSessionDiffFile) + cache = { key, diffs } + return { diffs } }, } } +function fingerprint(raw: SnapshotFileDiff): string { + const patch = createHash("sha1") + .update(raw.patch ?? "") + .digest("hex") + return [raw.file, raw.status, raw.additions, raw.deletions, patch].join(":") +} + /** * Project a backend `SnapshotFileDiff` onto the `DiffFile` shape the viewer * expects. Shared with `createTurnDiffSource` since both hit the same endpoint. */ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { + const file = raw.file ?? "" + const mime = imageMime(file) // Empty patch means binary or summarized (>256 KB) — normalize() can't - // parse it, so short-circuit to empty strings. - const view = raw.patch === "" ? null : normalize(raw) + // parse it, so short-circuit to empty strings. Binary snapshot images do + // not retain their sides, while text-backed SVG patches can be rebuilt. + const view = raw.patch === "" || (mime && mime !== "image/svg+xml") ? null : normalize(raw) + const before = view ? text(view, "deletions") : "" + const after = view ? text(view, "additions") : "" + const image = (() => { + if (mime === "image/svg+xml" && view) { + return { + before: raw.status === "added" ? undefined : encodeImageSide(mime, Buffer.from(before)), + after: raw.status === "deleted" ? undefined : encodeImageSide(mime, Buffer.from(after)), + } + } + if (mime) return {} + return undefined + })() return { - file: raw.file, - before: view ? text(view, "deletions") : "", - after: view ? text(view, "additions") : "", + file, + before: mime ? "" : before, + after: mime ? "" : after, + patch: mime ? "" : raw.patch, additions: raw.additions, deletions: raw.deletions, status: raw.status, tracked: true, generatedLike: false, - summarized: raw.patch === "", + // A zero-stat empty patch has no text body to fetch; nonzero stats + // indicate a deferred large-file summary. + summarized: !mime && raw.patch === "" && (raw.additions !== 0 || raw.deletions !== 0), + kind: mime ? "image" : undefined, + image, + stamp: mime ? fingerprint(raw) : undefined, } } diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index 9af28badce3..d21a508902b 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -2,15 +2,20 @@ import * as vscode from "vscode" import { GitOps } from "../../agent-manager/GitOps" import { generatedLike } from "../../agent-manager/local-diff" import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import { imageMime, loadImage } from "../shared/image" +import { resolveInside } from "../shared/path" import type { DiffFile } from "../types" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" import { + blobOid, blobSize, INDEX_REF, MAX_DETAIL_BYTES, parseNameStatus, parseNumstat, + parseRawOids, showBlob, + showBlobBytes, summarize, type FileEntry, } from "./git-status" @@ -24,6 +29,11 @@ export const STAGED_DESCRIPTOR: DiffSourceDescriptor = { capabilities: { revert: false, comments: true }, } +function stamp(entry: FileEntry, before: string, after: string): FileEntry { + if (!imageMime(entry.file)) return entry + return { ...entry, stamp: `${entry.status}:${before}:${after}` } +} + /** * Diff between the git index and HEAD — what `git diff --cached` would show. * Polls on the standard interval; revert isn't supported (use `git reset` from @@ -37,23 +47,36 @@ export function createStagedDiffSource(): DiffSource { const root = (): string | undefined => getWorkspaceRoot() const listEntries = async (dir: string): Promise => { - const [nameStatus, numstat] = await Promise.all([ + const [nameStatus, numstat, raw] = await Promise.all([ git.execGit(["-c", "core.quotepath=false", "diff", "--cached", "--name-status", "--no-renames", "HEAD"], dir), git.execGit(["-c", "core.quotepath=false", "diff", "--cached", "--numstat", "--no-renames", "HEAD"], dir), + git.execGit( + ["-c", "core.quotepath=false", "diff", "--cached", "--raw", "--abbrev=64", "--no-renames", "HEAD"], + dir, + ), ]) if (nameStatus.code !== 0) { log("git diff --cached --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) return [] } const counts = parseNumstat(numstat.code === 0 ? numstat.stdout : "") - const items = parseNameStatus(nameStatus.stdout) - return items.map((item) => ({ - file: item.file, - status: item.status, - additions: counts.get(item.file)?.additions ?? 0, - deletions: counts.get(item.file)?.deletions ?? 0, - tracked: true, - })) + const refs = parseRawOids(raw.code === 0 ? raw.stdout : "") + return parseNameStatus(nameStatus.stdout).map((item) => { + const ref = refs.get(item.file) + const entry = { + file: item.file, + status: item.status, + additions: counts.get(item.file)?.additions ?? 0, + deletions: counts.get(item.file)?.deletions ?? 0, + tracked: true, + binary: counts.get(item.file)?.binary ?? false, + } + return stamp( + entry, + item.status === "added" ? "missing" : (ref?.before ?? "missing"), + item.status === "deleted" ? "missing" : (ref?.after ?? "missing"), + ) + }) } return { @@ -72,7 +95,7 @@ export function createStagedDiffSource(): DiffSource { async fetchFile(file: string): Promise { const dir = root() - if (!dir || !file) return null + if (!dir || !file || !resolveInside(dir, file)) return null // Resolve the entry for this single file so we know its status. Reading // both refs blindly would still work, but knowing the status lets us @@ -80,8 +103,23 @@ export function createStagedDiffSource(): DiffSource { const entry = await fileEntry(git, dir, file, log) if (!entry) return null + const mime = imageMime(file) + if (entry.binary && !mime) return summarize(entry) const beforeBytes = entry.status === "added" ? 0 : await blobSize(git, dir, "HEAD", file) const afterBytes = entry.status === "deleted" ? 0 : await blobSize(git, dir, INDEX_REF, file) + if (mime) { + const image = await loadImage( + file, + entry.status === "added" + ? undefined + : { bytes: beforeBytes, read: () => showBlobBytes(git, dir, "HEAD", file) }, + entry.status === "deleted" + ? undefined + : { bytes: afterBytes, read: () => showBlobBytes(git, dir, INDEX_REF, file) }, + ) + return { ...summarize(entry), summarized: false, image } + } + if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { log("Staged detail skipped: file too large", { file, beforeBytes, afterBytes, cap: MAX_DETAIL_BYTES }) return summarize(entry) @@ -90,18 +128,24 @@ export function createStagedDiffSource(): DiffSource { // For added: HEAD has no blob. For deleted: index has no blob. const before = entry.status === "added" ? "" : await showBlob(git, dir, "HEAD", file) const after = entry.status === "deleted" ? "" : await showBlob(git, dir, INDEX_REF, file) + const result = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--cached", "--no-ext-diff", "--no-renames", "HEAD", "--", file], + dir, + ) + const patch = result.code === 0 ? result.stdout : undefined const summarized = before === "" && after === "" && entry.status === "modified" return { file, before, after, + patch, additions: entry.additions, deletions: entry.deletions, status: entry.status, tracked: true, generatedLike: generatedLike(file), summarized, - stamp: `${entry.status}:${entry.additions}:${entry.deletions}`, + stamp: entry.stamp ?? `${entry.status}:${entry.additions}:${entry.deletions}`, } }, @@ -135,11 +179,18 @@ async function fileEntry( dir, ) const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") - return { + const entry = { file: item.file, status: item.status, additions: stats.get(item.file)?.additions ?? 0, deletions: stats.get(item.file)?.deletions ?? 0, tracked: true, + binary: stats.get(item.file)?.binary ?? false, } + if (!imageMime(item.file)) return entry + const [before, after] = await Promise.all([ + item.status === "added" ? "missing" : blobOid(git, dir, "HEAD", item.file), + item.status === "deleted" ? "missing" : blobOid(git, dir, INDEX_REF, item.file), + ]) + return stamp(entry, before, after) } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index 26f1210b928..b4d0c996161 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -3,18 +3,25 @@ import * as vscode from "vscode" import { GitOps } from "../../agent-manager/GitOps" import { generatedLike } from "../../agent-manager/local-diff" import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import { binaryFile } from "../shared/binary" +import { imageMime, loadImage } from "../shared/image" +import { resolveInside } from "../shared/path" import type { DiffFile } from "../types" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" import { + blobOid, blobSize, + diskStamp, fileSize, INDEX_REF, MAX_DETAIL_BYTES, parseNameStatus, parseNumstat, + parseRawOids, readDisk, - resolveInside, + readDiskBytes, showBlob, + showBlobBytes, summarize, type FileEntry, } from "./git-status" @@ -28,6 +35,11 @@ export const UNSTAGED_DESCRIPTOR: DiffSourceDescriptor = { capabilities: { revert: false, comments: true }, } +function stamp(entry: FileEntry, before: string, after: string): FileEntry { + if (!imageMime(entry.file)) return entry + return { ...entry, stamp: `${entry.status}:${before}:${after}` } +} + /** * Diff between the working tree and the index — what `git diff` shows for * tracked files, plus untracked files (treated as fully-added). Read-only; @@ -41,22 +53,33 @@ export function createUnstagedDiffSource(): DiffSource { const root = (): string | undefined => getWorkspaceRoot() const listTracked = async (dir: string): Promise => { - const [nameStatus, numstat] = await Promise.all([ + const [nameStatus, numstat, raw] = await Promise.all([ git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames"], dir), git.execGit(["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames"], dir), + git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--abbrev=64", "--no-renames"], dir), ]) if (nameStatus.code !== 0) { log("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) return [] } const counts = parseNumstat(numstat.code === 0 ? numstat.stdout : "") - return parseNameStatus(nameStatus.stdout).map((item) => ({ - file: item.file, - status: item.status, - additions: counts.get(item.file)?.additions ?? 0, - deletions: counts.get(item.file)?.deletions ?? 0, - tracked: true, - })) + const refs = parseRawOids(raw.code === 0 ? raw.stdout : "") + return Promise.all( + parseNameStatus(nameStatus.stdout).map(async (item) => { + const entry = { + file: item.file, + status: item.status, + additions: counts.get(item.file)?.additions ?? 0, + deletions: counts.get(item.file)?.deletions ?? 0, + tracked: true, + binary: counts.get(item.file)?.binary ?? false, + } + if (!imageMime(item.file)) return entry + const before = item.status === "added" ? "missing" : (refs.get(item.file)?.before ?? "missing") + const after = item.status === "deleted" ? "missing" : await diskStamp(dir, item.file) + return stamp(entry, before, after) + }), + ) } const listUntracked = async (dir: string): Promise => { @@ -82,6 +105,7 @@ export function createUnstagedDiffSource(): DiffSource { additions: 0, deletions: 0, tracked: false, + binary: await binaryFile(full), // Untracked entries always have additions/deletions = 0 (numstat // can't compute them without an index blob), so fold size+mtime // into the stamp. Editing the file changes mtime → the webview @@ -113,15 +137,24 @@ export function createUnstagedDiffSource(): DiffSource { async fetchFile(file: string): Promise { const dir = root() - if (!dir || !file) return null + if (!dir || !file || !resolveInside(dir, file)) return null const entry = await fileEntry(git, dir, file, log) if (!entry) return null + const mime = imageMime(file) + if (entry.binary && !mime) return summarize(entry) - const beforeBytes = !entry.tracked || entry.status === "added" ? 0 : await blobSize(git, dir, INDEX_REF, file) - const afterBytes = entry.status === "deleted" ? 0 : await fileSize(dir, file) - if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { - log("Unstaged detail skipped: file too large", { file, beforeBytes, afterBytes, cap: MAX_DETAIL_BYTES }) + const bytes = await detailBytes(git, dir, file, entry) + const image = await imageDetail(git, dir, file, entry, bytes.before, bytes.after) + if (image) return image + + if (bytes.before > MAX_DETAIL_BYTES || bytes.after > MAX_DETAIL_BYTES) { + log("Unstaged detail skipped: file too large", { + file, + beforeBytes: bytes.before, + afterBytes: bytes.after, + cap: MAX_DETAIL_BYTES, + }) return summarize(entry) } @@ -130,6 +163,10 @@ export function createUnstagedDiffSource(): DiffSource { // after = disk content (or "" for deleted). const before = !entry.tracked || entry.status === "added" ? "" : await showBlob(git, dir, INDEX_REF, file) const after = entry.status === "deleted" ? "" : await readDisk(dir, file) + const result = entry.tracked + ? await git.execGit(["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", "--", file], dir) + : undefined + const patch = result?.code === 0 ? result.stdout : undefined const summarized = before === "" && after === "" && entry.status === "modified" // For untracked added files numstat doesn't return counts, so backfill @@ -139,6 +176,7 @@ export function createUnstagedDiffSource(): DiffSource { file, before, after, + patch, additions, deletions: entry.deletions, status: entry.status, @@ -160,6 +198,31 @@ export function createUnstagedDiffSource(): DiffSource { } } +async function detailBytes(git: GitOps, dir: string, file: string, entry: FileEntry) { + const before = !entry.tracked || entry.status === "added" ? 0 : await blobSize(git, dir, INDEX_REF, file) + const after = entry.status === "deleted" ? 0 : await fileSize(dir, file) + return { before, after } +} + +async function imageDetail( + git: GitOps, + dir: string, + file: string, + entry: FileEntry, + beforeBytes: number, + afterBytes: number, +): Promise { + if (!imageMime(file)) return undefined + const image = await loadImage( + file, + !entry.tracked || entry.status === "added" + ? undefined + : { bytes: beforeBytes, read: () => showBlobBytes(git, dir, INDEX_REF, file) }, + entry.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readDiskBytes(dir, file) }, + ) + return { ...summarize(entry), summarized: false, image } +} + async function fileEntry( git: GitOps, dir: string, @@ -180,13 +243,20 @@ async function fileEntry( dir, ) const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") - return { + const entry = { file: item.file, status: item.status, additions: stats.get(item.file)?.additions ?? 0, deletions: stats.get(item.file)?.deletions ?? 0, tracked: true, + binary: stats.get(item.file)?.binary ?? false, } + if (!imageMime(item.file)) return entry + const [before, after] = await Promise.all([ + item.status === "added" ? "missing" : blobOid(git, dir, INDEX_REF, item.file), + item.status === "deleted" ? "missing" : diskStamp(dir, item.file), + ]) + return stamp(entry, before, after) } } @@ -199,6 +269,8 @@ async function fileEntry( log("Unstaged file rejected: outside workspace", { file }) return undefined } + const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard", "--", file], dir) + if (untracked.code !== 0 || !untracked.stdout.split("\n").includes(file)) return undefined const stat = await fs.lstat(full).catch(() => undefined) if (!stat) { log("Unstaged file not found", { file }) @@ -210,6 +282,7 @@ async function fileEntry( additions: 0, deletions: 0, tracked: false, + binary: await binaryFile(full), stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index fdeea76a44a..1f3d8ee25a4 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -139,15 +139,16 @@ async function resolveOverrideRef( /** * Project a `WorktreeDiffEntry` from `local-diff.ts` onto the `DiffFile` shape - * expected by the diff viewer. Drops `patch` (the webview rebuilds before/after - * for itself) and coerces optional `before`/`after` to empty strings when the - * entry is summarized. + * expected by the diff viewer. Preserve its hunk-bounded `patch` so Pierre can + * parse the git diff directly rather than recomputing a diff from full source + * contents; summarized entries still coerce optional content to empty strings. */ function toDiffFile(entry: WorktreeDiffEntry): DiffFile { return { - file: entry.file, + file: entry.file ?? "", before: entry.before ?? "", after: entry.after ?? "", + patch: entry.patch, additions: entry.additions, deletions: entry.deletions, status: entry.status, @@ -155,5 +156,7 @@ function toDiffFile(entry: WorktreeDiffEntry): DiffFile { generatedLike: entry.generatedLike, summarized: entry.summarized, stamp: entry.stamp, + kind: entry.kind, + image: entry.image, } } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 59dd384f5a4..913d4560a31 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -12,11 +12,27 @@ export interface PanelContext { baseBranchOverride?: string } +export type DiffImageError = "too-large" | "unreadable" + +export interface DiffImageSide { + mime: string + bytes: number + data?: string + error?: DiffImageError +} + +export interface DiffImage { + before?: DiffImageSide + after?: DiffImageSide +} + /** Mirrors `WorktreeFileDiff` in webview-ui/src/types/messages/agent-manager.ts. */ export interface DiffFile { file: string before: string after: string + /** Hunk-bounded unified patch used by Pierre to avoid re-diffing full files. */ + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -24,4 +40,6 @@ export interface DiffFile { generatedLike?: boolean summarized?: boolean stamp?: string + kind?: "image" + image?: DiffImage } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 60e972d82e4..55cd1480d91 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -7,12 +7,14 @@ import { DiffViewerProvider } from "./diff/DiffViewerProvider" import { DiffSourceCatalog } from "./diff/sources/catalog" import { DiffVirtualProvider } from "./DiffVirtualProvider" import { SettingsEditorProvider } from "./SettingsEditorProvider" +import { MarketplacePanelProvider } from "./MarketplacePanelProvider" import { SubAgentViewerProvider } from "./SubAgentViewerProvider" import { EXTENSION_DISPLAY_NAME } from "./constants" import { KiloConnectionService } from "./services/cli-backend" import { registerAutocompleteProvider } from "./services/autocomplete" import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend" import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" +import { AttentionService } from "./services/attention" import { BrowserAutomationService } from "./services/browser-automation" import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" @@ -28,7 +30,6 @@ let shuttingDown = false const RESTORE_KEY = "kilo.workbench.restore" type RestoreState = { - sidebar?: boolean agentManager?: boolean } @@ -36,9 +37,9 @@ const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => { panel.title = title || EXTENSION_DISPLAY_NAME } -// Activated via "onStartupFinished" (package.json) so that commands, code actions, keybindings, -// autocomplete, commit-message generation, and URI deep links all work immediately — without -// requiring the user to open a Kilo sidebar or panel first. The CLI backend is NOT spawned here; +// Activated via "onStartupFinished" and "onUri" (package.json) so that commands, code actions, +// keybindings, autocomplete, commit-message generation, and URI deep links all work immediately — +// without requiring the user to open a Kilo sidebar or panel first. The CLI backend is NOT spawned here; // it starts lazily when a webview connects or when ensureBackendForAutocomplete() triggers it. export function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") @@ -49,10 +50,8 @@ export function activate(context: vscode.ExtensionContext) { // Create shared connection service (one server for all webviews) const connectionService = new KiloConnectionService(context) let restore = context.workspaceState.get(RESTORE_KEY) ?? {} - const closeSidebar = restore.sidebar === false const remember = (patch: RestoreState) => { const next = { ...restore, ...patch } - if (shuttingDown && patch.sidebar === false) next.sidebar = restore.sidebar if (shuttingDown && patch.agentManager === false) next.agentManager = restore.agentManager restore = next void context.workspaceState.update(RESTORE_KEY, restore) @@ -103,9 +102,6 @@ export function activate(context: vscode.ExtensionContext) { }), ) - // Prewarm the CLI backend early so autocomplete is ready before first editor use. - ensureBackendForAutocomplete(connectionService) - for (const folder of vscode.workspace.workspaceFolders ?? []) { void markWorkspace(folder.uri.fsPath, (msg) => console.warn(`[Kilo New] ${msg}`)) } @@ -123,9 +119,7 @@ export function activate(context: vscode.ExtensionContext) { } // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService, context, { - onSidebarVisibilityChange: (visible) => remember({ sidebar: visible }), - }) + const provider = new KiloProvider(context.extensionUri, connectionService, context) provider.setRemoteService(remoteService) // Register the webview view provider for the sidebar. @@ -135,7 +129,6 @@ export function activate(context: vscode.ExtensionContext) { webviewOptions: { retainContextWhenHidden: true }, }), ) - if (closeSidebar) void vscode.commands.executeCommand("workbench.action.closeSidebar") // Ensure Agent Manager navigation keybindings work when a VS Code terminal has focus. // The terminal intercepts all keystrokes unless the command is listed in @@ -150,7 +143,7 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(kiloClawProvider) // Create Agent Manager provider for editor panel - const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context) + const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService) const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService) agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider @@ -184,6 +177,13 @@ export function activate(context: vscode.ExtensionContext) { return [...dirs] }, ) + const attention = new AttentionService(connectionService, { + approve: (event, directory) => autoApprove.approve(event, directory), + }) + + // Prewarm only after all global event consumers are ready. + ensureBackendForAutocomplete(connectionService) + provider.setAutoApproveController(autoApprove) agentManagerHost.setAutoApproveController(autoApprove) @@ -263,17 +263,18 @@ export function activate(context: vscode.ExtensionContext) { agentManagerHost.setDiffVirtualProvider(diffVirtualProvider) context.subscriptions.push(diffVirtualProvider) - // Create settings/profile editor provider (opens in editor area, not sidebar) + // Create standalone editor providers (open in editor area, not sidebar) const settingsEditorProvider = new SettingsEditorProvider(context.extensionUri, connectionService, context) settingsEditorProvider.setRemoteService(remoteService) - context.subscriptions.push(settingsEditorProvider) + const marketplacePanelProvider = new MarketplacePanelProvider(context.extensionUri, connectionService, context) + context.subscriptions.push(settingsEditorProvider, marketplacePanelProvider) // Create sub-agent viewer provider (read-only editor panel for sub-agent sessions) const subAgentViewerProvider = new SubAgentViewerProvider(context.extensionUri, connectionService, context) context.subscriptions.push(subAgentViewerProvider) - // Register serializers so settings/diff/sub-agent panels restore on restart - const settingsViews = ["settingsPanel", "profilePanel", "marketplacePanel"] as const + // Register serializers so standalone panels restore on restart + const settingsViews = ["settingsPanel", "profilePanel"] as const for (const suffix of settingsViews) { context.subscriptions.push( vscode.window.registerWebviewPanelSerializer(`kilo-code.new.${suffix}`, { @@ -285,6 +286,15 @@ export function activate(context: vscode.ExtensionContext) { ) } + context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer(MarketplacePanelProvider.viewType, { + deserializeWebviewPanel(panel: vscode.WebviewPanel) { + marketplacePanelProvider.deserializePanel(panel) + return Promise.resolve() + }, + }), + ) + context.subscriptions.push( vscode.window.registerWebviewPanelSerializer(DiffViewerProvider.viewType, { deserializeWebviewPanel(panel: vscode.WebviewPanel) { @@ -346,8 +356,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManagerOpen", () => { agentManagerProvider.openPanel() }), - vscode.commands.registerCommand("kilo-code.new.marketplaceButtonClicked", (directory?: string) => { - settingsEditorProvider.openPanel("marketplace", undefined, directory) + vscode.commands.registerCommand("kilo-code.new.marketplaceButtonClicked", (directory?: string | null) => { + marketplacePanelProvider.openPanel(directory) }), vscode.commands.registerCommand("kilo-code.new.kiloClawOpen", () => { kiloClawProvider.openPanel() @@ -380,7 +390,7 @@ export function activate(context: vscode.ExtensionContext) { }), // legacy-migration start vscode.commands.registerCommand("kilo-code.new.openMigrationWizard", () => { - provider.postMessage({ type: "migrationState", needed: true }) + provider.postMessage({ type: "migrationState", needed: true, source: "legacy" }) }), // legacy-migration end vscode.commands.registerCommand("kilo-code.new.generateTerminalCommand", async () => { @@ -428,6 +438,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => { agentManagerProvider.postMessage({ type: "action", action: "tabNext" }) }), + vscode.commands.registerCommand("kilo-code.new.agentManager.search", () => { + agentManagerProvider.postMessage({ type: "action", action: "search" }) + }), vscode.commands.registerCommand("kilo-code.new.agentManager.showTerminal", () => { // Route through the webview so it can reach into the active session // state and open the VS Code integrated terminal for it. @@ -458,6 +471,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.openWorktree", () => { agentManagerProvider.postMessage({ type: "action", action: "openWorktree" }) }), + vscode.commands.registerCommand("kilo-code.new.agentManager.openPR", () => { + agentManagerProvider.postMessage({ type: "action", action: "openPR" }) + }), vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => { agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" }) }), @@ -471,23 +487,33 @@ export function activate(context: vscode.ExtensionContext) { ), ) - // Register URI handler for session imports (vscode://kilocode.kilo-code/kilocode/s/{sessionId}) + // Register URI handler for extension deep links (vscode://kilocode.kilo-code/kilocode/...) context.subscriptions.push( vscode.window.registerUriHandler({ async handleUri(uri: vscode.Uri) { - const match = uri.path.match(/^\/kilocode\/s\/([a-zA-Z0-9_-]+)$/) - if (!match) return - const sessionId = match[1] - if (!sessionId) return - console.log("[Kilo New] URI handler: opening cloud session:", sessionId) + const sessionMatch = uri.path.match(/^\/kilocode\/s\/([a-zA-Z0-9_-]+)$/) + const sessionId = sessionMatch?.[1] + if (sessionId) { + console.log("[Kilo New] URI handler: opening cloud session:", sessionId) + await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`) + provider.openCloudSession(sessionId) + return + } + + if (uri.path !== "/kilocode/switch" && uri.path !== "/kilocode/model") return + const params = new URLSearchParams(uri.query) + const modelID = params.get("model") || undefined + const agent = params.get("agent") || undefined + if (!modelID && !agent) return + console.log("[Kilo New] URI handler: applying linked Kilo selection:", { modelID, agent }) await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`) - provider.openCloudSession(sessionId) + provider.selectKiloModel(modelID, agent) }, }), ) // Register autocomplete provider - registerAutocompleteProvider(context, connectionService) + void registerAutocompleteProvider(context, connectionService) // Register commit message generation registerCommitMessageService(context, connectionService) @@ -512,6 +538,7 @@ export function activate(context: vscode.ExtensionContext) { dispose: () => { shuttingDown = true unsubscribeStateChange() + attention.dispose() browserAutomationService.dispose() provider.dispose() connectionService.dispose() diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts index 889c12d8e71..618eaa79bf1 100644 --- a/packages/kilo-vscode/src/features.ts +++ b/packages/kilo-vscode/src/features.ts @@ -1,18 +1,20 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import * as vscode from "vscode" type PluginSpec = string | [string, Record] type ConfigLike = { plugin?: readonly PluginSpec[] | null - experimental?: { semantic_indexing?: boolean } | null } export type Features = { indexing: boolean + sandboxControls: boolean } export function configFeatures(config?: ConfigLike | null): Features { return { - indexing: hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true, + indexing: hasIndexingPlugin(config?.plugin ?? []), + sandboxControls: vscode.workspace.getConfiguration("kilo-code.new.internal").get("sandboxControls", false), } } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 6f0409f90e1..b862887edf6 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -1,4 +1,16 @@ -import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client" +import type { + Session, + Agent, + Event, + ProviderListResponse, + SyncEventMessageUpdated, + SyncEventMessageRemoved, + SyncEventMessagePartUpdated, + SyncEventMessagePartRemoved, + SyncEventSessionCreated, + SyncEventSessionUpdated, + SyncEventSessionDeleted, +} from "@kilocode/sdk/v2/client" import { prettifyError } from "zod/v4" import type { CloudSessionMessage, IndexingStatus } from "./services/cli-backend/types" import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler" @@ -200,6 +212,73 @@ export function sessionToWebview(session: Session) { } } +type SessionPatch = SyncEventSessionUpdated["data"]["info"] +export type WebviewSessionPatch = Partial> & { id: string } + +function set(target: T, key: K, value: T[K] | null | undefined): void { + if (value === undefined || value === null) return + target[key] = value +} + +function update(target: T, key: K, value: T[K] | null | undefined): void { + if (value === undefined) return + if (value === null) { + Reflect.deleteProperty(target, key) + return + } + target[key] = value +} + +function share(session: Session, url: string | null | undefined): void { + if (url === undefined) return + if (url === null) { + delete session.share + return + } + session.share = { url } +} + +export function applySessionPatch(current: Session, patch: SessionPatch): Session { + const next: Session = { ...current, time: { ...current.time } } + + set(next, "slug", patch.slug) + set(next, "projectID", patch.projectID) + set(next, "directory", patch.directory) + set(next, "title", patch.title) + set(next, "version", patch.version) + update(next, "workspaceID", patch.workspaceID) + update(next, "path", patch.path) + update(next, "parentID", patch.parentID) + update(next, "summary", patch.summary) + update(next, "cost", patch.cost) + update(next, "tokens", patch.tokens) + share(next, patch.share?.url) + update(next, "agent", patch.agent) + update(next, "model", patch.model) + update(next, "permission", patch.permission) + update(next, "revert", patch.revert) + set(next.time, "created", patch.time?.created) + set(next.time, "updated", patch.time?.updated) + update(next.time, "compacting", patch.time?.compacting) + update(next.time, "archived", patch.time?.archived) + + return next +} + +export function sessionPatchToWebview(sessionID: string, patch: SessionPatch): WebviewSessionPatch { + return { + id: sessionID, + ...(patch.parentID !== undefined && { parentID: patch.parentID }), + ...(patch.title !== undefined && patch.title !== null && { title: patch.title }), + ...(patch.time?.created !== undefined && + patch.time.created !== null && { createdAt: new Date(patch.time.created).toISOString() }), + ...(patch.time?.updated !== undefined && + patch.time.updated !== null && { updatedAt: new Date(patch.time.updated).toISOString() }), + ...(patch.revert !== undefined && { revert: patch.revert }), + ...(patch.summary !== undefined && { summary: patch.summary }), + } +} + export function indexProvidersById(all: ProviderInfo[]): Record { const normalized: Record = {} for (const provider of all) { @@ -375,6 +454,17 @@ export function sameDirectory(a: string, b: string): boolean { return path.relative(left.toLowerCase(), right.toLowerCase()) === "" } +type SyncEvent = + | SyncEventMessageUpdated + | SyncEventMessageRemoved + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved + | SyncEventSessionCreated + | SyncEventSessionUpdated + | SyncEventSessionDeleted + +type StreamEvent = Event | SyncEvent + export type WebviewMessage = | PartUpdate | PartBatch @@ -388,6 +478,7 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } + | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" } | { type: "permissionRequest" permission: { @@ -423,84 +514,92 @@ export type WebviewMessage = | { type: "permissionResolved"; permissionID: string } | { type: "permissionError"; permissionID: string; stale?: boolean } | { type: "sessionCreated"; session: ReturnType; draftID?: string } - | { type: "sessionUpdated"; session: ReturnType } + | { type: "sessionUpdated"; session: WebviewSessionPatch } + | { type: "sessionDeleted"; sessionID: string } | { type: "messageRemoved"; sessionID: string; messageID: string } | { type: "sessionError"; sessionID?: string; error?: unknown } | null -type PartEvent = Extract +type PartEvent = + | Extract + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved function mapPartEvent(event: PartEvent, sessionID: string | undefined): WebviewMessage { - if (!sessionID) return null - if (event.type === "message.part.updated") { - const part = event.properties.part as { messageID?: string; sessionID?: string } - return { - type: "partUpdated", - sessionID, - messageID: part.messageID || "", - part: event.properties.part, + if (event.type === "sync") { + if (event.name === "message.part.updated.1") { + const part = event.data.part + return { + type: "partUpdated", + sessionID: event.data.sessionID, + messageID: part.messageID, + part, + } } - } - if (event.type === "message.part.delta") { - const props = event.properties return { - type: "partUpdated", - sessionID: props.sessionID, - messageID: props.messageID, - part: { id: props.partID, type: "text", messageID: props.messageID, text: props.delta }, - delta: { type: "text-delta", textDelta: props.delta }, + type: "partRemoved", + sessionID: event.data.sessionID, + messageID: event.data.messageID, + partID: event.data.partID, } } + if (!sessionID) return null const props = event.properties return { - type: "partRemoved", + type: "partUpdated", sessionID: props.sessionID, messageID: props.messageID, - partID: props.partID, + part: { id: props.partID, type: "text", messageID: props.messageID, text: props.delta }, + delta: { type: "text-delta", textDelta: props.delta }, } } -export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | undefined): WebviewMessage { - if ( - event.type === "message.part.updated" || - event.type === "message.part.delta" || - event.type === "message.part.removed" - ) { - return mapPartEvent(event, sessionID) - } - switch (event.type) { - case "message.updated": { - const info = event.properties.info - return { - type: "messageCreated", - message: { - ...info, - createdAt: new Date(info.time.created).toISOString(), - }, - } - } - case "message.removed": { - const props = event.properties as { sessionID: string; messageID: string } - return { - type: "messageRemoved", - sessionID: props.sessionID, - messageID: props.messageID, +export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: string | undefined): WebviewMessage { + if (event.type === "sync") { + switch (event.name) { + case "message.updated.1": { + const info = event.data.info + return { + type: "messageCreated", + message: { + ...info, + createdAt: new Date(info.time.created).toISOString(), + }, + } } + case "message.removed.1": + return { + type: "messageRemoved", + sessionID: event.data.sessionID, + messageID: event.data.messageID, + } + case "message.part.updated.1": + case "message.part.removed.1": + return mapPartEvent(event, sessionID) + case "session.created.1": + return { + type: "sessionCreated", + session: sessionToWebview(event.data.info), + } + case "session.updated.1": + return null + case "session.deleted.1": + return { + type: "sessionDeleted", + sessionID: event.data.sessionID, + } } + } + if (event.type === "message.part.delta") return mapPartEvent(event, sessionID) + switch (event.type) { case "session.status": { const info = event.properties.status - // "offline" is not yet in the SDK SessionStatus type (pending SDK regeneration), - // so we use string comparison to forward the message field for offline status. - const status = info.type as string + const status = info.type const extra = - status === "retry" - ? { - attempt: (info as any).attempt as number, - message: (info as any).message as string, - next: (info as any).next as number, - } - : status === "offline" - ? { message: (info as any).message as string } + info.type === "retry" + ? { attempt: info.attempt, message: info.message, next: info.next } + : info.type === "offline" + ? { message: info.message } : {} return { type: "sessionStatus" as const, @@ -509,6 +608,12 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un ...extra, } } + case "session.turn.close": + return { + type: "sessionTurnClosed", + sessionID: event.properties.sessionID, + reason: event.properties.reason, + } case "permission.asked": return { type: "permissionRequest", @@ -576,16 +681,6 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un error: event.properties.error, } } - case "session.created": - return { - type: "sessionCreated", - session: sessionToWebview(event.properties.info), - } - case "session.updated": - return { - type: "sessionUpdated", - session: sessionToWebview(event.properties.info), - } case "indexing.status": return { type: "indexingStatusLoaded", @@ -616,10 +711,12 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess * Returns true when the event carries a projectID that does not match the expected one. * When expectedProjectID is undefined (not yet resolved), nothing is filtered. */ -export function isEventFromForeignProject(event: Event, expectedProjectID: string | undefined): boolean { - if (!expectedProjectID) return false - if (event.type === "session.created" || event.type === "session.updated") { - return event.properties.info.projectID !== expectedProjectID +export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: string | undefined): boolean { + if (!expectedProjectID || event.type !== "sync") return false + if (event.name === "session.created.1" || event.name === "session.deleted.1") { + return event.data.info.projectID !== expectedProjectID } - return false + if (event.name !== "session.updated.1") return false + const project = event.data.info.projectID + return project !== undefined && project !== expectedProjectID } diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 8d3d00676e9..7bb355b5b26 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -1,4 +1,69 @@ -import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" +import { sameDirectory } from "../kilo-provider-utils" + +export class SessionAbort { + private active = new Map>() + + observe(sessionID: string, status: SessionStatus["type"], dir?: string) { + if (!dir) return + const dirs = this.active.get(sessionID) + if (status === "idle") { + if (!dirs) return + for (const entry of dirs) { + if (sameDirectory(entry, dir)) dirs.delete(entry) + } + if (dirs.size === 0) this.active.delete(sessionID) + return + } + if (!dirs) { + this.active.set(sessionID, new Set([dir])) + return + } + if (![...dirs].some((entry) => sameDirectory(entry, dir))) dirs.add(dir) + } + + preserve(sessionID: string, status: SessionStatus["type"] | undefined, dir: string) { + if (!status || status === "idle" || this.active.has(sessionID)) return + this.observe(sessionID, status, dir) + } + + async stop(client: KiloClient, sessionID: string, fallback: string) { + const known = this.active.has(sessionID) + const dirs = [...(this.active.get(sessionID) ?? [])] + if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + const results = await Promise.allSettled(dirs.map((dir) => abortSession({ client, sessionID, dir }))) + const failures = results.flatMap((result, index) => + result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], + ) + if (failures.length > 0) { + console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) + return false + } + if (known) this.active.delete(sessionID) + return known + } + + dispose(dir: string) { + const idle: string[] = [] + for (const [sessionID, dirs] of this.active) { + for (const entry of dirs) { + if (sameDirectory(entry, dir)) dirs.delete(entry) + } + if (dirs.size > 0) continue + this.active.delete(sessionID) + idle.push(sessionID) + } + return idle + } + + delete(sessionID: string) { + this.active.delete(sessionID) + } + + clear() { + this.active.clear() + } +} export async function abortSession(input: { client: KiloClient; sessionID: string; dir: string }) { await input.client.session.abort({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true }) diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts new file mode 100644 index 00000000000..1c55c65ec6b --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -0,0 +1,131 @@ +import * as vscode from "vscode" +import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview" +import { isAbsolutePath } from "../path-utils" +import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider" + +type EditorOpenMessage = { + type?: string + filePath?: string + line?: number + column?: number + content?: string + language?: string +} + +function openExternal(url: unknown): void { + if (typeof url !== "string") return + void vscode.env.openExternal(vscode.Uri.parse(url)) +} + +function openDiffVirtual(provider: DiffVirtualProvider | undefined, diff: unknown, initialDiffStyle?: unknown): void { + if (!provider || !diff) return + const file = diff as DiffVirtualFile + file.initialDiffStyle = initialDiffStyle === "split" ? "split" : "unified" + provider.open(file) +} + +function previewImage(dir: vscode.Uri | undefined, dataUrl: string, filename: string): void { + if (!dir) return + + const img = parseImage(dataUrl, filename) + if (!img) return + + const root = vscode.Uri.joinPath(dir, getPreviewDir()) + const uri = vscode.Uri.joinPath(dir, buildPreviewPath(img.name, Date.now())) + const clean = () => + vscode.workspace.fs.readDirectory(root).then( + (items) => { + const stale = trimEntries(items.map(([name]) => ({ path: name }))) + return Promise.all( + stale.map((name) => + Promise.resolve(vscode.workspace.fs.delete(vscode.Uri.joinPath(root, name), { recursive: true })).then( + undefined, + (err: unknown) => { + console.warn("[Kilo New] KiloProvider: Failed to delete stale preview:", err) + }, + ), + ), + ) + }, + () => [], + ) + const open = () => + vscode.commands + .executeCommand(...getPreviewCommand(uri)) + .then(undefined, () => vscode.commands.executeCommand("vscode.open", uri)) + + void vscode.workspace.fs + .createDirectory(root) + .then(() => vscode.workspace.fs.writeFile(uri, img.data)) + .then(() => clean()) + .then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err)) +} + +export function handleEditorAction( + message: EditorOpenMessage & { + url?: unknown + diff?: unknown + initialDiffStyle?: unknown + dataUrl?: string + filename?: string + }, + opts: { + dir: () => string + diff?: DiffVirtualProvider + storage?: vscode.Uri + }, +): boolean { + if (message.type === "openFile") { + if (message.filePath) openFile(opts.dir(), message.filePath, message.line, message.column) + return true + } + if (message.type === "openContent") { + if (message.content) openContent(message.content, message.language) + return true + } + if (message.type === "openExternal") { + openExternal(message.url) + return true + } + if (message.type === "openDiffVirtual") { + openDiffVirtual(opts.diff, message.diff, message.initialDiffStyle) + return true + } + if (message.type === "previewImage") { + if (message.dataUrl && message.filename) previewImage(opts.storage, message.dataUrl, message.filename) + return true + } + return false +} + +function openContent(content: string, language?: string): void { + vscode.workspace.openTextDocument({ content, language: language || "log" }).then( + (doc) => vscode.window.showTextDocument(doc, { preview: true }), + (err) => console.error("[Kilo New] KiloProvider: Failed to open content:", err), + ) +} + +function openFile(dir: string, filePath: string, line?: number, column?: number): void { + const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath) + vscode.workspace.fs.stat(uri).then( + (stat) => { + if (stat.type & vscode.FileType.Directory) { + vscode.commands.executeCommand("revealInExplorer", uri) + return + } + vscode.workspace.openTextDocument(uri).then( + (doc) => { + const options: vscode.TextDocumentShowOptions = { preview: true } + if (line !== undefined && line > 0) { + const col = column !== undefined && column > 0 ? column - 1 : 0 + const pos = new vscode.Position(line - 1, col) + options.selection = new vscode.Range(pos, pos) + } + vscode.window.showTextDocument(doc, options) + }, + (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), + ) + }, + (err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err), + ) +} diff --git a/packages/kilo-vscode/src/kilo-provider/fork-session.ts b/packages/kilo-vscode/src/kilo-provider/fork-session.ts index 1416320581c..d97fc213a0b 100644 --- a/packages/kilo-vscode/src/kilo-provider/fork-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/fork-session.ts @@ -32,6 +32,7 @@ export async function handleForkSession(ctx: ForkContext, sessionId: string, mes { getClient: () => ctx.connection.getClient(), state: undefined, + directory: ctx.directory(sessionId), postError: (message) => ctx.post({ type: "error", message }), registerWorktreeSession: () => {}, pushState: () => {}, diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 29ce097953d..19a3b2ab60d 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -9,6 +9,9 @@ import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocod import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types" import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils" import type { MessageFile } from "../message-files" +import { reviewMetadata, type ReviewMessageData } from "../../shared/review-comments" + +const TIMEOUT = 30_000 export interface CloudSessionContext { readonly client: KiloClient | null @@ -73,7 +76,7 @@ export async function handleRequestCloudSessionData(ctx: CloudSessionContext, se } try { - const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }) + const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }, { signal: AbortSignal.timeout(TIMEOUT) }) const data = result.data as CloudSessionData | undefined if (!data) { ctx.postMessage({ @@ -117,6 +120,7 @@ export async function handleImportAndSend( agent?: string, variant?: string, files?: MessageFile[], + review?: ReviewMessageData, command?: string, commandArgs?: string, ): Promise { @@ -135,10 +139,13 @@ export async function handleImportAndSend( // Step 1: Import the cloud session with fresh IDs let session: Session | undefined try { - const result = await ctx.client.kilo.cloud.session.import({ - sessionId: cloudSessionId, - directory: dir, - }) + const result = await ctx.client.kilo.cloud.session.import( + { + sessionId: cloudSessionId, + directory: dir, + }, + { signal: AbortSignal.timeout(TIMEOUT) }, + ) session = result.data as Session | undefined } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Cloud session import failed:", error) @@ -208,7 +215,7 @@ export async function handleImportAndSend( parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source }) } } - parts.push({ type: "text", text }) + parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined }) const editorContext = await ctx.gatherEditorContext() await client.session.promptAsync( @@ -235,6 +242,7 @@ export async function handleImportAndSend( draftID: session.id, messageID, files, + review: command ? undefined : review, }) } } diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts b/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts index a0a06c210c0..70c0f9c76cd 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/migration.ts @@ -2,7 +2,7 @@ * Legacy migration handlers — extracted from KiloProvider. * * Manages the migration wizard for users upgrading from Kilo Code v5.x. - * No vscode dependency — all vscode access is injected via MigrationContext. + * VS Code access is limited to migration service helpers and injected context. */ import type { KiloClient } from "@kilocode/sdk/v2/client" @@ -10,8 +10,13 @@ import type { LegacyMigrationData, MigrationSelections, MigrationSessionProgress, + MigrationSessionSelection, } from "../../legacy-migration/legacy-types" import * as MigrationService from "../../legacy-migration/migration-service" +import { runSessionBatch } from "../../legacy-migration/session-batch" +import { migrate as migrateSession } from "../../legacy-migration/sessions/migrate" +import { resolveSession } from "../../legacy-migration/task-store" +import { detectRooCodeSessions, type RooImportSource } from "../../roo-import/service" /** Subset of vscode.ExtensionContext needed by migration handlers. */ interface MigrationExtensionContext { @@ -28,21 +33,59 @@ interface MigrationExtensionContext { globalStorageUri: { fsPath: string } } +export type MigrationSource = "legacy" | "roo" +export type MigrationCacheEntry = + | { operationId: string; source: "legacy"; data: LegacyMigrationData } + | { operationId: string; source: "roo"; data: RooImportSource | null } +export type MigrationCache = Map + +export function getMigrationCache( + cache: MigrationCache, + source: "legacy", + operationId: string, +): Extract | undefined +export function getMigrationCache( + cache: MigrationCache, + source: "roo", + operationId: string, +): Extract | undefined +export function getMigrationCache(cache: MigrationCache, source: MigrationSource, operationId: string) { + const entry = cache.get(operationId) + return entry?.source === source ? entry : undefined +} + export interface MigrationContext { readonly client: KiloClient | null readonly extensionContext: MigrationExtensionContext | undefined postMessage(msg: unknown): void refreshSessions(): void - cachedLegacyData: LegacyMigrationData | null + migrationCache: MigrationCache migrationCheckInFlight: boolean lastMigrationHadErrors?: boolean disposeGlobal(): Promise broadcastComplete(): void } -function postSessionProgress(ctx: MigrationContext, progress: MigrationSessionProgress): void { +function emptyData(sessions: LegacyMigrationData["sessions"] = []): LegacyMigrationData { + return { + hasData: sessions.length > 0, + providers: [], + mcpServers: [], + customModes: [], + sessions, + } +} + +function postSessionProgress( + ctx: MigrationContext, + source: MigrationSource, + operationId: string, + progress: MigrationSessionProgress, +): void { ctx.postMessage({ - type: "legacyMigrationSessionProgress", + type: "migrationSessionProgress", + source, + operationId, session: progress.session, index: progress.index, total: progress.total, @@ -75,33 +118,42 @@ export async function checkAndShowMigrationWizard(ctx: MigrationContext): Promis if (!data.hasData) return - // Cache so migrate() doesn't re-read from SecretStorage/disk - ctx.cachedLegacyData = data - console.log("[Kilo New] KiloProvider: 🔄 Legacy data detected, showing migration wizard") + // The wizard re-requests the data via requestMigrationData on mount, so only the flag is sent here. ctx.postMessage({ type: "migrationState", needed: true, - data: { - providers: data.providers, - mcpServers: data.mcpServers, - customModes: data.customModes, - sessions: data.sessions, - defaultModel: data.defaultModel, - settings: data.settings, - }, + source: "legacy", }) } -/** Send the detected legacy data to the webview on explicit request. */ -export async function handleRequestLegacyMigrationData(ctx: MigrationContext): Promise { +/** Send migration data for the requested source to the webview. */ +export async function handleRequestMigrationData( + ctx: MigrationContext, + source: MigrationSource, + operationId: string, +): Promise { if (!ctx.extensionContext) return - const data = await MigrationService.detectLegacyData( - ctx.extensionContext as Parameters[0], - ) - ctx.cachedLegacyData = data + // A new request means a new wizard session; drop any entry from an abandoned one. + for (const key of ctx.migrationCache.keys()) { + if (key !== operationId) ctx.migrationCache.delete(key) + } + const data = await (async () => { + if (source === "roo") { + const roo = await detectRooCodeSessions(ctx.extensionContext as Parameters[0]) + ctx.migrationCache.set(operationId, { operationId, source, data: roo }) + return emptyData(roo?.sessions ?? []) + } + const legacy = await MigrationService.detectLegacyData( + ctx.extensionContext as Parameters[0], + ) + ctx.migrationCache.set(operationId, { operationId, source, data: legacy }) + return legacy + })() ctx.postMessage({ - type: "legacyMigrationData", + type: "migrationData", + source, + operationId, data: { providers: data.providers, mcpServers: data.mcpServers, @@ -113,34 +165,83 @@ export async function handleRequestLegacyMigrationData(ctx: MigrationContext): P }) } +async function startRooMigration( + ctx: MigrationContext, + operationId: string, + selections: { sessions?: MigrationSessionSelection[] }, +): Promise { + if (!ctx.extensionContext || !ctx.client) return + const cached = getMigrationCache(ctx.migrationCache, "roo", operationId) + const source = cached + ? cached.data + : await detectRooCodeSessions(ctx.extensionContext as Parameters[0]) + if (!cached) ctx.migrationCache.set(operationId, { operationId, source: "roo", data: source }) + if (!source) { + ctx.postMessage({ + type: "migrationComplete", + source: "roo", + operationId, + results: [ + { item: "Roo Code sessions", category: "session", status: "warning", message: "No Roo Code sessions found." }, + ], + }) + return + } + + const results = await runSessionBatch({ + selections: selections.sessions ?? [], + sessions: source.sessions, + resolve: (id) => resolveSession(source.catalog, id), + migrate: (selection, resolved, progress) => + migrateSession( + selection, + ctx.extensionContext as Parameters[1], + ctx.client as KiloClient, + progress, + resolved, + ), + onProgress: (item, status, message) => { + ctx.postMessage({ type: "migrationProgress", source: "roo", operationId, item, status, message }) + }, + onSessionProgress: (progress) => postSessionProgress(ctx, "roo", operationId, progress), + }) + + ctx.lastMigrationHadErrors = results.some((item) => item.status === "error") + ctx.postMessage({ type: "migrationComplete", source: "roo", operationId, results }) +} + /** Run the migration for the selected items. */ -export async function handleStartLegacyMigration( +async function startLegacyMigration( ctx: MigrationContext, + operationId: string, selections: MigrationSelections, ): Promise { if (!ctx.extensionContext || !ctx.client) return try { + const cached = getMigrationCache(ctx.migrationCache, "legacy", operationId) const results = await MigrationService.migrate( ctx.extensionContext as Parameters[0], ctx.client, selections, (item, status, message) => { - ctx.postMessage({ type: "legacyMigrationProgress", item, status, message }) + ctx.postMessage({ type: "migrationProgress", source: "legacy", operationId, item, status, message }) }, (progress: MigrationSessionProgress) => { - postSessionProgress(ctx, progress) + postSessionProgress(ctx, "legacy", operationId, progress) }, - ctx.cachedLegacyData?.settings, - ctx.cachedLegacyData?.sessions, + cached?.data.settings, + cached?.data.sessions, ) ctx.lastMigrationHadErrors = results.some((item) => item.status === "error") - ctx.postMessage({ type: "legacyMigrationComplete", results }) + ctx.postMessage({ type: "migrationComplete", source: "legacy", operationId, results }) } catch (error) { ctx.lastMigrationHadErrors = true console.error("[Kilo New] KiloProvider: ❌ Migration failed", error) ctx.postMessage({ - type: "legacyMigrationComplete", + type: "migrationComplete", + source: "legacy", + operationId, results: [ { item: "Migration", @@ -153,6 +254,24 @@ export async function handleStartLegacyMigration( } } +export async function handleStartMigration( + ctx: MigrationContext, + source: MigrationSource, + operationId: string, + selections: MigrationSelections, +): Promise { + try { + if (source === "roo") { + await startRooMigration(ctx, operationId, selections) + return + } + await startLegacyMigration(ctx, operationId, selections) + } finally { + // The operation has finished (or thrown); its cached discovery is no longer needed. + ctx.migrationCache.delete(operationId) + } +} + export async function handleFinalizeLegacyMigration(ctx: MigrationContext): Promise { if (!ctx.extensionContext) return await ctx.disposeGlobal() diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index 3344111a175..2b6e3608cac 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -14,16 +14,17 @@ export interface PermissionContext { readonly currentSessionId: string | undefined readonly trackedSessionIds: Set readonly sessionDirectories: ReadonlyMap + readonly extraDirectories?: () => string[] postMessage(msg: unknown): void getWorkspaceDirectory(sessionId?: string): string recordPermissionDirectory(requestID: string, directory: string): void getPermissionDirectory(requestID: string): string | undefined clearPermissionDirectory(requestID: string): void - prunePermissionDirectories(active: Set): void + prunePermissionDirectories(active: Set, dirs?: Set): void } -export function recoveryDirs(workspace: string, dirs: ReadonlyMap) { - return [...new Set([workspace, ...dirs.values()])] +export function recoveryDirs(workspace: string, dirs: ReadonlyMap, extra: string[] = []) { + return [...new Set([workspace, ...dirs.values(), ...extra])] } export function recoverablePermissions(perms: RecoverablePermission[], tracked: Set, seen: Set) { @@ -35,12 +36,16 @@ export function recoverablePermissions(perms: RecoverablePermission[], tracked: } function isNotFoundError(error: unknown): boolean { - if (!error || typeof error !== "object") return false - const obj = error as Record - if (obj.name === "NotFoundError") return true - if (typeof obj.status === "number" && obj.status === 404) return true - const data = obj.data as Record | undefined - return data?.name === "NotFoundError" + const record = (value: unknown) => + value && typeof value === "object" ? (value as Record) : undefined + const obj = record(error) + if (!obj) return false + + const cause = record(obj.cause) + const body = record(cause?.body) + return [obj, record(obj.data), cause, body, record(body?.data)].some( + (value) => value?.name === "NotFoundError" || value?.status === 404, + ) } /** @@ -123,11 +128,17 @@ export async function handlePermissionResponse( export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Promise { if (!ctx.client) return try { - const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories) + const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories, ctx.extraDirectories?.() ?? []) const seen = new Set() + const valid = new Set() for (const dir of dirs) { - const { data } = await ctx.client.permission.list({ directory: dir }) + const { data, error } = await ctx.client.permission.list({ directory: dir }) + if (error) { + console.error(`[Kilo New] KiloProvider: Failed to fetch pending permissions for ${dir}:`, error) + continue + } + valid.add(dir) if (!data) continue for (const perm of recoverablePermissions(data, ctx.trackedSessionIds, seen)) { ctx.recordPermissionDirectory(perm.id, dir) @@ -146,7 +157,7 @@ export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Pr }) } } - ctx.prunePermissionDirectories(seen) + ctx.prunePermissionDirectories(seen, valid) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch pending permissions:", error) } diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts index 69d37960006..477db768032 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts @@ -6,15 +6,53 @@ * No vscode dependency. */ -import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { KiloClient, QuestionRequest } from "@kilocode/sdk/v2/client" -interface QuestionContext { +export interface QuestionContext { readonly client: KiloClient | null readonly currentSessionId: string | undefined readonly trackedSessionIds: Set readonly sessionDirectories: ReadonlyMap + readonly extraDirectories?: () => string[] postMessage(msg: unknown): void getWorkspaceDirectory(sessionId?: string): string + recordQuestionDirectory(requestID: string, directory: string): void + getQuestionDirectory(requestID: string): string | undefined + clearQuestionDirectory(requestID: string): void + getQuestionRevision(): number + pruneQuestionDirectories(active: Set, dirs: Set): void +} + +interface QuestionRecovery { + readonly seen: Set + readonly complete: boolean +} + +function isNotFoundError(error: unknown): boolean { + const record = (value: unknown) => + value && typeof value === "object" ? (value as Record) : undefined + const obj = record(error) + if (!obj) return false + + const cause = record(obj.cause) + const body = record(cause?.body) + return [obj, record(obj.data), cause, body, record(body?.data)].some( + (value) => value?.name === "NotFoundError" || value?._tag === "NotFound" || value?.status === 404, + ) +} + +function stale(ctx: QuestionContext, requestID: string): void { + ctx.clearQuestionDirectory(requestID) + ctx.postMessage({ type: "questionResolved", requestID }) + void fetchAndSendPendingQuestions(ctx) +} + +async function recover(ctx: QuestionContext, requestID: string): Promise { + const result = await fetchAndSendPendingQuestions(ctx) + if (!result?.complete || result.seen.has(requestID)) return false + ctx.clearQuestionDirectory(requestID) + ctx.postMessage({ type: "questionResolved", requestID }) + return true } /** @@ -23,29 +61,52 @@ interface QuestionContext { * called after child-session sync and after SSE reconnects so missed * question.asked events don't leave the server blocked indefinitely. */ -export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promise { +export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promise { if (!ctx.client) return try { - const dirs = new Set([ctx.getWorkspaceDirectory(), ...ctx.sessionDirectories.values()]) - const seen = new Set() - for (const dir of dirs) { - const { data } = await ctx.client.question.list({ directory: dir }) - if (!data) continue - for (const q of data) { - if (seen.has(q.id)) continue - seen.add(q.id) - if (!ctx.trackedSessionIds.has(q.sessionID)) continue + for (;;) { + const dirs = new Set([ + ctx.getWorkspaceDirectory(), + ...ctx.sessionDirectories.values(), + ...(ctx.extraDirectories?.() ?? []), + ]) + const revision = ctx.getQuestionRevision() + const seen = new Set() + const scanned = new Set() + const failed = new Set() + const pending: Array<{ question: QuestionRequest; dir: string }> = [] + for (const dir of dirs) { + const { data, error } = await ctx.client.question.list({ directory: dir }) + if (error) { + failed.add(dir) + console.error(`[Kilo New] KiloProvider: Failed to fetch pending questions for ${dir}:`, error) + continue + } + scanned.add(dir) + if (!data) continue + for (const q of data) { + if (seen.has(q.id)) continue + seen.add(q.id) + if (!ctx.trackedSessionIds.has(q.sessionID)) continue + pending.push({ question: q, dir }) + } + } + if (ctx.getQuestionRevision() !== revision) continue + for (const item of pending) { + ctx.recordQuestionDirectory(item.question.id, item.dir) ctx.postMessage({ type: "questionRequest", question: { - id: q.id, - sessionID: q.sessionID, - questions: q.questions, - blocking: q.blocking, - tool: q.tool, + id: item.question.id, + sessionID: item.question.sessionID, + questions: item.question.questions, + blocking: item.question.blocking, + tool: item.question.tool, }, }) } + ctx.pruneQuestionDirectories(seen, scanned) + return { seen, complete: failed.size === 0 } } } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch pending questions:", error) @@ -65,14 +126,19 @@ export async function handleQuestionReply( } const sid = sessionID ?? ctx.currentSessionId + const origin = ctx.getQuestionDirectory(requestID) + const dir = origin ?? ctx.getWorkspaceDirectory(sid) try { - await ctx.client.question.reply( - { requestID, answers, directory: ctx.getWorkspaceDirectory(sid) }, - { throwOnError: true }, - ) + await ctx.client.question.reply({ requestID, answers, directory: dir }, { throwOnError: true }) + ctx.clearQuestionDirectory(requestID) return true } catch (error) { + if (isNotFoundError(error) && origin) { + stale(ctx, requestID) + return false + } + if (isNotFoundError(error) && (await recover(ctx, requestID))) return false console.error("[Kilo New] KiloProvider: Failed to reply to question:", error) ctx.postMessage({ type: "questionError", requestID }) return false @@ -91,11 +157,19 @@ export async function handleQuestionReject( } const sid = sessionID ?? ctx.currentSessionId + const origin = ctx.getQuestionDirectory(requestID) + const dir = origin ?? ctx.getWorkspaceDirectory(sid) try { - await ctx.client.question.reject({ requestID, directory: ctx.getWorkspaceDirectory(sid) }, { throwOnError: true }) + await ctx.client.question.reject({ requestID, directory: dir }, { throwOnError: true }) + ctx.clearQuestionDirectory(requestID) return true } catch (error) { + if (isNotFoundError(error) && origin) { + stale(ctx, requestID) + return false + } + if (isNotFoundError(error) && (await recover(ctx, requestID))) return false console.error("[Kilo New] KiloProvider: Failed to reject question:", error) ctx.postMessage({ type: "questionError", requestID }) return false diff --git a/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts b/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts new file mode 100644 index 00000000000..8fbd0ace48e --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode" + +type Post = (msg: unknown) => void + +export function buildIndexingSettingsMessage() { + const config = vscode.workspace.getConfiguration("kilo-code.new.indexing") + return { + type: "indexingSettingsLoaded" as const, + settings: { + showButtonWhenDisabled: config.get("showButtonWhenDisabled", true), + }, + } +} + +export function watchIndexingConfig(post: Post): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("kilo-code.new.indexing")) { + post(buildIndexingSettingsMessage()) + } + }) +} + +export function validIndexingSetting(key: string, value: unknown) { + return key === "showButtonWhenDisabled" && typeof value === "boolean" +} diff --git a/packages/kilo-vscode/src/kilo-provider/message-page.ts b/packages/kilo-vscode/src/kilo-provider/message-page.ts index 504182a0c30..e9a13a0aebd 100644 --- a/packages/kilo-vscode/src/kilo-provider/message-page.ts +++ b/packages/kilo-vscode/src/kilo-provider/message-page.ts @@ -26,8 +26,7 @@ export async function fetchMessagePage( signal?: AbortSignal }, ) { - // limit: 0 is the server contract for "return every message" — used by - // the sub-agent viewer, which has no "load earlier" UI. + // limit: 0 is the server contract for "return every message". const full = input.limit === 0 const read = async (before?: string) => { const result = await retry(() => diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index 8fad1c57c48..32f5d3793ce 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -1,8 +1,8 @@ export type KiloProviderOptions = { projectDirectory?: string | null platform?: string + snapshotInitialization?: "wait" slimEditMetadata?: boolean tabTitle?: (title: string) => void - onSidebarVisibilityChange?: (visible: boolean) => void worktreeDirectories?: () => string[] } diff --git a/packages/kilo-vscode/src/kilo-provider/remove-config-item.ts b/packages/kilo-vscode/src/kilo-provider/remove-config-item.ts new file mode 100644 index 00000000000..60a18ab0ffc --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/remove-config-item.ts @@ -0,0 +1,39 @@ +import type * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend" +import { removeMarketplaceItemFromAllScopes, type MarketplaceRemoveContext } from "../services/marketplace/actions" +import { MarketplaceInstaller } from "../services/marketplace/installer" +import { MarketplacePaths } from "../services/marketplace/paths" +import type { MarketplaceItemRef } from "../services/marketplace/types" + +export interface RemoveConfigItemContext { + connection: KiloConnectionService + project: () => string | undefined + directory: () => string + refresh: () => Promise + remove: MarketplaceRemoveContext["remove"] + storage?: vscode.Uri +} + +export function createMarketplaceRemover(): MarketplaceRemoveContext["remove"] { + const installer = new MarketplaceInstaller(new MarketplacePaths()) + return (item, scope, project) => installer.remove(item, scope, project) +} + +export async function removeAgent(ctx: RemoveConfigItemContext, name: string): Promise { + return remove(ctx, { id: name, type: "agent" }) +} + +export async function removeMcp(ctx: RemoveConfigItemContext, name: string): Promise { + return remove(ctx, { id: name, type: "mcp" }) +} + +async function remove(ctx: RemoveConfigItemContext, item: MarketplaceItemRef): Promise { + const actions: MarketplaceRemoveContext = { + connection: ctx.connection, + storage: ctx.storage, + remove: ctx.remove, + } + const removed = await removeMarketplaceItemFromAllScopes(actions, item, ctx.project(), ctx.directory()) + if (removed) await ctx.refresh() + return removed +} diff --git a/packages/kilo-vscode/src/kilo-provider/rename-session.ts b/packages/kilo-vscode/src/kilo-provider/rename-session.ts new file mode 100644 index 00000000000..62b06a04637 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/rename-session.ts @@ -0,0 +1,18 @@ +import type { KiloClient, Session } from "@kilocode/sdk/v2/client" +import { parseSessionTitle } from "../shared/session-title" + +export async function renameSession(input: { + client: KiloClient | null + sessionID: string + title: unknown + directory: string +}): Promise { + if (!input.client) throw new Error("Not connected to CLI backend") + const result = parseSessionTitle(input.title) + if ("error" in result) throw new Error("Invalid session title") + const { data } = await input.client.session.update( + { sessionID: input.sessionID, directory: input.directory, title: result.value }, + { throwOnError: true }, + ) + return data +} diff --git a/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts b/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts index 1f3c91e8bae..3209d3c6e9d 100644 --- a/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts +++ b/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts @@ -17,6 +17,7 @@ export type StreamSchedulerStats = { emitted: number batches: number active: number + visible: number background: number } @@ -32,6 +33,8 @@ export type StreamSchedulerOptions = { backgroundStepMs?: number /** Hard cap for the background cadence. Defaults to 400ms. */ backgroundMaxMs?: number + /** Flush cadence for visible inline child sessions. Defaults to 50ms. */ + visibleMs?: number } // Scheduler tuning — rationale: @@ -71,6 +74,7 @@ export type StreamSchedulerOptions = { // ~500ms the UI starts feeling disconnected; below 300ms the many-agent // backoff stops providing meaningful throttling. const DEFAULT_ACTIVE_MS = 16 +const DEFAULT_VISIBLE_MS = 50 const DEFAULT_BG_BASE_MS = 150 const DEFAULT_BG_STEP_MS = 20 const DEFAULT_BG_MAX_MS = 400 @@ -110,10 +114,14 @@ function mergePartUpdate(prev: PartUpdate | undefined, msg: PartUpdate): PartUpd export class SessionStreamScheduler { private active: string | undefined private atimer: ReturnType | null = null + private vtimer: ReturnType | null = null private btimer: ReturnType | null = null private bgFirstQueuedAt = 0 + private visibleFirstQueuedAt = 0 private readonly queues = new Map>() + private readonly visible = new Set() private readonly activeMs: number + private readonly visibleMs: number private readonly bgBase: number private readonly bgStep: number private readonly bgMax: number @@ -122,6 +130,7 @@ export class SessionStreamScheduler { emitted: 0, batches: 0, active: 0, + visible: 0, background: 0, } @@ -130,6 +139,7 @@ export class SessionStreamScheduler { opts?: StreamSchedulerOptions, ) { this.activeMs = opts?.activeMs ?? DEFAULT_ACTIVE_MS + this.visibleMs = opts?.visibleMs ?? DEFAULT_VISIBLE_MS this.bgBase = opts?.backgroundBaseMs ?? DEFAULT_BG_BASE_MS this.bgStep = opts?.backgroundStepMs ?? DEFAULT_BG_STEP_MS this.bgMax = opts?.backgroundMaxMs ?? DEFAULT_BG_MAX_MS @@ -143,10 +153,26 @@ export class SessionStreamScheduler { this.atimer = null } this.active = sessionID - if (prev && this.queues.get(prev)?.size) this.scheduleBackground() + if (prev && this.queues.get(prev)?.size) this.schedule(prev) if (sessionID) this.flush(sessionID) } + setVisible(sessionID: string, visible: boolean): void { + const changed = visible ? !this.visible.has(sessionID) : this.visible.has(sessionID) + if (!changed) return + if (visible) this.visible.add(sessionID) + else this.visible.delete(sessionID) + if (this.queues.get(sessionID)?.size) this.schedule(sessionID) + if (this.vtimer && !this.hasVisible()) { + clearTimeout(this.vtimer) + this.vtimer = null + } + if (this.btimer && !this.hasBackground()) { + clearTimeout(this.btimer) + this.btimer = null + } + } + push(msg: PartUpdate): void { this.counters.received++ const key = partUpdateKey(msg) @@ -183,6 +209,11 @@ export class SessionStreamScheduler { this.emit(this.take(sessionID)) + if (this.vtimer && !this.hasVisible()) { + clearTimeout(this.vtimer) + this.vtimer = null + } + if (this.btimer && !this.hasBackground()) { clearTimeout(this.btimer) this.btimer = null @@ -200,6 +231,10 @@ export class SessionStreamScheduler { */ drop(sessionID: string): void { this.queues.delete(sessionID) + if (this.vtimer && !this.hasVisible()) { + clearTimeout(this.vtimer) + this.vtimer = null + } if (this.btimer && !this.hasBackground()) { clearTimeout(this.btimer) this.btimer = null @@ -209,6 +244,7 @@ export class SessionStreamScheduler { dispose(): void { this.clearTimers() this.queues.clear() + this.visible.clear() } stats(): Readonly { @@ -230,9 +266,23 @@ export class SessionStreamScheduler { this.atimer = setTimeout(() => this.flushActive(), this.activeMs) return } + if (this.visible.has(sessionID)) { + this.scheduleVisible() + return + } this.scheduleBackground() } + private scheduleVisible(): void { + if (!this.hasVisible()) return + const now = Date.now() + if (!this.vtimer) this.visibleFirstQueuedAt = now + const elapsed = now - this.visibleFirstQueuedAt + const remaining = Math.max(0, this.visibleMs - elapsed) + if (this.vtimer) clearTimeout(this.vtimer) + this.vtimer = setTimeout(() => this.flushVisible(), remaining) + } + private scheduleBackground(): void { const count = this.backgroundCount() if (count === 0) return @@ -259,6 +309,12 @@ export class SessionStreamScheduler { this.emit(this.takeBackground()) } + private flushVisible(): void { + this.vtimer = null + this.visibleFirstQueuedAt = 0 + this.emit(this.takeVisible()) + } + private take(sessionID: string): PartUpdate[] { const queue = this.queues.get(sessionID) if (!queue) return [] @@ -276,20 +332,43 @@ export class SessionStreamScheduler { const updates: PartUpdate[] = [] for (const [sid, queue] of this.queues) { if (sid === this.active) continue + if (this.visible.has(sid)) continue updates.push(...queue.values()) this.queues.delete(sid) } return updates } + private takeVisible(): PartUpdate[] { + const updates: PartUpdate[] = [] + for (const [sid, queue] of this.queues) { + if (sid === this.active || !this.visible.has(sid)) continue + updates.push(...queue.values()) + this.queues.delete(sid) + } + return updates + } + + private visibleCount(): number { + let n = 0 + for (const [sid, queue] of this.queues) { + if (sid !== this.active && this.visible.has(sid) && queue.size > 0) n++ + } + return n + } + private backgroundCount(): number { let n = 0 for (const [sid, queue] of this.queues) { - if (sid !== this.active && queue.size > 0) n++ + if (sid !== this.active && !this.visible.has(sid) && queue.size > 0) n++ } return n } + private hasVisible(): boolean { + return this.visibleCount() > 0 + } + private hasBackground(): boolean { return this.backgroundCount() > 0 } @@ -315,12 +394,15 @@ export class SessionStreamScheduler { private countLane(sessionID: string): void { if (sessionID === this.active) this.counters.active++ + else if (this.visible.has(sessionID)) this.counters.visible++ else this.counters.background++ } private clearTimers(): void { if (this.atimer) clearTimeout(this.atimer) this.atimer = null + if (this.vtimer) clearTimeout(this.vtimer) + this.vtimer = null if (this.btimer) clearTimeout(this.btimer) this.btimer = null } diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts index b8fa13b3c0b..df8d576e030 100644 --- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts +++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts @@ -1,15 +1,20 @@ /** - * Pure data-transform helpers that strip heavy tool metadata from - * message parts before sending them to the webview via postMessage. + * Pure data-transform helpers that strip heavy message metadata before + * sending transcripts to the webview via postMessage. * * The webview communicates with the extension over VS Code's IPC bridge. - * Every message is JSON-serialised → deserialised on each side. Tool parts + * Every message is JSON-serialised → deserialised on each side. Tool parts * from edit, apply_patch, multiedit and write often carry full file contents - * (before/after snapshots, patch text, written content). Sending those on - * every session switch makes serialisation the dominant bottleneck. + * (before/after snapshots, patch text, written content). User message summaries + * and reasoning parts can also carry patches and encrypted provider metadata + * that the webview does not use. Sending those on every session switch makes + * serialisation the dominant bottleneck. * * This module strips fields the webview never (or rarely) needs while keeping - * everything required to render collapsed tool-part headers and diagnostics. + * everything required to render transcript summaries, tool details and + * diagnostics. It transforms outgoing webview copies only: backend session + * storage remains the source of truth for continuation, caching, forks and + * exports. * * No vscode dependency — safe to unit-test in isolation. */ @@ -181,6 +186,24 @@ function slimBash(state: Record): Record { // Public API // --------------------------------------------------------------------------- +/** Strip patches used only by the explicit turn-diff fetch from message summaries. */ +export function slimInfo(info: T): T { + if (!info || typeof info !== "object") return info + + const obj = info as Record + const summary = obj.summary + if (!isObj(summary) || !Array.isArray(summary.diffs)) return info + if (!summary.diffs.some((diff) => isObj(diff) && "patch" in diff)) return info + + const diffs = summary.diffs.map((diff) => { + if (!isObj(diff) || !("patch" in diff)) return diff + const next = { ...diff } + delete next.patch + return next + }) + return { ...obj, summary: { ...summary, diffs } } as T +} + const slimmers: Record) => Record> = { read: slimOutput, list: slimOutput, @@ -193,11 +216,24 @@ const slimmers: Record) => Record(part: T, obj: Record): T { + const meta = obj.metadata + if (!isObj(meta)) return part + const openai = meta.openai + if (!isObj(openai) || !("reasoningEncryptedContent" in openai)) return part + + const next = { ...openai } + delete next.reasoningEncryptedContent + return { ...obj, metadata: { ...meta, openai: next } } as T +} + +/** Strip heavy metadata from a single transcript part. */ export function slimPart(part: T): T { if (!part || typeof part !== "object") return part const obj = part as Record + if (obj.type === "reasoning") return slimReasoning(part, obj) if (obj.type !== "tool") return part const tool = obj.tool diff --git a/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts b/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts new file mode 100644 index 00000000000..83b97c3103e --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts @@ -0,0 +1,53 @@ +import type * as vscode from "vscode" + +type Message = { type?: unknown; sessionID?: unknown; visible?: unknown } + +function parse(message: unknown): Message | undefined { + if (!message || typeof message !== "object") return undefined + const msg = message as Message + if (msg.type !== "streamSessionVisible") return undefined + return msg +} + +export class VisibleTaskStreams { + private readonly refs = new Map() + private active = true + + constructor(private readonly set: (id: string, visible: boolean) => void) {} + + clear(): void { + for (const id of this.refs.keys()) this.set(id, false) + this.refs.clear() + } + + delete(id: string): void { + this.set(id, false) + this.refs.delete(id) + } + + setActive(active: boolean): void { + if (this.active === active) return + this.active = active + for (const id of this.refs.keys()) this.set(id, active) + } + + bindPanel(panel: vscode.WebviewPanel, focus: () => void): vscode.Disposable { + this.setActive(panel.active) + return panel.onDidChangeViewState(() => { + this.setActive(panel.active) + focus() + }) + } + + handle(message: unknown): boolean { + const msg = parse(message) + if (!msg) return false + if (typeof msg.sessionID !== "string" || typeof msg.visible !== "boolean") return true + const count = this.refs.get(msg.sessionID) ?? 0 + const next = msg.visible ? count + 1 : Math.max(0, count - 1) + if (next === 0) this.refs.delete(msg.sessionID) + else this.refs.set(msg.sessionID, next) + this.set(msg.sessionID, this.active && next > 0) + return true + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/work-style-apply-handler.ts b/packages/kilo-vscode/src/kilo-provider/work-style-apply-handler.ts new file mode 100644 index 00000000000..a8c6611eeeb --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/work-style-apply-handler.ts @@ -0,0 +1,59 @@ +import * as vscode from "vscode" +import type { Config } from "@kilocode/sdk/v2/client" +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import type { WorkStyle, WorkStyleConfig, WorkStyleState } from "../shared/work-style-presets" +import { applyWorkStyle, type WorkStyleSettingSnapshot } from "./work-style-apply" + +function inspect(config: vscode.WorkspaceConfiguration, key: string): WorkStyleSettingSnapshot { + const info = config.inspect(key) + return { + global: info?.globalValue, + customized: + info?.globalValue !== undefined || info?.workspaceValue !== undefined || info?.workspaceFolderValue !== undefined, + } +} + +async function apply(connection: KiloConnectionService, directory: string, style: WorkStyle) { + const settings = vscode.workspace.getConfiguration("kilo-code.new") + return applyWorkStyle(style, { + read: async () => { + const client = await connection.getClientAsync(directory) + const { data } = await client.config.get({ directory }, { throwOnError: true }) + return (data ?? {}) as WorkStyleConfig + }, + inspect: (key) => inspect(settings, key), + write: async (key, value) => { + await settings.update(key, value, vscode.ConfigurationTarget.Global) + }, + patch: async (config) => { + const client = await connection.getClientAsync(directory) + await client.global.config.update({ config: config as Config }, { throwOnError: true }) + }, + }) +} + +export async function handleWorkStyleApplyMessage(input: { + message: { type?: string; style?: WorkStyleState } + connection: KiloConnectionService + directory: string + post: (message: unknown) => void +}): Promise { + if (input.message.type !== "applyWorkStyle") return false + if (input.message.style !== "human-in-the-loop" && input.message.style !== "autonomous") { + console.error("[Kilo New] Invalid style in applyWorkStyle message") + input.post({ type: "workStyleApplyFailed", message: "Invalid work style", rollbackFailed: false }) + return true + } + + const result = await apply(input.connection, input.directory, input.message.style) + input.post( + result.ok + ? { type: "workStyleApplied", style: input.message.style } + : { + type: "workStyleApplyFailed", + message: result.error, + rollbackFailed: result.rollback.length > 0, + }, + ) + return true +} diff --git a/packages/kilo-vscode/src/kilo-provider/work-style-apply.ts b/packages/kilo-vscode/src/kilo-provider/work-style-apply.ts new file mode 100644 index 00000000000..52b40f29c38 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/work-style-apply.ts @@ -0,0 +1,63 @@ +import { + buildWorkStyleApplyPlan, + type WorkStyle, + type WorkStyleConfig, + type WorkStyleSettings, +} from "../shared/work-style-presets" + +type Setting = keyof WorkStyleSettings | "agentWorkStyle" + +export interface WorkStyleSettingSnapshot { + customized: boolean + global: unknown +} + +export interface WorkStyleStore { + read: () => Promise + inspect: (key: Setting) => WorkStyleSettingSnapshot + write: (key: Setting, value: unknown) => Promise + patch: (config: WorkStyleConfig) => Promise +} + +type WorkStyleApplyResult = + | { ok: true } + | { + ok: false + error: string + rollback: Setting[] + } + +function message(err: unknown): string { + if (err instanceof Error) return err.message + return String(err) +} + +export async function applyWorkStyle(style: WorkStyle, store: WorkStyleStore): Promise { + const completed: Array<{ key: Setting; value: unknown }> = [] + + try { + const config = await store.read() + const plan = buildWorkStyleApplyPlan({ + style, + config, + settingDefault: (key) => !store.inspect(key).customized, + }) + const writes: Array<{ key: Setting; value: unknown }> = [ + ...Object.entries(plan.settings).map(([key, value]) => ({ key: key as keyof WorkStyleSettings, value })), + { key: "agentWorkStyle", value: style }, + ] + + for (const write of writes) { + completed.push({ key: write.key, value: store.inspect(write.key).global }) + await store.write(write.key, write.value) + } + if (Object.keys(plan.config).length > 0) await store.patch(plan.config) + return { ok: true } + } catch (err) { + const rollback: Setting[] = [] + for (const write of [...completed].reverse()) { + await store.write(write.key, write.value).catch(() => rollback.push(write.key)) + } + return { ok: false, error: message(err), rollback } + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/work-style.ts b/packages/kilo-vscode/src/kilo-provider/work-style.ts new file mode 100644 index 00000000000..36edccb4782 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/work-style.ts @@ -0,0 +1,87 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import { getInitialWorkStyle, type WorkStyleState } from "../shared/work-style-presets" +import { handleWorkStyleApplyMessage } from "./work-style-apply-handler" + +export const WORK_STYLE_SETTING_KEYS = ["showTaskTimeline"] as const + +function getConfig() { + return vscode.workspace.getConfiguration("kilo-code.new") +} + +function isWorkStyleConfigured(): boolean { + return getConfig().inspect("agentWorkStyle")?.globalValue !== undefined +} + +export function getWorkStylePayload() { + return { + type: "workStyleLoaded" as const, + style: getConfig().get("agentWorkStyle", "unset"), + } +} + +export function isWorkStyleSetting(key: string): boolean { + return WORK_STYLE_SETTING_KEYS.includes(key as (typeof WORK_STYLE_SETTING_KEYS)[number]) || key === "agentWorkStyle" +} + +export function watchWorkStyleConfig(post: (message: unknown) => void, next?: vscode.Disposable) { + const keys = ["agentWorkStyle", ...WORK_STYLE_SETTING_KEYS] + const watcher = vscode.workspace.onDidChangeConfiguration((event) => { + if (keys.some((key) => event.affectsConfiguration(`kilo-code.new.${key}`))) post(getWorkStylePayload()) + }) + return next ? vscode.Disposable.from(watcher, next) : watcher +} + +export async function setWorkStyle(style: WorkStyleState) { + await getConfig().update("agentWorkStyle", style, vscode.ConfigurationTarget.Global) +} + +async function hasAnySession(connection: KiloConnectionService, directory: string): Promise { + const client = await connection.getClientAsync(directory) + const { data } = await client.experimental.session.list( + { + roots: true, + limit: 1, + archived: true, + }, + { throwOnError: true }, + ) + return data.length > 0 +} + +async function initializeWorkStyle(connection: KiloConnectionService, directory: string): Promise { + if (isWorkStyleConfigured()) return + + const hasSessions = await hasAnySession(connection, directory) + + if (isWorkStyleConfigured()) return + await setWorkStyle(getInitialWorkStyle(hasSessions)) +} + +export async function handleWorkStyleMessage(input: { + message: { type?: string; style?: WorkStyleState } + connection: KiloConnectionService + directory: string + post: (message: unknown) => void +}): Promise { + if (input.message.type === "requestWorkStyle") { + const initialized = await initializeWorkStyle(input.connection, input.directory) + .then(() => true) + .catch((err: unknown) => { + console.error("[Kilo New] Failed to initialize work style:", err) + return false + }) + const payload = getWorkStylePayload() + input.post(initialized ? payload : { ...payload, style: "skipped" }) + return true + } + if (await handleWorkStyleApplyMessage(input)) return true + if (input.message.type !== "setWorkStyle") return false + if (!input.message.style) { + console.error("[Kilo New] Missing style in setWorkStyle message") + return true + } + await setWorkStyle(input.message.style) + input.post(getWorkStylePayload()) + return true +} diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index fb0c4beebfc..5d00764d52f 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -144,6 +144,7 @@ export class KiloClawProvider implements vscode.Disposable { scriptUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "dist", "kiloclaw.js")), styleUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "dist", "kiloclaw.css")), iconsBaseUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "assets", "icons")), + workerUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "dist", "shiki-worker.js")), title: "KiloClaw", }) @@ -398,7 +399,7 @@ export class KiloClawProvider implements vscode.Disposable { return false } - this.attachEventHandlers(events, chat) + this.attachEventHandlers(events) this.subscribeSandboxContext() return true } @@ -510,7 +511,7 @@ export class KiloClawProvider implements vscode.Disposable { this.subscribedConversationContext = null } - private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void { + private attachEventHandlers(events: EventServiceClient): void { // Reset on reconnect — the event stream may have missed events while // disconnected, so refetch authoritative state. const offReconnect = events.onReconnect(() => { diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts index 5583b32af51..8d402dafe81 100644 --- a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -89,10 +89,6 @@ export class KiloChatClient { }) } - getConversation(conversationId: string): Promise { - return this.request(`/v1/conversations/${conversationId}`) - } - createConversation(req: { sandboxId: string title?: string diff --git a/packages/kilo-vscode/src/kiloclaw/token-manager.ts b/packages/kilo-vscode/src/kiloclaw/token-manager.ts index 745b9c7c3f8..93020e67d67 100644 --- a/packages/kilo-vscode/src/kiloclaw/token-manager.ts +++ b/packages/kilo-vscode/src/kiloclaw/token-manager.ts @@ -26,11 +26,6 @@ export class TokenManager { constructor(private readonly getClient: () => KiloClient | null) {} - /** Latest resolved token info (may be stale). Used for URL extraction. */ - peek(): ChatToken | null { - return this.cached - } - /** Drop the cached token; next `get` will refetch. */ clear(): void { this.cached = null diff --git a/packages/kilo-vscode/src/legacy-migration/migration-service.ts b/packages/kilo-vscode/src/legacy-migration/migration-service.ts index 7a2bb2036b0..9ac01ab8f4c 100644 --- a/packages/kilo-vscode/src/legacy-migration/migration-service.ts +++ b/packages/kilo-vscode/src/legacy-migration/migration-service.ts @@ -36,9 +36,11 @@ import type { MigrationSessionInfo, MigrationSessionProgress, } from "./legacy-types" -import { buildSessionMeta, buildSessionProgress } from "./migration-session-progress" import type { MigrationResultItem } from "./migration-types" +import { runSessionBatch } from "./session-batch" +import { listSessions, resolveSession, scanTaskStore } from "./task-store" import { createSessionID } from "./sessions/lib/ids" +import type { LegacyHistoryItem } from "./sessions/lib/legacy-types" import { migrate as migrateSession } from "./sessions/migrate" // --------------------------------------------------------------------------- @@ -77,7 +79,7 @@ export async function detectLegacyData(context: vscode.ExtensionContext): Promis const customModes = await readLegacyCustomModes(context) const prompts = readLegacyCustomModePrompts(context) const settings = readLegacySettings(context) - const sessions = await readSessionsInGlobalStorage(context) + const sessions = listSessions(await readSessionCatalog(context)) const oauthProviders = new Set() const codexRaw = await context.secrets.get(CODEX_OAUTH_SECRET_KEY) @@ -116,28 +118,10 @@ export async function detectLegacyData(context: vscode.ExtensionContext): Promis } } -async function readSessionsInGlobalStorage(context: vscode.ExtensionContext) { - const items = context.globalState.get<{ id: string; task?: string; workspace?: string; ts?: number }[]>( - "taskHistory", - [], - ) - const base = vscode.Uri.joinPath(context.globalStorageUri, "tasks") - const sessions: MigrationSessionInfo[] = [] - for (const item of items) { - const file = vscode.Uri.joinPath(base, item.id, "api_conversation_history.json") - const exists = await vscode.workspace.fs.stat(file).then( - () => true, - () => false, - ) - if (!exists) continue - sessions.push({ - id: item.id, - title: item.task?.trim() || item.id, - directory: item.workspace?.trim() || "", - time: item.ts ?? 0, - }) - } - return sessions +async function readSessionCatalog(context: vscode.ExtensionContext) { + const items = context.globalState.get("taskHistory", []) + const dir = vscode.Uri.joinPath(context.globalStorageUri, "tasks").fsPath + return (await scanTaskStore(dir, items, { mode: "history" })).catalog } // --------------------------------------------------------------------------- @@ -152,9 +136,6 @@ export type ProgressCallback = ( export type SessionProgressCallback = (progress: MigrationSessionProgress) => void -const SESSION_DELAY = 300 -const SESSION_SUMMARY_DELAY = 1000 - /** * Executes migration for the selected items. * Calls onProgress for each item with real-time status updates. @@ -176,7 +157,8 @@ export async function migrate( const customModes = await readLegacyCustomModes(context) const prompts = readLegacyCustomModePrompts(context) const legacySettings = cachedSettings ?? readLegacySettings(context) - const sessions = cachedSessions ?? (await readSessionsInGlobalStorage(context)) + const catalog = await readSessionCatalog(context) + const sessions = cachedSessions ?? listSessions(catalog) const results: MigrationResultItem[] = [] @@ -277,36 +259,16 @@ export async function migrate( } if (selections.sessions?.length) { - const list = selections.sessions - for (const [index, item] of list.entries()) { - onProgress(item.id, "migrating") - const session = sessions.find((entry: MigrationSessionInfo) => entry.id === item.id) - const meta = buildSessionMeta(session, index, list.length) - const progress = buildSessionProgress(meta, onSessionProgress) - const result = await migrateSession(item, context, client, meta, progress) - const reason = result.ok ? "Session migrated" : result.message - results.push({ - item: item.id, - category: "session", - status: result.ok ? "success" : "error", - message: reason, - }) - onProgress(item.id, result.ok ? "success" : "error", reason) - if (index < list.length - 1) { - await new Promise((resolve) => setTimeout(resolve, SESSION_DELAY)) - } - } - const last = list.at(-1) - const session = last ? sessions.find((item: MigrationSessionInfo) => item.id === last.id) : undefined - if (session && onSessionProgress) { - onSessionProgress({ - session, - index: list.length, - total: list.length, - phase: "summary", - }) - await new Promise((resolve) => setTimeout(resolve, SESSION_SUMMARY_DELAY)) - } + results.push( + ...(await runSessionBatch({ + selections: selections.sessions, + sessions, + resolve: (id) => resolveSession(catalog, id), + migrate: (selection, source, progress) => migrateSession(selection, context, client, progress, source), + onProgress, + onSessionProgress, + })), + ) } // Migrate default model diff --git a/packages/kilo-vscode/src/legacy-migration/session-batch.ts b/packages/kilo-vscode/src/legacy-migration/session-batch.ts new file mode 100644 index 00000000000..4fb009b4037 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/session-batch.ts @@ -0,0 +1,63 @@ +import type { MigrationSessionInfo, MigrationSessionProgress, MigrationSessionSelection } from "./legacy-types" +import type { MigrationResultItem } from "./migration-types" +import { buildSessionMeta, buildSessionProgress } from "./migration-session-progress" +import type { SessionSource } from "./task-store" +import type { migrate as migrateSession } from "./sessions/migrate" + +const DELAY = 300 +const SUMMARY_DELAY = 1000 + +type Result = Awaited> + +interface BatchOptions { + selections: MigrationSessionSelection[] + sessions: MigrationSessionInfo[] + resolve(id: string): SessionSource | undefined + migrate( + selection: MigrationSessionSelection, + source: SessionSource, + progress: ReturnType, + ): Promise + onProgress(item: string, status: "migrating" | "success" | "warning" | "error", message?: string): void + onSessionProgress?: (progress: MigrationSessionProgress) => void + delay?: (ms: number) => Promise +} + +export async function runSessionBatch(options: BatchOptions): Promise { + const results: MigrationResultItem[] = [] + const wait = options.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + + for (const [index, selection] of options.selections.entries()) { + options.onProgress(selection.id, "migrating") + const session = options.sessions.find((entry) => entry.id === selection.id) + const source = options.resolve(selection.id) + if (!source) { + const message = "Session source not found" + results.push({ item: session?.title ?? selection.id, category: "session", status: "error", message }) + options.onProgress(selection.id, "error", message) + continue + } + + const meta = buildSessionMeta(session, index, options.selections.length) + const result = await options.migrate(selection, source, buildSessionProgress(meta, options.onSessionProgress)) + const status = result.ok ? (result.skipped ? "warning" : "success") : "error" + const message = result.ok ? (result.skipped ? "Already imported." : undefined) : result.message + results.push({ item: session?.title ?? selection.id, category: "session", status, message }) + options.onProgress(selection.id, status, message) + if (index < options.selections.length - 1) await wait(DELAY) + } + + const last = options.selections.at(-1) + const session = last ? options.sessions.find((entry) => entry.id === last.id) : undefined + if (session && options.onSessionProgress) { + options.onSessionProgress({ + session, + index: options.selections.length, + total: options.selections.length, + phase: "summary", + }) + await wait(SUMMARY_DELAY) + } + + return results +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts index 725aacf6059..123ae58cfff 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts @@ -7,10 +7,11 @@ export function createSession( item: LegacyHistoryItem | undefined, projectID: string, dir: string, + key = id, ): NonNullable { const session = makeSession() - session.id = createSessionID(id) + session.id = createSessionID(key) session.projectID = projectID diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts b/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts index a77ca130fa2..1dfd2a6eb59 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts @@ -1,8 +1,9 @@ import * as vscode from "vscode" import type { KiloClient } from "@kilocode/sdk/v2/client" import { getMigrationErrorMessage } from "../errors/migration-error" -import type { MigrationSessionInfo, MigrationSessionProgress, MigrationSessionSelection } from "../legacy-types" +import type { MigrationSessionProgress, MigrationSessionSelection } from "../legacy-types" import { createSessionID } from "./lib/ids" +import type { SessionSource } from "../task-store" import type { LegacyHistoryItem } from "./lib/legacy-types" import { parseSession } from "./parser" @@ -30,19 +31,19 @@ export async function migrate( input: MigrationSessionSelection, context: vscode.ExtensionContext, client: KiloClient, - meta?: { - session: MigrationSessionInfo - index: number - total: number - }, onProgress?: ProgressCallback, + resolved?: SessionSource, ): Promise { - const dir = vscode.Uri.joinPath(context.globalStorageUri, "tasks").fsPath const items = context.globalState.get("taskHistory", []) - const item = items.find((item) => item.id === input.id) + const source = resolved ?? { + id: input.id, + dir: vscode.Uri.joinPath(context.globalStorageUri, "tasks").fsPath, + item: items.find((item) => item.id === input.id), + } + const key = source.namespace ? `${source.namespace}:${source.id}` : source.id const progress = (next: Progress) => { - if (!meta || !onProgress) return + if (!onProgress) return onProgress(next) } @@ -68,12 +69,12 @@ export async function migrate( try { if (!input.force) { - const result = await client.session.get({ sessionID: createSessionID(input.id) }) + const result = await client.session.get({ sessionID: createSessionID(key) }) if (result.data) return skip() } progress({ phase: "preparing" }) - const payload = await parseSession(input.id, dir, item) + const payload = await parseSession(source.id, source.dir, source.item, undefined, key) progress({ phase: "storing" }) const project = await client.kilocode.sessionImport.project(payload.project, { throwOnError: true }) const projectID = project.data?.id ?? payload.project.id diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts b/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts index 966ba46bf41..57683c6d119 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts @@ -1,4 +1,4 @@ -import type { LegacyHistoryItem } from "./lib/legacy-types" +import type { LegacyApiMessage, LegacyHistoryItem } from "./lib/legacy-types" import type { KilocodeSessionImportMessageData as Message, KilocodeSessionImportPartData as Part, @@ -19,15 +19,20 @@ export interface NormalizedSession { parts: Array> } -export async function parseSession(id: string, dir: string, item?: LegacyHistoryItem): Promise { +export async function parseSession( + id: string, + dir: string, + item?: LegacyHistoryItem, + input?: LegacyApiMessage[], + key = id, +): Promise { const root = await normalizeLegacyPath(item?.workspace) const next = item ? { ...item, workspace: root } : undefined const project = createProject(next) - const session = createSession(id, next, project.id, root) - const file = await getApiConversationHistory(id, dir) - const conversation = parseFile(file) - const messages = parseMessagesFromConversation(conversation, id, root, next) - const parts = parsePartsFromConversation(conversation, id, item) + const session = createSession(id, next, project.id, root, key) + const conversation = input ?? parseFile(await getApiConversationHistory(id, dir)) + const messages = parseMessagesFromConversation(conversation, key, root, next) + const parts = parsePartsFromConversation(conversation, key, item) return { project, diff --git a/packages/kilo-vscode/src/legacy-migration/task-store.ts b/packages/kilo-vscode/src/legacy-migration/task-store.ts new file mode 100644 index 00000000000..73daad0604f --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/task-store.ts @@ -0,0 +1,244 @@ +import * as path from "node:path" +import * as vscode from "vscode" +import type { MigrationSessionInfo } from "./legacy-types" +import type { LegacyHistoryItem } from "./sessions/lib/legacy-types" + +const API_FILE = "api_conversation_history.json" +const UI_FILE = "ui_messages.json" + +export interface SessionSource { + id: string + dir: string + item?: LegacyHistoryItem + namespace?: string + mtime?: number +} + +export interface SessionEntry { + id: string + session: MigrationSessionInfo + source: SessionSource +} + +export type SessionCatalog = Map + +export interface ScanDiagnostic { + id: string + dir: string + reason: "ui-only" | "malformed" | "missing-workspace" +} + +export interface TaskScan { + catalog: SessionCatalog + diagnostics: ScanDiagnostic[] +} + +export function listSessions(catalog: SessionCatalog) { + return [...catalog.values()].map((entry) => entry.session).sort((a, b) => b.time - a.time || a.id.localeCompare(b.id)) +} + +export function resolveSession(catalog: SessionCatalog, id: string) { + return catalog.get(id)?.source +} + +export type ScanMode = "history" | "discover" + +export interface ScanOptions { + namespace?: string + /** + * "history" trusts the provided history items and only checks that each task's + * conversation file still exists (cheap stat, used by legacy migration). + * "discover" enumerates every task directory on disk and parses conversation + * files to recover titles (used when no history is available, e.g. Roo import). + */ + mode?: ScanMode +} + +export async function scanTaskStore( + dir: string, + items: LegacyHistoryItem[] = [], + options: ScanOptions = {}, +): Promise { + const mode = options.mode ?? (items.length ? "history" : "discover") + return mode === "history" + ? scanFromHistory(dir, items, options.namespace) + : scanFromDisk(dir, items, options.namespace) +} + +/** Builds a catalog from known history items, only confirming each conversation file exists. */ +async function scanFromHistory(dir: string, items: LegacyHistoryItem[], namespace?: string): Promise { + const catalog: SessionCatalog = new Map() + + for (const item of items) { + if (catalog.has(item.id)) continue + if (!(await exists(path.join(dir, item.id, API_FILE)))) continue + catalog.set(item.id, { + id: item.id, + session: { + id: item.id, + title: item.task?.trim() || fallbackTitle(item.id), + directory: item.workspace?.trim() || "", + time: item.ts ?? timestamp(item.id), + }, + source: { id: item.id, dir, item, namespace }, + }) + } + + return { catalog, diagnostics: [] } +} + +/** Enumerates every task directory on disk and parses conversation files to recover titles. */ +async function scanFromDisk(dir: string, items: LegacyHistoryItem[], namespace?: string): Promise { + const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(dir)).then( + (value) => value, + () => [] as [string, vscode.FileType][], + ) + const history = new Map(items.map((item) => [item.id, item])) + const catalog: SessionCatalog = new Map() + const diagnostics: ScanDiagnostic[] = [] + + for (const [id, type] of entries.sort(([a], [b]) => a.localeCompare(b))) { + if (type !== vscode.FileType.Directory) continue + const root = path.join(dir, id) + const api = path.join(root, API_FILE) + const valid = await readHistory(api) + if (!valid.exists) { + if (await exists(path.join(root, UI_FILE))) diagnostics.push({ id, dir, reason: "ui-only" }) + continue + } + if (!valid.valid) { + diagnostics.push({ id, dir, reason: "malformed" }) + continue + } + + const item = await readHistoryItem(root, id, valid.data, history.get(id)) + const workspace = item.workspace?.trim() + if (!workspace) { + diagnostics.push({ id, dir, reason: "missing-workspace" }) + continue + } + catalog.set(id, { + id, + session: { + id, + title: item.task?.trim() || fallbackTitle(id), + directory: workspace, + time: item.ts ?? timestamp(id), + }, + source: { id, dir, item, namespace, mtime: valid.mtime }, + }) + } + + return { catalog, diagnostics } +} + +export async function readTaskIndex(dir: string): Promise { + return Promise.resolve(vscode.workspace.fs.readFile(vscode.Uri.file(path.join(dir, "_index.json")))) + .then((bytes) => { + const json = JSON.parse(Buffer.from(bytes).toString("utf8")) as { entries?: unknown } + if (!Array.isArray(json.entries)) return [] + return json.entries.flatMap((value) => { + if (!value || typeof value !== "object") return [] + const item = value as Record + if (typeof item.id !== "string") return [] + return [parseRecord(item, item.id)] + }) + }) + .catch(() => []) +} + +async function readHistory(file: string) { + const uri = vscode.Uri.file(file) + return Promise.all([vscode.workspace.fs.readFile(uri), vscode.workspace.fs.stat(uri)]) + .then(([bytes, stat]) => { + const json = JSON.parse(Buffer.from(bytes).toString("utf8")) as unknown + return { + exists: true as const, + valid: Array.isArray(json), + data: Array.isArray(json) ? json : [], + mtime: stat.mtime, + } + }) + .catch((error) => { + if (error instanceof SyntaxError) { + return { exists: true as const, valid: false, data: [] as unknown[], mtime: 0 } + } + return { exists: false as const, valid: false, data: [] as unknown[], mtime: 0 } + }) +} + +async function readHistoryItem( + root: string, + id: string, + messages: unknown[], + indexed?: LegacyHistoryItem, +): Promise { + const file = path.join(root, "history_item.json") + const stored = await Promise.resolve(vscode.workspace.fs.readFile(vscode.Uri.file(file))) + .then((bytes) => parseItem(Buffer.from(bytes).toString("utf8"), id)) + .catch(() => undefined) + if (stored) return stored + if (indexed) return indexed + + return { + id, + task: titleFromMessages(messages) || fallbackTitle(id), + workspace: "", + ts: timestamp(id), + } +} + +function parseItem(input: string, id: string): LegacyHistoryItem | undefined { + return parseRecord(JSON.parse(input) as Record, id) +} + +function parseRecord(json: Record, id: string): LegacyHistoryItem { + return { + id, + task: typeof json.task === "string" ? json.task.trim().slice(0, 120) : fallbackTitle(id), + workspace: typeof json.workspace === "string" ? json.workspace : "", + ts: typeof json.ts === "number" ? json.ts : timestamp(id), + mode: typeof json.mode === "string" ? json.mode : undefined, + rootTaskId: typeof json.rootTaskId === "string" ? json.rootTaskId : undefined, + parentTaskId: typeof json.parentTaskId === "string" ? json.parentTaskId : undefined, + } +} + +function titleFromMessages(messages: unknown[]) { + for (const value of messages) { + if (!value || typeof value !== "object") continue + const msg = value as { role?: string; content?: unknown } + if (msg.role !== "user") continue + const text = textFromContent(msg.content) + if (text) return text.slice(0, 120).replace(/\n/g, " ").trim() + } + return "" +} + +function textFromContent(content: unknown): string { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + for (const value of content) { + if (!value || typeof value !== "object") continue + const block = value as { type?: string; text?: unknown } + if (block.type === "text" && typeof block.text === "string" && block.text.trim()) return block.text + } + return "" +} + +function exists(file: string) { + return vscode.workspace.fs.stat(vscode.Uri.file(file)).then( + () => true, + () => false, + ) +} + +function timestamp(id: string) { + const value = Number(id) + return Number.isFinite(value) && value > 1_000_000_000_000 ? value : 0 +} + +function fallbackTitle(id: string) { + const time = timestamp(id) + return time ? new Date(time).toLocaleString() : id +} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index cae5ba1e0c8..742380b5604 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -9,7 +9,7 @@ import { sanitizeCustomProviderConfig, withCustomProviderDeletions, } from "./shared/custom-provider" -import { CUSTOM_PROVIDER_PACKAGE, KILO_AUTO, parseModelString } from "./shared/provider-model" +import { isCustomProviderPackage, KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "./shared/provider-model" import { configFeatures } from "./features" /** @@ -18,6 +18,12 @@ import { configFeatures } from "./features" */ type AuthState = "api" | "oauth" | "wellknown" +/** API key retained extension-side for authenticated model fetches (#10139). */ +export interface StoredProviderKey { + key: string + baseURL: string +} + function disabledWithout(list: string[] | undefined, id: string) { return (list ?? []).filter((item) => item !== id) } @@ -27,7 +33,7 @@ function record(value: unknown): value is Record { } function customProvider(config: unknown) { - return record(config) && config.npm === CUSTOM_PROVIDER_PACKAGE + return record(config) && isCustomProviderPackage(config.npm) } function same(a: unknown, b: unknown): boolean { @@ -44,7 +50,7 @@ function same(a: unknown, b: unknown): boolean { return akeys.every((key, index) => key === bkeys[index] && same(a[key], b[key])) } -/** Fetch auth methods alongside the provider list. Auth states default to empty (endpoint not yet available). */ +/** Fetch provider availability and authentication state without exposing stored credentials. */ export async function fetchProviderData(client: KiloClient, dir: string) { const authRequest = typeof client.provider.auth === "function" @@ -53,23 +59,56 @@ export async function fetchProviderData(client: KiloClient, dir: string) { .then((r) => r.data ?? {}) .catch(() => ({})) : Promise.resolve({}) + const kiloRequest = client.kilo + .authStatus({ directory: dir }, { throwOnError: true }) + .then((r) => (r.data?.authenticated ? (r.data.type ?? null) : null)) + .catch(() => null) - const [{ data: response }, authMethods] = await Promise.all([ + const [{ data: response }, authMethods, kiloAuth] = await Promise.all([ client.provider.list({ directory: dir }, { throwOnError: true }), authRequest, + kiloRequest, ]) const authStates: Record = {} + const storedKeys: Record = {} const all = response.all.map((item) => { const raw = item as Record if (typeof raw.id === "string" && typeof raw.key === "string" && raw.key) { authStates[raw.id] = "api" + // Retain the key on the extension side so model fetches for an existing + // provider can authenticate without the webview ever seeing the secret + // (#10139). Only providers with a configured baseURL are retained — the + // fetch handler requires a URL match before applying a stored key. + const options = record(raw.options) ? raw.options : undefined + const baseURL = options && typeof options.baseURL === "string" ? options.baseURL : undefined + if (baseURL) storedKeys[raw.id] = { key: raw.key, baseURL } } if (!("key" in raw)) return item const next = { ...raw } delete next.key return next as (typeof response.all)[number] }) - return { response: { ...response, all }, authMethods, authStates } + delete authStates[KILO_PROVIDER_ID] + if (kiloAuth) authStates[KILO_PROVIDER_ID] = kiloAuth + return { response: { ...response, all }, authMethods, authStates, storedKeys } +} + +/** + * Resolve the stored API key for a model fetch on an existing provider. + * The key is only applied when the requested URL matches the provider's + * configured baseURL, so a stored secret can never be redirected to a + * different host (e.g. after the user edits the URL field). + */ +export function resolveStoredKey( + storedKeys: Record, + providerID: unknown, + url: string, +): string | undefined { + if (typeof providerID !== "string" || !providerID) return undefined + const stored = storedKeys[providerID] + if (!stored) return undefined + const normalize = (value: string) => value.trim().replace(/\/+$/, "") + return normalize(stored.baseURL) === normalize(url) ? stored.key : undefined } export function buildActionContext( diff --git a/packages/kilo-vscode/src/review-utils.ts b/packages/kilo-vscode/src/review-utils.ts index 42dac76b284..c0d7eccbeda 100644 --- a/packages/kilo-vscode/src/review-utils.ts +++ b/packages/kilo-vscode/src/review-utils.ts @@ -23,15 +23,17 @@ export function openFileInEditor( prefix = "Kilo", ): void { const uri = vscode.Uri.file(filePath) - const target = Math.max(1, Math.floor(line ?? 1)) - const col = column !== undefined && column > 0 ? column - 1 : 0 - const pos = new vscode.Position(target - 1, col) - const selection = new vscode.Range(pos, pos) + const options: vscode.TextDocumentShowOptions = { viewColumn, preview: true } + if (line !== undefined && line > 0) { + const target = Math.max(1, Math.floor(line)) + const col = column !== undefined && column > 0 ? column - 1 : 0 + const pos = new vscode.Position(target - 1, col) + options.selection = new vscode.Range(pos, pos) + } - vscode.workspace.openTextDocument(uri).then( - (doc) => vscode.window.showTextDocument(doc, { viewColumn, preview: true, selection }), - (err) => console.error(`[Kilo New] ${prefix}: Failed to open file:`, uri.fsPath, err), - ) + void vscode.commands + .executeCommand("vscode.open", uri, options) + .then(undefined, (err) => console.error(`[Kilo New] ${prefix}: Failed to open file:`, uri.fsPath, err)) } export function openWorkspaceRelativeFile(relativePath: string, line?: number, column?: number): void { diff --git a/packages/kilo-vscode/src/roo-import/service.ts b/packages/kilo-vscode/src/roo-import/service.ts new file mode 100644 index 00000000000..1225fc7d540 --- /dev/null +++ b/packages/kilo-vscode/src/roo-import/service.ts @@ -0,0 +1,64 @@ +import * as path from "node:path" +import * as vscode from "vscode" +import { + listSessions, + readTaskIndex, + scanTaskStore, + type ScanDiagnostic, + type SessionCatalog, + type SessionEntry, +} from "../legacy-migration/task-store" + +const ROOTS = [ + "roovscode.roo-cline", + "roovscode.roo-code", + "rooveterinaryinc.roo-cline", + "rooveterinaryinc.roo-code", + "rooveterinaryinc.roo-code-nightly", +] + +export interface RooImportSource { + catalog: SessionCatalog + sessions: ReturnType + diagnostics: ScanDiagnostic[] +} + +/** Scans every known Roo storage root and keeps the most recent, complete copy of duplicate task IDs. */ +export async function detectRooCodeSessions( + context: vscode.ExtensionContext, + customPath?: string, +): Promise { + const parent = path.dirname(context.globalStorageUri.fsPath) + const configured = customPath ?? vscode.workspace.getConfiguration("roo-cline").get("customStoragePath", "") + const custom = typeof configured === "string" ? configured.trim() : "" + const roots = [ + ...ROOTS.map((root) => path.join(parent, root, "tasks")), + ...(custom ? [path.join(custom, "tasks")] : []), + ] + const dirs = [...new Map(roots.map((dir) => [path.resolve(dir), dir])).values()] + const catalog: SessionCatalog = new Map() + const diagnostics: ScanDiagnostic[] = [] + + for (const dir of dirs) { + const items = await readTaskIndex(dir) + const scan = await scanTaskStore(dir, items, { namespace: "roo", mode: "discover" }) + diagnostics.push(...scan.diagnostics) + for (const [id, entry] of [...scan.catalog].sort(([a], [b]) => a.localeCompare(b))) { + const current = catalog.get(id) + if (!current || compare(entry, current) >= 0) catalog.set(id, entry) + } + } + + for (const diagnostic of diagnostics) { + console.warn(`[Kilo New] Roo import skipped ${diagnostic.reason} task ${diagnostic.id} in ${diagnostic.dir}`) + } + + const sessions = listSessions(catalog) + return sessions.length ? { catalog, sessions, diagnostics } : null +} + +function compare(a: SessionEntry, b: SessionEntry) { + const complete = (entry: SessionEntry) => + Number(Boolean(entry.source.item?.task?.trim())) + Number(Boolean(entry.source.item?.mode)) + return (a.source.mtime ?? 0) - (b.source.mtime ?? 0) || a.session.time - b.session.time || complete(a) - complete(b) +} diff --git a/packages/kilo-vscode/src/services/attention/index.ts b/packages/kilo-vscode/src/services/attention/index.ts new file mode 100644 index 00000000000..a621bf6e22b --- /dev/null +++ b/packages/kilo-vscode/src/services/attention/index.ts @@ -0,0 +1 @@ +export { AttentionService, previewSound } from "./service" diff --git a/packages/kilo-vscode/src/services/attention/service.ts b/packages/kilo-vscode/src/services/attention/service.ts new file mode 100644 index 00000000000..e70c1310dcf --- /dev/null +++ b/packages/kilo-vscode/src/services/attention/service.ts @@ -0,0 +1,138 @@ +import * as vscode from "vscode" +import type { TuiAttentionSoundName } from "@kilocode/plugin/tui" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" +import type { KiloConnectionService } from "../cli-backend/connection-service" +import { playSound, resolveSoundID } from "./sound" + +type Sync = Extract +type Question = Extract +type Permission = Extract +type Asked = Extract +type Status = Extract +type Close = Extract +type Error = Extract + +type Options = { + approve?: (event: Asked, directory?: string) => boolean | Promise +} + +export function previewSound(value: string) { + void playSound("default", resolveSoundID(value)) +} + +export class AttentionService implements vscode.Disposable { + private readonly active = new Set() + private readonly errored = new Set() + private readonly questions = new Set() + private readonly permissions = new Set() + private readonly unsubscribeEvent: () => void + private readonly unsubscribeState: () => void + + constructor( + connection: KiloConnectionService, + private readonly opts: Options = {}, + ) { + this.unsubscribeEvent = connection.onEvent((event, directory) => this.handle(event, directory)) + this.unsubscribeState = connection.onStateChange((state) => { + if (state === "error" || state === "disconnected") this.reset() + }) + } + + dispose() { + this.unsubscribeEvent() + this.unsubscribeState() + this.reset() + } + + private handle(event: SSEPayload, directory?: string) { + if (event.type === "sync") return this.sync(event) + if (event.type === "question.asked" || event.type === "question.replied" || event.type === "question.rejected") { + return this.question(event) + } + if (event.type === "permission.asked" || event.type === "permission.replied") { + return this.permission(event, directory) + } + if (event.type === "session.deleted") return this.remove(event.properties.sessionID) + if (event.type === "session.status") return this.status(event) + if (event.type === "session.turn.close") return this.close(event) + if (event.type === "session.error") return this.error(event) + } + + private sync(event: Sync) { + if (event.name !== "session.deleted.1") return + this.remove(event.data.sessionID) + } + + private remove(sessionID: string) { + this.active.delete(sessionID) + this.errored.delete(sessionID) + } + + private question(event: Question) { + if (event.type !== "question.asked") { + this.questions.delete(event.properties.requestID) + return + } + if (this.questions.has(event.properties.id)) return + this.questions.add(event.properties.id) + this.notify("question") + } + + private permission(event: Permission, directory?: string) { + if (event.type !== "permission.asked") { + this.permissions.delete(event.properties.requestID) + return + } + const id = event.properties.id + if (this.permissions.has(id)) return + this.permissions.add(id) + const alert = () => { + if (!this.permissions.has(id)) return + this.notify("permission") + } + const approval = this.opts.approve?.(event, directory) + if (approval === true) return + if (approval === false || approval === undefined) return alert() + void approval.then((handled) => { + if (!handled) alert() + }, alert) + } + + private status(event: Status) { + const sessionID = event.properties.sessionID + if (event.properties.status.type !== "busy" && event.properties.status.type !== "retry") return + this.active.add(sessionID) + this.errored.delete(sessionID) + } + + private close(event: Close) { + const sessionID = event.properties.sessionID + if (!this.active.delete(sessionID)) return + if (this.errored.delete(sessionID)) return + if (event.properties.reason !== "completed") return + if (event.properties.parentID !== undefined) return + this.notify("done") + } + + private error(event: Error) { + const sessionID = event.properties.sessionID + if (!sessionID || !this.active.has(sessionID)) return + this.errored.add(sessionID) + if (event.properties.error?.name === "MessageAbortedError") return + this.notify("error") + } + + private notify(sound: TuiAttentionSoundName) { + const config = vscode.workspace.getConfiguration("kilo-code.new.attention") + if (!config.get("enabled", false)) return + const selected = resolveSoundID(config.get("sound", "default")) + void playSound(sound, selected) + } + + private reset() { + this.active.clear() + this.errored.clear() + this.questions.clear() + this.permissions.clear() + } +} diff --git a/packages/kilo-vscode/src/services/attention/sound.ts b/packages/kilo-vscode/src/services/attention/sound.ts new file mode 100644 index 00000000000..df8ceff1a53 --- /dev/null +++ b/packages/kilo-vscode/src/services/attention/sound.ts @@ -0,0 +1,163 @@ +import * as fs from "fs" +import * as path from "path" +import type { TuiAttentionSoundName } from "@kilocode/plugin/tui" +import { exec } from "../../util/process" + +export const CustomSoundIDs = [ + "alert-01", + "alert-02", + "alert-03", + "alert-04", + "alert-05", + "alert-06", + "alert-07", + "alert-08", + "alert-09", + "alert-10", + "bip-bop-01", + "bip-bop-02", + "bip-bop-03", + "bip-bop-04", + "bip-bop-05", + "bip-bop-06", + "bip-bop-07", + "bip-bop-08", + "bip-bop-09", + "bip-bop-10", + "staplebops-01", + "staplebops-02", + "staplebops-03", + "staplebops-04", + "staplebops-05", + "staplebops-06", + "staplebops-07", + "nope-01", + "nope-02", + "nope-03", + "nope-04", + "nope-05", + "nope-06", + "nope-07", + "nope-08", + "nope-09", + "nope-10", + "nope-11", + "nope-12", + "yup-01", + "yup-02", + "yup-03", + "yup-04", + "yup-05", + "yup-06", +] as const + +export type CustomSoundID = (typeof CustomSoundIDs)[number] +export type AttentionSoundID = "default" | "system" | CustomSoundID + +const ids = new Set(CustomSoundIDs) +const files: Record = { + default: "bip-bop-01", + question: "bip-bop-03", + permission: "staplebops-06", + error: "nope-03", + done: "bip-bop-01", + subagent_done: "yup-01", +} + +const root = path.join(__dirname, "../audio-wav") +let chain = Promise.resolve(false) +let queued = 0 +const limit = 3 + +export function resolveSoundID(value: string | undefined): AttentionSoundID { + if (value === "system" || value === "default" || (value && ids.has(value))) return value as AttentionSoundID + return "default" +} + +async function run(commands: Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }>) { + for (const command of commands) { + const ok = await exec(command.cmd, command.args, command.env ? { env: command.env } : {}).then( + () => true, + (error) => { + console.debug("[Kilo New] notification sound command failed", { cmd: command.cmd, error }) + return false + }, + ) + if (ok) return true + } + return false +} + +function systemCommands(): Array<{ cmd: string; args: string[] }> { + if (process.platform === "darwin") return [{ cmd: "osascript", args: ["-e", "beep"] }] + if (process.platform === "linux") { + return [ + { cmd: "canberra-gtk-play", args: ["-i", "message-new-instant"] }, + { cmd: "paplay", args: ["/usr/share/sounds/freedesktop/stereo/message.oga"] }, + ] + } + if (process.platform === "win32") { + return [ + { + cmd: "powershell", + args: ["-NoProfile", "-NonInteractive", "-Command", "[System.Media.SystemSounds]::Exclamation.Play()"], + }, + ] + } + return [] +} + +function fileCommands(file: string): Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }> { + if (process.platform === "darwin") { + return [ + { cmd: "afplay", args: [file] }, + { cmd: "play", args: [file] }, + ] + } + if (process.platform === "linux") { + return [ + { cmd: "aplay", args: [file] }, + { cmd: "paplay", args: [file] }, + { cmd: "play", args: [file] }, + ] + } + if (process.platform === "win32") { + return [ + { + cmd: "powershell", + args: [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$sound = [System.Media.SoundPlayer]::new($env:KILO_SOUND_PATH); $sound.PlaySync(); $sound.Dispose()", + ], + env: { ...process.env, KILO_SOUND_PATH: file }, + }, + ] + } + return [] +} + +async function perform(name: TuiAttentionSoundName, selected: AttentionSoundID, dir: string) { + if (selected === "system") return run(systemCommands()) + const id = selected === "default" ? files[name] : selected + const file = path.resolve(dir, `${id}.wav`) + if (!file.startsWith(`${path.resolve(dir)}${path.sep}`)) return false + if (!fs.existsSync(file)) { + console.warn("[Kilo New] notification sound is missing", { file }) + return false + } + const ok = await run(fileCommands(file)) + if (ok) console.debug("[Kilo New] notification sound played", { name, selected }) + return ok +} + +export async function playSound(name: TuiAttentionSoundName, selected: AttentionSoundID = "default", dir = root) { + if (queued >= limit) return false + queued += 1 + const task = chain.catch(() => false).then(() => perform(name, selected, dir)) + chain = task.finally(() => { + queued -= 1 + }) + return task +} diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index a272f677372..7a2eeaebbaf 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -6,6 +6,10 @@ import { AutocompleteStatusBar } from "./AutocompleteStatusBar" import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider" import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider" import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry" +import { NextEditInlineCompletionProvider } from "./next-edit/NextEditInlineCompletionProvider" +import { disposeLog } from "./next-edit/log" +import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager" +import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" import type { KiloConnectionService } from "../cli-backend" import { hasValidCredentials } from "./fim" import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" @@ -23,11 +27,13 @@ export interface AutocompleteServiceSettings { function readSettings(): AutocompleteServiceSettings { const config = vscode.workspace.getConfiguration(CONFIG_SECTION) + const info = getAutocompleteModel(config.get("provider"), config.get("model")) return { enableAutoTrigger: config.get("enableAutoTrigger") ?? true, enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding") ?? true, enableChatAutocomplete: config.get("enableChatAutocomplete") ?? true, - model: getAutocompleteModel(config.get("model") ?? "").id, + provider: info.providerID, + model: info.modelID, snoozeUntil: config.get("snoozeUntil"), } } @@ -59,9 +65,15 @@ export class AutocompleteServiceManager { // VSCode Providers public readonly codeActionProvider: AutocompleteCodeActionProvider public readonly inlineCompletionProvider: AutocompleteInlineCompletionProvider + public readonly nextEditProvider: NextEditInlineCompletionProvider + public readonly nextEditSuggestionManager: NextEditSuggestionManager private inlineCompletionProviderDisposable: vscode.Disposable | null = null + private inlineCompletionProviderKind: "classic" | "next-edit" | null = null private unsubscribeState: (() => void) | null = null private unsubscribeEvent: (() => void) | null = null + // Resolved copy of the classic provider's ignore controller for synchronous + // snippet filtering. Null until the async initialize() resolves. + private ignoreControllerSync: { validateAccess(fsPath: string): boolean } | null = null constructor(context: vscode.ExtensionContext, connectionService: KiloConnectionService) { if (AutocompleteServiceManager._instance) { @@ -88,6 +100,52 @@ export class AutocompleteServiceManager { new AutocompleteTelemetry(), (status) => this.handleFatalAutocompleteError(status), ) + // Cache the resolved ignore controller for synchronous snippet filtering. + void this.inlineCompletionProvider.ignoreController.then((ic) => { + this.ignoreControllerSync = ic + }) + + this.nextEditSuggestionManager = new NextEditSuggestionManager() + this.nextEditProvider = new NextEditInlineCompletionProvider({ + connectionService, + suggestionManager: this.nextEditSuggestionManager, + getModelSelection: () => { + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + return { providerId: info.providerID, modelId: info.modelID } + }, + isFileAllowed: async (fsPath) => { + const ignore = await this.inlineCompletionProvider.ignoreController + return ignore.validateAccess(fsPath) + }, + getRecentlyViewedSnippets: () => { + // Reuse the LRU populated by the classic provider — keeps a single + // RecentlyVisitedRangesService instance instead of double-tracking. + // Suppress snippets until access checks are available, then include + // only content explicitly approved by the ignore controller. + const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets() + const ignore = this.ignoreControllerSync + if (!ignore) return [] + return toAllowedMercuryRecentSnippets(raw, (path) => ignore.validateAccess(path)) + }, + onFatalError: (status) => this.handleFatalAutocompleteError(status), + onSuggestion: (event) => { + const eventName = + event.status === "error" + ? TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_FAILED + : event.shown + ? TelemetryEventName.AUTOCOMPLETE_LLM_SUGGESTION_RETURNED + : TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_COMPLETED + TelemetryProxy.capture(eventName, { + mode: "next-edit", + model: getAutocompleteModel(this.settings?.provider, this.settings?.model).id, + latencyMs: event.latencyMs, + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + shown: event.shown, + errorStatus: event.errorStatus, + }) + }, + }) // Reload when CLI backend connection state changes so autocomplete // picks up the connected state even if it wasn't ready at startup. @@ -119,9 +177,7 @@ export class AutocompleteServiceManager { public async load() { this.settings = readSettings() - if (this.settings.model) { - this.inlineCompletionProvider.setModel(this.settings.model) - } + this.inlineCompletionProvider.setModel(getAutocompleteModel(this.settings.provider, this.settings.model).id) await this.updateGlobalContext() this.updateStatusBar() @@ -136,25 +192,47 @@ export class AutocompleteServiceManager { */ private async ensureInlineCompletionProviderRegistration() { const shouldBeRegistered = (this.settings?.enableAutoTrigger ?? false) && !this.isSnoozed() - const isRegistered = this.inlineCompletionProviderDisposable !== null + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + const desiredKind: "classic" | "next-edit" = info.kind === "edit" ? "next-edit" : "classic" + + // Mode change while still enabled requires a swap: tear down the old + // registration so the new provider takes over. + if ( + shouldBeRegistered && + this.inlineCompletionProviderKind !== null && + this.inlineCompletionProviderKind !== desiredKind + ) { + if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear() + this.inlineCompletionProviderDisposable?.dispose() + this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null + } - // Already in the correct state — nothing to do - if (shouldBeRegistered === isRegistered) { - return + if (!shouldBeRegistered && this.inlineCompletionProviderKind === "next-edit") { + this.nextEditSuggestionManager.clear() } + const isRegistered = this.inlineCompletionProviderDisposable !== null + if (shouldBeRegistered === isRegistered) return if (!shouldBeRegistered) { this.inlineCompletionProviderDisposable!.dispose() this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null return } - // Register classic provider (tracked via this.inlineCompletionProviderDisposable, - // not context.subscriptions, so re-registration on reconnect doesn't leak) + const provider: vscode.InlineCompletionItemProvider = + desiredKind === "next-edit" ? this.nextEditProvider : this.inlineCompletionProvider this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider( { scheme: "file" }, - this.inlineCompletionProvider, + provider, ) + this.inlineCompletionProviderKind = desiredKind + } + + /** Which provider is currently registered (`null` if none). */ + public get currentMode(): "classic" | "next-edit" | null { + return this.inlineCompletionProviderKind } public async disable() { @@ -179,37 +257,6 @@ export class AutocompleteServiceManager { return Date.now() < snoozeUntil } - /** - * Get remaining snooze time in seconds - */ - public getSnoozeRemainingSeconds(): number { - const snoozeUntil = this.settings?.snoozeUntil - if (!snoozeUntil) { - return 0 - } - const remaining = Math.max(0, Math.ceil((snoozeUntil - Date.now()) / 1000)) - return remaining - } - - /** - * Snooze autocomplete for a specified number of seconds - */ - public async snooze(seconds: number): Promise { - if (this.snoozeTimer) { - clearTimeout(this.snoozeTimer) - this.snoozeTimer = null - } - - const snoozeUntil = Date.now() + seconds * 1000 - await writeSettings({ snoozeUntil }) - - this.snoozeTimer = setTimeout(() => { - void this.unsnooze() - }, seconds * 1000) - - await this.load() - } - /** * Cancel snooze and re-enable autocomplete */ @@ -319,11 +366,13 @@ export class AutocompleteServiceManager { } private getCurrentModelName(): string { - return this.inlineCompletionProvider.getModelId() + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + return info.label } private getCurrentProviderName(): string { - return getAutocompleteModel(this.inlineCompletionProvider.getModelId()).provider + const info = getAutocompleteModel(this.settings?.provider, this.settings?.model) + return info.provider } private hasNoUsableProvider(): boolean { @@ -408,10 +457,17 @@ export class AutocompleteServiceManager { if (this.inlineCompletionProviderDisposable) { this.inlineCompletionProviderDisposable.dispose() this.inlineCompletionProviderDisposable = null + this.inlineCompletionProviderKind = null } // Dispose inline completion provider resources this.inlineCompletionProvider.dispose() + this.nextEditProvider.dispose() + this.nextEditSuggestionManager.dispose() + + // Drop the dedicated Next Edit OutputChannel so it doesn't leak across + // extension reloads. + disposeLog() // Clear singleton instance AutocompleteServiceManager._instance = null diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts index 1244b25b9ec..5fc37a76bf8 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts @@ -84,10 +84,6 @@ vi.mock("../classic-auto-complete/AutocompleteInlineCompletionProvider", () => { public setModel(id: string) { this.modelId = id } - public getModelId(): string { - return this.modelId - } - constructor(..._args: any[]) {} } return { AutocompleteInlineCompletionProvider } @@ -317,21 +313,5 @@ describe("AutocompleteServiceManager (less mocked logic)", () => { expect(manager.isSnoozed()).toBe(true) }) - - it("getSnoozeRemainingSeconds() returns 0 when not snoozed", async () => { - const manager = await createManager() - ;(manager as any).settings = {} - - expect(manager.getSnoozeRemainingSeconds()).toBe(0) - }) - - it("getSnoozeRemainingSeconds() returns a positive number when snoozed", async () => { - const manager = await createManager() - ;(manager as any).settings = { snoozeUntil: Date.now() + 30_000 } - - const remaining = manager.getSnoozeRemainingSeconds() - expect(remaining).toBeGreaterThan(0) - expect(remaining).toBeLessThanOrEqual(30) - }) }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts index 5552d8c9fa6..43bf1438ae4 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts @@ -24,43 +24,63 @@ describe("autocomplete settings", () => { update.mockClear() }) - it("includes the configured model in loaded settings", async () => { - state.set("model", "inception/mercury-edit-2") + it("includes the configured direct provider model in loaded settings", async () => { + state.set("provider", "inception") + state.set("model", "mercury-edit-2") const { buildAutocompleteSettingsMessage } = await import("../settings") - expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit-2") + expect(buildAutocompleteSettingsMessage().settings.provider).toBe("inception") + expect(buildAutocompleteSettingsMessage().settings.model).toBe("mercury-edit-2") }) - it("defaults to codestral when no model is set", async () => { + it("passes a bare model setting through unchanged so the webview can render it as-is", async () => { + // The webview now distinguishes "no explicit setting" (null) from "user + // picked something." We don't try to interpret a bare `model` here — + // resolving it to a default happens at the runtime layer, not in the + // settings message. + state.set("model", "mercury-edit-2") const { buildAutocompleteSettingsMessage } = await import("../settings") - expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508") + expect(buildAutocompleteSettingsMessage().settings.provider).toBeNull() + expect(buildAutocompleteSettingsMessage().settings.model).toBe("mercury-edit-2") }) - it("defaults to codestral when stored model is no longer supported", async () => { - state.set("model", "some/removed-model") + it("returns null for both keys when nothing is set (let the webview render 'Not set')", async () => { const { buildAutocompleteSettingsMessage } = await import("../settings") - expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508") + expect(buildAutocompleteSettingsMessage().settings.provider).toBeNull() + expect(buildAutocompleteSettingsMessage().settings.model).toBeNull() }) - it("maps legacy inception/mercury-edit to inception/mercury-edit-2", async () => { - state.set("model", "inception/mercury-edit") + it("preserves an unsupported stored model verbatim — runtime fallback handles resolution", async () => { + state.set("model", "some/removed-model") const { buildAutocompleteSettingsMessage } = await import("../settings") - expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit-2") + expect(buildAutocompleteSettingsMessage().settings.provider).toBeNull() + expect(buildAutocompleteSettingsMessage().settings.model).toBe("some/removed-model") }) it("validates supported model updates", async () => { const { validAutocompleteSetting } = await import("../settings") - expect(validAutocompleteSetting("model", "inception/mercury-edit-2")).toBe(true) + expect(validAutocompleteSetting("model", "mercury-edit-2")).toBe(true) + expect(validAutocompleteSetting("provider", "inception")).toBe(true) + }) + + it("accepts null/undefined for provider and model so users can clear the setting", async () => { + const { validAutocompleteSetting } = await import("../settings") + + expect(validAutocompleteSetting("provider", null)).toBe(true) + expect(validAutocompleteSetting("provider", undefined)).toBe(true) + expect(validAutocompleteSetting("model", null)).toBe(true) + expect(validAutocompleteSetting("model", undefined)).toBe(true) }) - it("rejects unsupported model updates", async () => { + it("rejects unsupported autocomplete updates", async () => { const { validAutocompleteSetting } = await import("../settings") expect(validAutocompleteSetting("model", "other/model")).toBe(false) + expect(validAutocompleteSetting("provider", "openrouter")).toBe(false) }) it("rejects non-boolean toggle updates", async () => { diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts index 3971e785a11..369906ea4a1 100644 --- a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -7,7 +7,7 @@ import { VisibleCodeTracker } from "../context/VisibleCodeTracker" import { FileIgnoreController } from "../shims/FileIgnoreController" import type { KiloConnectionService } from "../../cli-backend" import { generateFim, hasValidCredentials } from "../fim" -import { getAutocompleteModel } from "../../../shared/autocomplete-models" +import { getAutocompleteModel, getAutocompleteModelById } from "../../../shared/autocomplete-models" import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils" interface ChatCompletionRequestMessage { @@ -20,6 +20,12 @@ interface ChatCompletionResponseSender { postMessage(message: { type: "chatCompletionResult"; text: string; requestId: string }): void } +export function getChatAutocompleteModel(provider?: string, model?: string) { + const info = getAutocompleteModel(provider, model) + if (info.kind !== "edit") return info + return getAutocompleteModelById(info.fimModelID) +} + /** * Chat textarea autocomplete with cached per-request objects. * @@ -77,7 +83,7 @@ export class ChatTextAreaAutocomplete { async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> { const cfg = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") - const entry = getAutocompleteModel(cfg.get("model") ?? "") + const entry = getChatAutocompleteModel(cfg.get("provider"), cfg.get("model")) const startTime = Date.now() // Build context for telemetry diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 78c16079f52..cc38a899e43 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -22,7 +22,7 @@ import { import { FimPromptBuilder } from "./FillInTheMiddle" import { hasValidCredentials } from "../fim" import type { KiloConnectionService } from "../../cli-backend" -import { getAutocompleteModel } from "../../../shared/autocomplete-models" +import { getAutocompleteModelById } from "../../../shared/autocomplete-models" import { ContextRetrievalService } from "../continuedev/core/autocomplete/context/ContextRetrievalService" import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde" import { RecentlyVisitedRangesService } from "../continuedev/core/vscode-test-harness/src/autocomplete/RecentlyVisitedRangesService" @@ -111,13 +111,13 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple private connectionService: KiloConnectionService private costTrackingCallback: CostTrackingCallback private getSettings: () => AutocompleteServiceSettings | null - private recentlyVisitedRangesService: RecentlyVisitedRangesService + public readonly recentlyVisitedRangesService: RecentlyVisitedRangesService private recentlyEditedTracker: RecentlyEditedTracker private debounceTimer: NodeJS.Timeout | null = null /** The pending request associated with the current debounce timer (if any) */ private debouncedPendingRequest: PendingRequest | null = null private isFirstCall: boolean = true - private ignoreController: Promise + public readonly ignoreController: Promise /** Abort controller for the current in-flight FIM request */ private fimAbortController: AbortController | null = null private acceptedCommand: vscode.Disposable | null = null @@ -233,10 +233,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple this.contextProvider.modelId = modelId } - public getModelId(): string { - return this.contextProvider.modelId - } - private processSuggestion( suggestionText: string, prefix: string, @@ -345,7 +341,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const telemetryContext: AutocompleteContext = { languageId: document.languageId, modelId: this.contextProvider.modelId, - provider: getAutocompleteModel(this.contextProvider.modelId).provider, + provider: getAutocompleteModelById(this.contextProvider.modelId).provider, } this.telemetry?.captureSuggestionRequested(telemetryContext) @@ -586,7 +582,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const telemetryContext: AutocompleteContext = { languageId, modelId: this.contextProvider.modelId, - provider: getAutocompleteModel(this.contextProvider.modelId).provider, + provider: getAutocompleteModelById(this.contextProvider.modelId).provider, } // Defense-in-depth: credentials may become invalid between the provider gate and the actual diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts index 0d211aea235..622398b3136 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts @@ -133,13 +133,6 @@ export class ErrorBackoff { return false } - /** - * Whether a fatal (non-retriable) error is active — credits depleted, auth invalid, etc. - */ - isFatal(): boolean { - return this.fatal !== null - } - /** * The HTTP status code of the fatal error, or null. */ diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts index 6a36ae3ab32..564423b2f18 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts @@ -65,36 +65,14 @@ export class FimPromptBuilder { signal?: AbortSignal, ): Promise { const { formattedPrefix, prunedSuffix, autocompleteInput } = prompt - let perflog = "" - const logtime = (() => { - let timestamp = performance.now() - return (msg: string) => { - const baseline = timestamp - timestamp = performance.now() - perflog += `${msg}: ${timestamp - baseline}\n` - } - })() - - logtime("snippets") - - console.log("[FIM] formattedPrefix:", formattedPrefix) - let response = "" const onChunk = (text: string) => { response += text } - logtime("prep fim") const usageInfo = await generateFim(connection, modelId, formattedPrefix, prunedSuffix, onChunk, signal) - logtime("fim network") - console.log("[FIM] response:", response) const fillInAtCursorSuggestion = processSuggestion(response) - if (fillInAtCursorSuggestion.text) { - console.info("Final FIM suggestion:", fillInAtCursorSuggestion) - } - logtime("processSuggestion") - console.log(perflog + `lengths: ${formattedPrefix.length + prunedSuffix.length}\n`) return { suggestion: fillInAtCursorSuggestion, cost: usageInfo.cost, diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts index fc75473d7d1..d91c07fc41e 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts @@ -55,9 +55,7 @@ vi.mock("../../continuedev/core/autocomplete/snippets/getAllSnippets", () => ({ rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }), })) @@ -133,9 +131,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) @@ -178,9 +174,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) @@ -275,9 +269,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) @@ -318,12 +310,6 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [ - { - content: "diff content", - type: AutocompleteSnippetType.Diff, - }, - ], clipboardSnippets: [ { content: "clipboard content", @@ -331,14 +317,12 @@ describe("AutocompleteContextProvider", () => { copiedAt: "2024-01-01", }, ], - ideSnippets: [], staticSnippet: [], }) const { getSnippets } = await import("../../continuedev/core/autocomplete/templating/filtering") ;(getSnippets as any).mockImplementation((_helper: any, payload: any) => [ ...payload.recentlyOpenedFileSnippets, - ...payload.diffSnippets, ...payload.clipboardSnippets, ]) @@ -357,11 +341,9 @@ describe("AutocompleteContextProvider", () => { result.snippetsWithUris.some((s) => "filepath" in s && s.filepath && s.filepath.includes("blocked.ts")), ).toBe(false) // But should contain snippets without file paths - expect(result.snippetsWithUris).toHaveLength(2) - expect(result.snippetsWithUris[0].content).toBe("diff content") - expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Diff) - expect(result.snippetsWithUris[1].content).toBe("clipboard content") - expect(result.snippetsWithUris[1].type).toBe(AutocompleteSnippetType.Clipboard) + expect(result.snippetsWithUris).toHaveLength(1) + expect(result.snippetsWithUris[0].content).toBe("clipboard content") + expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Clipboard) }) it("should allow all files when no ignore controller is provided", async () => { @@ -389,9 +371,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts index 68b874a0b17..0e3900983e6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts @@ -4,7 +4,6 @@ import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde import { AutocompleteInput } from "../types" import { HelperVars } from "../continuedev/core/autocomplete/util/HelperVars" import { getAllSnippetsWithoutRace } from "../continuedev/core/autocomplete/snippets/getAllSnippets" -import { getDefinitionsFromLsp } from "../continuedev/core/vscode-test-harness/src/autocomplete/lsp" import { DEFAULT_AUTOCOMPLETE_OPTS } from "../continuedev/core/util/parameters" import { getSnippets } from "../continuedev/core/autocomplete/templating/filtering" import { FileIgnoreController } from "../shims/FileIgnoreController" @@ -97,7 +96,6 @@ export async function getProcessedSnippets( const snippetPayload = await getAllSnippetsWithoutRace({ helper, ide, - getDefinitionsFromLsp, contextRetrievalService: contextService, }) diff --git a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts index dfe2b6a8f24..f1be8972335 100644 --- a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts @@ -24,8 +24,6 @@ import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils" const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"] export class VisibleCodeTracker { - private lastContext: VisibleCodeContext | null = null - constructor( private workspacePath: string, private ignoreController: FileIgnoreController | null = null, @@ -101,19 +99,10 @@ export class VisibleCodeTracker { }) } - this.lastContext = { + return { timestamp: Date.now(), editors: editorInfos, } - - return this.lastContext - } - - /** - * Returns the last captured context, or null if never captured. - */ - public getLastContext(): VisibleCodeContext | null { - return this.lastContext } /** 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 02f14eb85aa..aee32df9230 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 @@ -1,6 +1,15 @@ import { getUriFileExtension } from "../../util/uri" import { BracketMatchingService } from "../filtering/BracketMatchingService" -import { CharacterFilter, LineFilter } from "../filtering/streamTransforms/lineStream" + +type LineStream = AsyncGenerator +type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream +type CharacterFilter = (args: { + chars: AsyncGenerator + prefix: string + suffix: string + filepath: string + multiline: boolean +}) => AsyncGenerator export interface AutocompleteLanguageInfo { /** diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.test.ts deleted file mode 100644 index a092a205af4..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest" -import { rankAndOrderSnippets, fillPromptWithSnippets } from "./index" -import { RankedSnippet } from "../../types" - -// vibecoded -describe("rankAndOrderSnippets", () => { - it("should rank and order snippets by similarity to cursor context", () => { - // Create a simple mock HelperVars with only the required properties - const mockHelper = { - fullPrefix: "function calculateTotal(items) {\n const total = items.reduce(", - fullSuffix: ", 0);\n return total;\n}", - options: { - slidingWindowSize: 50, - slidingWindowPrefixPercentage: 0.5, - }, - } as any - - // Create test snippets with different levels of similarity to the cursor context - const snippets: RankedSnippet[] = [ - { - filepath: "utils.ts", - range: { - start: { line: 10, character: 0 }, - end: { line: 12, character: 0 }, - }, - contents: "// Helper function for database queries\nfunction queryDb() {}", - }, - { - filepath: "math.ts", - range: { - start: { line: 5, character: 0 }, - end: { line: 7, character: 0 }, - }, - contents: "// Array reduce function\nconst sum = items.reduce((acc, item) => acc + item, 0);", - }, - { - filepath: "helpers.ts", - range: { - start: { line: 20, character: 0 }, - end: { line: 22, character: 0 }, - }, - contents: - "// Calculate total with reduce\nfunction calculateSum(items) {\n return items.reduce((a, b) => a + b);\n}", - }, - ] - - const result = rankAndOrderSnippets(snippets, mockHelper) - - // Verify the result has the expected structure - expect(result).toHaveLength(3) - - // All snippets should have scores assigned - expect(result.every((s) => typeof s.score === "number")).toBe(true) - - // All snippets should have required properties - result.forEach((snippet) => { - expect(snippet.filepath).toBeDefined() - expect(snippet.range).toBeDefined() - expect(snippet.contents).toBeDefined() - expect(snippet.score).toBeGreaterThanOrEqual(0) - }) - - // Scores should be in ascending order (lower scores = better matches) - for (let i = 0; i < result.length - 1; i++) { - expect(result[i].score).toBeLessThanOrEqual(result[i + 1].score) - } - }) -}) -// vibecoded - -describe("fillPromptWithSnippets", () => { - it("should fill token budget with snippets until limit is reached", () => { - // Create snippets with required properties (including score) - const snippets: Required[] = [ - { - filepath: "math.ts", - range: { - start: { line: 1, character: 0 }, - end: { line: 3, character: 0 }, - }, - contents: "function add(a, b) { return a + b; }", - score: 0.1, - }, - { - filepath: "utils.ts", - range: { - start: { line: 5, character: 0 }, - end: { line: 7, character: 0 }, - }, - contents: "function multiply(x, y) { return x * y; }", - score: 0.2, - }, - { - filepath: "helpers.ts", - range: { - start: { line: 10, character: 0 }, - end: { line: 15, character: 0 }, - }, - contents: "function calculateSum(items) {\n return items.reduce((acc, item) => acc + item, 0);\n}", - score: 0.3, - }, - ] - - // Set token limit to include first 2 snippets but not the third - // Using a model name and a reasonable token limit - const maxSnippetTokens = 25 - const modelName = "gpt-3.5-turbo" - - const result = fillPromptWithSnippets(snippets, maxSnippetTokens, modelName) - - // Verify that we got fewer snippets than we started with - expect(result.length).toBeLessThan(snippets.length) - expect(result.length).toBeGreaterThan(0) - - // Verify all returned snippets are from the original list - result.forEach((snippet) => { - expect(snippets).toContainEqual(snippet) - }) - - // Verify snippets maintain their order (first snippets are kept) - for (let i = 0; i < result.length; i++) { - expect(result[i]).toBe(snippets[i]) - } - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts index d0115a12f20..79095acb40d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts @@ -1,133 +1,9 @@ -import { RangeInFileWithContents } from "../../../" -import { countTokens } from "../../../llm/countTokens" -import { RankedSnippet } from "../../types" -import { HelperVars } from "../../util/HelperVars" - const rx = /[\s.,/#!$%^&*;:{}=\-_`~()[\]]/g + export function getSymbolsForSnippet(snippet: string): Set { const symbols = snippet .split(rx) - .map((s) => s.trim()) - .filter((s) => s !== "") + .map((symbol) => symbol.trim()) + .filter((symbol) => symbol !== "") return new Set(symbols) } - -/** - * Calculate similarity as number of shared symbols divided by total number of unique symbols between both. - */ -function jaccardSimilarity(a: string, b: string): number { - const aSet = getSymbolsForSnippet(a) - const bSet = getSymbolsForSnippet(b) - const union = new Set([...aSet, ...bSet]).size - - // Avoid division by zero - if (union === 0) { - return 0 - } - - let intersection = 0 - for (const symbol of aSet) { - if (bSet.has(symbol)) { - intersection++ - } - } - - return intersection / union -} - -/** - * Rank code snippets to be used in tab-autocomplete prompt. Returns a sorted version of the snippet array. - */ -export function rankAndOrderSnippets(ranges: RankedSnippet[], helper: HelperVars): Required[] { - //MINIMAL_REPO - this isn't actually used in continue - const windowAroundCursor = - helper.fullPrefix.slice(-helper.options.slidingWindowSize * helper.options.slidingWindowPrefixPercentage) + - helper.fullSuffix.slice(helper.options.slidingWindowSize * (1 - helper.options.slidingWindowPrefixPercentage)) - - const snippets: Required[] = ranges.map((snippet) => ({ - score: snippet.score ?? jaccardSimilarity(snippet.contents, windowAroundCursor), - ...snippet, - })) - const uniqueSnippets = deduplicateSnippets(snippets) - return uniqueSnippets.sort((a, b) => a.score - b.score) -} - -/** - * Deduplicate code snippets by merging overlapping ranges into a single range. - */ -function deduplicateSnippets(snippets: Required[]): Required[] { - // Group by file - const fileGroups: { - [key: string]: Required[] - } = {} - for (const snippet of snippets) { - if (!fileGroups[snippet.filepath]) { - fileGroups[snippet.filepath] = [] - } - fileGroups[snippet.filepath].push(snippet) - } - - // Merge overlapping ranges - const allRanges = [] - for (const file of Object.keys(fileGroups)) { - allRanges.push(...mergeSnippetsByRange(fileGroups[file])) - } - return allRanges -} - -function mergeSnippetsByRange(snippets: Required[]): Required[] { - if (snippets.length <= 1) { - return snippets - } - - const sorted = snippets.sort((a, b) => a.range.start.line - b.range.start.line) - const merged: Required[] = [] - - while (sorted.length > 0) { - const next = sorted.shift()! - const last = merged[merged.length - 1] - if (merged.length > 0 && last.range.end.line >= next.range.start.line) { - // Merge with previous snippet - last.score = Math.max(last.score, next.score) - try { - last.range.end = next.range.end - } catch (e) { - console.log("Error merging ranges", e) - } - last.contents = mergeOverlappingRangeContents(last, next) - } else { - merged.push(next) - } - } - - return merged -} - -function mergeOverlappingRangeContents(first: RangeInFileWithContents, second: RangeInFileWithContents): string { - const firstLines = first.contents.split("\n") - const numOverlapping = first.range.end.line - second.range.start.line - return `${firstLines.slice(-numOverlapping).join("\n")}\n${second.contents}` -} - -/** - * Fill the allowed space with snippets. - * It is assumed that the snippets are sorted by score. - */ -export function fillPromptWithSnippets( //MINIMAL_REPO - this isn't actually used in continue - snippets: Required[], - maxSnippetTokens: number, - modelName: string, -): Required[] { - let tokensRemaining = maxSnippetTokens - const keptSnippets: Required[] = [] - for (let i = 0; i < snippets.length; i++) { - const snippet = snippets[i] - const tokenCount = countTokens(snippet.contents, modelName) - if (tokensRemaining - tokenCount >= 0) { - tokensRemaining -= tokenCount - keptSnippets.push(snippet) - } - } - - return keptSnippets -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts index 3a3609ccaf2..894205a7d51 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts @@ -48,31 +48,6 @@ export class StaticContextService { }) } - public static formatAutocompleteStaticSnippet(ctx: StaticContext): string { - let output = `AutocompleteStaticSnippet:\n` - output += ` holeType: ${ctx.holeType}\n` - - output += ` relevantTypes:\n` - if (ctx.relevantTypes.size === 0) { - output += ` (none)\n` - } else { - ctx.relevantTypes.forEach((types, filepath) => { - output += ` ${filepath}: [${types.join(", ")}]\n` - }) - } - - output += ` relevantHeaders:\n` - if (ctx.relevantHeaders.size === 0) { - output += ` (none)\n` - } else { - ctx.relevantHeaders.forEach((headers, filepath) => { - output += ` ${filepath}: [${headers.join(", ")}]\n` - }) - } - - return output - } - public async getContext(helper: HelperVars): Promise { const tsFiles = await this.getTypeScriptFilesFromWorkspaces(helper.workspaceUris) // Get the three contexts holeContext, relevantTypes, relevantHeaders. diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts index 6a205b77e51..bce4ab033c6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach } from "vitest" -import { BracketMatchingService, BRACKETS, BRACKETS_REVERSE } from "./BracketMatchingService" +import { beforeEach, describe, expect, it } from "vitest" +import { BRACKETS, BRACKETS_REVERSE, BracketMatchingService } from "./BracketMatchingService" describe("BracketMatchingService", () => { let service: BracketMatchingService @@ -8,300 +8,93 @@ describe("BracketMatchingService", () => { service = new BracketMatchingService() }) - describe("BRACKETS constants", () => { - it("should have correct opening-to-closing bracket mappings", () => { - expect(BRACKETS["("]).toBe(")") - expect(BRACKETS["{"]).toBe("}") - expect(BRACKETS["["]).toBe("]") - }) - - it("should have correct closing-to-opening bracket mappings", () => { - expect(BRACKETS_REVERSE[")"]).toBe("(") - expect(BRACKETS_REVERSE["}"]).toBe("{") - expect(BRACKETS_REVERSE["]"]).toBe("[") - }) + it("defines matching bracket pairs", () => { + expect(BRACKETS).toEqual({ "(": ")", "{": "}", "[": "]" }) + expect(BRACKETS_REVERSE).toEqual({ ")": "(", "}": "{", "]": "[" }) }) - describe("handleAcceptedCompletion", () => { - it("should track unmatched opening brackets from completion", () => { - service.handleAcceptedCompletion("function test() {", "test.ts") - // Internal state should track the unmatched opening brackets - // We can verify this by checking behavior in subsequent calls - }) - - it("should handle matched bracket pairs correctly", () => { - service.handleAcceptedCompletion("function test() { return 1; }", "test.ts") - // All brackets are matched, so stack should be empty - }) - - it("should handle multiple unmatched opening brackets", () => { - service.handleAcceptedCompletion("if (condition) { while (true) {", "test.ts") - // Should track both unmatched { brackets - }) - - it("should handle nested bracket structures", () => { - service.handleAcceptedCompletion("arr[0] = { key: [1, 2]", "test.ts") - // Should track unmatched { and [ - }) - - it("should stop tracking when encountering unmatched closing bracket", () => { - service.handleAcceptedCompletion("function test() { } }", "test.ts") - // Should stop when encountering the extra closing brace - }) - - it("should handle different bracket types", () => { - service.handleAcceptedCompletion("const obj = { arr: [1, (2", "test.ts") - // Should track {, [, and ( - }) - - it("should reset state for each new completion", () => { - service.handleAcceptedCompletion("function test() {", "test.ts") - service.handleAcceptedCompletion("class MyClass {", "test.ts") - // Second call should reset state from first call - }) - - it("should update filepath tracking", () => { - service.handleAcceptedCompletion("function a() {", "file1.ts") - service.handleAcceptedCompletion("function b() {", "file2.ts") - // Should track that we're now in file2.ts - }) - - it("should handle empty completion string", () => { - service.handleAcceptedCompletion("", "test.ts") - // Should not throw and should have empty stack - }) - - it("should handle completion with only text and no brackets", () => { - service.handleAcceptedCompletion("const x = 5;", "test.ts") - // Should complete successfully with empty bracket stack - }) - - it("should handle complex nested structure", () => { - service.handleAcceptedCompletion("obj = { a: [1, { b: (x", "test.ts") - // Should track {, [, {, ( - }) + async function* stream(chunks: string[]): AsyncGenerator { + for (const chunk of chunks) yield chunk + } + + async function collect(gen: AsyncGenerator): Promise { + const chunks: string[] = [] + for await (const chunk of gen) chunks.push(chunk) + return chunks.join("") + } + + it("allows a matching single-line closing bracket", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["x + 1)"]), + "const result = calculate(", + ");", + "test.ts", + false, + ) + expect(await collect(result)).toBe("x + 1)") }) - describe("stopOnUnmatchedClosingBracket", () => { - // Helper function to create async generator from array - async function* arrayToAsyncGen(arr: string[]): AsyncGenerator { - for (const item of arr) { - yield item - } - } - - // Helper to collect all values from async generator - async function collectAll(gen: AsyncGenerator): Promise { - const results: string[] = [] - for await (const item of gen) { - results.push(item) - } - return results - } - - describe("multiline mode", () => { - it("should allow closing brackets that match previous completion", async () => { - service.handleAcceptedCompletion("function test() {", "test.ts") - const stream = arrayToAsyncGen(["\n return 1;\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() ", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("\n return 1;\n}") - }) - - it("should not use previous completion state from different file", async () => { - service.handleAcceptedCompletion("function test() {", "file1.ts") - const stream = arrayToAsyncGen(["}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true) - const result = await collectAll(filtered) - // Different file so stack is empty, but '}' is in whitespace section - // Whitespace section (closing brackets before non-whitespace) yields without checking - // Since '}' doesn't match /[^\s\)\}\]]/, it's all whitespace/closing brackets - // So entire chunk is yielded and loop continues to end - expect(result.join("")).toBe("}") - }) - - it("should stop on unmatched closing bracket in multiline", async () => { - const stream = arrayToAsyncGen(["function test() {\n return 1;\n}\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - // The chunk contains one complete function and one extra '}' - // Processing char by char: '{' at position 16 opens, '}' at position 32 closes (stack empty) - // '\n' at position 33, then '}' at position 34 is unmatched (stack empty) - // Yields chunk.slice(0, 34) which includes the newline after the first } - expect(result.join("")).toBe("function test() {\n return 1;\n}\n") - }) - - it("should handle multiple chunks in stream", async () => { - const stream = arrayToAsyncGen(["function", " test()", " {", "\n return", " 1;", "\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("function test() {\n return 1;\n}") - }) - }) - - describe("single-line mode", () => { - it("should allow completing brackets from current line", async () => { - const stream = arrayToAsyncGen(["x + 1)"]) - const filtered = service.stopOnUnmatchedClosingBracket( - stream, - "const result = calculate(", - ");", - "test.ts", - false, - ) - const result = await collectAll(filtered) - expect(result.join("")).toBe("x + 1)") - }) - - it("should handle bracket in suffix that gets overwritten", async () => { - const stream = arrayToAsyncGen(["1, 2, 3)"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "array.push(", ");", "test.ts", false) - const result = await collectAll(filtered) - // Should allow the closing paren because suffix has one - expect(result.join("")).toBe("1, 2, 3)") - }) - - it("should stop on unmatched closing bracket in single-line", async () => { - const stream = arrayToAsyncGen(["x + 1))"]) - const filtered = service.stopOnUnmatchedClosingBracket( - stream, - "const result = calculate(", - "", - "test.ts", - false, - ) - const result = await collectAll(filtered) - expect(result.join("")).toBe("x + 1)") - }) - - it("should handle multiple bracket pairs on current line", async () => { - const stream = arrayToAsyncGen(['" + y + ")])']) - const filtered = service.stopOnUnmatchedClosingBracket( - stream, - 'array.push({ key: getValue("x', - "", - "test.ts", - false, - ) - const result = await collectAll(filtered) - // Current line has: { ( ( - // Stream closes: ) ] ) - // First ) matches third (, second ] doesn't match second ( (expects }), stops before ] - expect(result.join("")).toBe('" + y + ")') - }) - }) - - describe("edge cases", () => { - it("should handle empty stream", async () => { - const stream = arrayToAsyncGen([]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result).toEqual([]) - }) - - it("should handle stream with only whitespace before brackets", async () => { - const stream = arrayToAsyncGen([" \n }"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe(" \n }") - }) - - it("should allow closing brackets before non-whitespace content", async () => { - const stream = arrayToAsyncGen([")\n const x = 1;"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test(", "", "test.ts", true) - const result = await collectAll(filtered) - // In multiline mode with no previous completion, stack starts empty but prefix has '(' - // Actually, prefix is NOT processed in multiline mode (only previous completion state) - // Whitespace section: searches for /[^\s\)\}\]]/, finds 'c' at index 7 - // Yields ')\n ' (everything before 'c'), then processes 'const x = 1;' - // 'const x = 1;' has no brackets, yields entire remaining chunk - expect(result.join("")).toBe(")\n const x = 1;") - }) - - it("should handle mixed bracket types correctly", async () => { - const stream = arrayToAsyncGen(["]})"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "obj = { arr: [{ key: val", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("]})") - }) - - it("should handle suffix with spaces before closing bracket", async () => { - const stream = arrayToAsyncGen(["1, 2, 3)"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", " )", "test.ts", false) - const result = await collectAll(filtered) - // Spaces in suffix should be ignored, bracket should be added to stack - expect(result.join("")).toBe("1, 2, 3)") - }) + it("stops at an unmatched single-line closing bracket", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["x + 1))"]), + "const result = calculate(", + "", + "test.ts", + false, + ) + expect(await collect(result)).toBe("x + 1)") + }) - it("should stop when suffix parsing ends at non-bracket", async () => { - const stream = arrayToAsyncGen(["x)"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", ") {", "test.ts", false) - const result = await collectAll(filtered) - // In single-line mode, current line is 'func() {' - // Stack from current line: ( opens, ) closes (matches), { opens -> stack = ['{'] - // Suffix ') {': unshift adds '(' to FRONT of stack -> stack = ['(', '{'] - // Stream 'x)': ')' is closing bracket - // stack.pop() removes from END, returns '{', BRACKETS['{'] = '}', char = ')' - // '}' !== ')' so condition is true, stops and yields 'x' - expect(result.join("")).toBe("x") - }) + it("tracks brackets opened by the current stream", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["function test() {", "\n return 1;", "\n}"]), + "", + "", + "test.ts", + true, + ) + expect(await collect(result)).toBe("function test() {\n return 1;\n}") + }) - it("should handle chunk boundary on closing bracket", async () => { - const stream = arrayToAsyncGen(["return 1", ";", "\n", "}", "extra"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true) - const result = await collectAll(filtered) - // In multiline mode without previous completion state, stack starts empty - // Prefix doesn't add to stack in multiline mode - // First chunk 'return 1' has no brackets, yielded - // Second chunk ';' has no brackets, yielded - // Third chunk '\n' is whitespace with no brackets, still in whitespace section - // Fourth chunk '}' is closing bracket in whitespace section, but stack is empty so stops immediately - expect(result.join("")).toBe("return 1;\n") - }) + it("stops at an unmatched multiline closing bracket", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["function test() {\n return 1;\n}\n}"]), + "", + "", + "test.ts", + true, + ) + expect(await collect(result)).toBe("function test() {\n return 1;\n}\n") + }) - it("should handle nested brackets in stream", async () => { - const stream = arrayToAsyncGen(["arr[i][j]"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "const val = ", ";", "test.ts", false) - const result = await collectAll(filtered) - expect(result.join("")).toBe("arr[i][j]") - }) + it("handles nested bracket types", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream(["arr[i][j]"]), "const val = ", ";", "test.ts", false) + expect(await collect(result)).toBe("arr[i][j]") + }) - it("should handle unmatched opening brackets in stream", async () => { - const stream = arrayToAsyncGen(["arr[index"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("arr[index") - }) - }) + it("uses closing brackets from a whitespace-prefixed suffix", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream(["1, 2, 3)"]), "func(", " )", "test.ts", false) + expect(await collect(result)).toBe("1, 2, 3)") + }) - describe("state persistence across completions", () => { - it("should use state from previous completion in same file", async () => { - service.handleAcceptedCompletion("if (cond) {\n while (true) {", "test.ts") - const stream = arrayToAsyncGen(["\n doWork();\n }\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("\n doWork();\n }\n}") - }) + it("stops suffix bracket parsing at other content", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream(["x)"]), "func(", ") {", "test.ts", false) + expect(await collect(result)).toBe("x") + }) - it("should not use state from previous file", async () => { - service.handleAcceptedCompletion("if (cond) {", "file1.ts") - const stream = arrayToAsyncGen(["}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true) - const result = await collectAll(filtered) - // Different file so stack is empty, but '}' is in whitespace section - // Since '}' doesn't match /[^\s\)\}\]]/, entire chunk yielded without bracket checking - expect(result.join("")).toBe("}") - }) + it("handles a closing bracket at a chunk boundary", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["return 1", ";", "\n", "}", "extra"]), + "function test() {", + "", + "test.ts", + true, + ) + expect(await collect(result)).toBe("return 1;\n") + }) - it("should clear state when switching files", async () => { - service.handleAcceptedCompletion("function a() {", "file1.ts") - service.handleAcceptedCompletion("function b() {", "file2.ts") - const stream = arrayToAsyncGen(["\n return;\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true) - const result = await collectAll(filtered) - // Should only allow one closing brace from file2's state - expect(result.join("")).toBe("\n return;\n}") - }) - }) + it("handles an empty stream", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream([]), "", "", "test.ts", true) + expect(await collect(result)).toBe("") }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts index 8ad73dda990..f46f287c1ca 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts @@ -13,61 +13,27 @@ export const BRACKETS_REVERSE: { [key: string]: string } = { * But sometimes we started the pair in a previous autocomplete suggestion */ export class BracketMatchingService { - private openingBracketsFromLastCompletion: string[] = [] - private lastCompletionFile: string | undefined = undefined - - handleAcceptedCompletion(completion: string, filepath: string) { - this.openingBracketsFromLastCompletion = [] - const stack: string[] = [] - - for (let i = 0; i < completion.length; i++) { - const char = completion[i] - if (Object.keys(BRACKETS).includes(char)) { - // It's an opening bracket - stack.push(char) - } else if (Object.values(BRACKETS).includes(char)) { - // It's a closing bracket - if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) { - break - } - } - } - - // Any remaining opening brackets in the stack are uncompleted - this.openingBracketsFromLastCompletion = stack - this.lastCompletionFile = filepath - } - async *stopOnUnmatchedClosingBracket( stream: AsyncGenerator, prefix: string, suffix: string, - filepath: string, + _filepath: string, multiline: boolean, // Whether this is a multiline completion or not ): AsyncGenerator { - let stack: string[] = [] - if (multiline) { - // Add opening brackets from the previous response - if (this.lastCompletionFile === filepath) { - stack = [...this.openingBracketsFromLastCompletion] - } else { - this.lastCompletionFile = undefined - } - } else { + const stack: string[] = [] + if (!multiline) { // If single line completion, then allow completing bracket pairs that are // started on the current line but not finished on the current line - if (!multiline) { - const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "") - for (let i = 0; i < currentLine.length; i++) { - const char = currentLine[i] - if (Object.keys(BRACKETS).includes(char)) { - // It's an opening bracket - stack.push(char) - } else if (Object.values(BRACKETS).includes(char)) { - // It's a closing bracket - if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) { - break - } + const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "") + for (let i = 0; i < currentLine.length; i++) { + const char = currentLine[i] + if (Object.keys(BRACKETS).includes(char)) { + // It's an opening bracket + stack.push(char) + } else if (Object.values(BRACKETS).includes(char)) { + // It's a closing bracket + if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) { + break } } } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.test.ts deleted file mode 100644 index c89b6bddf35..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, it, Mock, vi } from "vitest" - -import { - avoidPathLine, - avoidEmptyComments, - streamWithNewLines, - lineIsRepeated, - stopAtSimilarLine, - stopAtLines, - LINES_TO_STOP_AT, - PREFIXES_TO_SKIP, - skipPrefixes, - stopAtRepeatingLines, -} from "./lineStream" - -describe("lineStream (production-used subset)", () => { - let mockFullStop: Mock - - async function getLineGenerator(lines: any[]) { - return (async function* () { - for (const line of lines) { - yield line - } - })() - } - - async function getFilteredLines(results: AsyncGenerator) { - const output: string[] = [] - for await (const line of results) { - output.push(line) - } - return output - } - - beforeEach(() => { - mockFullStop = vi.fn() - }) - - describe("avoidPathLine", () => { - it("filters out '// Path: ...' lines", async () => { - const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"]) - const result = avoidPathLine(linesGenerator, "//") - const filteredLines = await getFilteredLines(result) - expect(filteredLines).toEqual(["const x = 5;", "//", "console.log(x);"]) - }) - }) - - describe("avoidEmptyComments", () => { - it("filters out empty comment-only lines", async () => { - const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"]) - const result = avoidEmptyComments(linesGenerator, "//") - const filteredLines = await getFilteredLines(result) - expect(filteredLines).toEqual(["// Path: src/index.ts", "const x = 5;", "console.log(x);"]) - }) - }) - - describe("streamWithNewLines", () => { - it("adds newline separators between lines", async () => { - const linesGenerator = await getLineGenerator(["line1", "line2", "line3"]) - const result = streamWithNewLines(linesGenerator) - const filteredLines = await getFilteredLines(result) - expect(filteredLines).toEqual(["line1", "\n", "line2", "\n", "line3"]) - }) - }) - - describe("lineIsRepeated", () => { - it("returns true for similar lines", () => { - expect(lineIsRepeated("const x = 5;", "const x = 6;")).toBe(true) - }) - - it("returns false for different lines", () => { - expect(lineIsRepeated("const x = 5;", "let y = 10;")).toBe(false) - }) - - it("returns false for short lines", () => { - expect(lineIsRepeated("x=5", "x=6")).toBe(false) - }) - }) - - describe("stopAtSimilarLine", () => { - it("stops at the exact same line", async () => { - const lineToTest = "const x = 6" - const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest]) - - const result = stopAtSimilarLine(linesGenerator, lineToTest, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["console.log();", "const y = () => {};"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - - it("stops at a similar line", async () => { - const lineToTest = "const x = 6;" - const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest]) - - const result = stopAtSimilarLine(linesGenerator, "a" + lineToTest, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["console.log();", "const y = () => {};"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - - it("continues on bracket-ending lines", async () => { - const linesGenerator = await getLineGenerator([" if (x > 0) {", " console.log(x);", " }"]) - - const result = stopAtSimilarLine(linesGenerator, "}", mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual([" if (x > 0) {", " console.log(x);", " }"]) - expect(mockFullStop).toHaveBeenCalledTimes(0) - }) - }) - - describe("stopAtLines", () => { - it("stops at specified lines", async () => { - const linesGenerator = await getLineGenerator([ - "const x = 5;", - "let y = 10;", - LINES_TO_STOP_AT[0], - "const z = 15;", - ]) - - const result = stopAtLines(linesGenerator, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - - it("stops when stop phrase has leading whitespace", async () => { - const linesGenerator = await getLineGenerator([ - "const x = 5;", - "let y = 10;", - ` ${LINES_TO_STOP_AT[0]}`, - "const z = 15;", - ]) - - const result = stopAtLines(linesGenerator, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - }) - - describe("skipPrefixes", () => { - it("skips configured prefixes on the first line", async () => { - const linesGenerator = await getLineGenerator([`${PREFIXES_TO_SKIP[0]}const x = 5;`, "let y = 10;"]) - - const result = skipPrefixes(linesGenerator) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"]) - }) - }) - - describe("stopAtRepeatingLines", () => { - it("yields non-repeating lines and does not stop prematurely", async () => { - const linesGenerator = await getLineGenerator(["a", "b", "c", "d", "e"]) - - const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop) - const filteredLines = await getFilteredLines(result as any) - - expect(filteredLines).toEqual(["a", "b", "c", "d", "e"]) - expect(mockFullStop).not.toHaveBeenCalled() - }) - - it("stops when a line repeats 3 times consecutively", async () => { - const linesGenerator = await getLineGenerator(["x", "x", "x", "x", "after"]) - - const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop) - const filteredLines = await getFilteredLines(result as any) - - // Only the first of the repeating lines is yielded - expect(filteredLines).toEqual(["x"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.ts deleted file mode 100644 index a4cc5e15265..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { LineStream } from "../../../diff/util" -import { lineIsRepeated } from "../../util/textSimilarity" - -export { lineIsRepeated } - -export type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream - -export type CharacterFilter = (args: { - chars: AsyncGenerator - prefix: string - suffix: string - filepath: string - multiline: boolean -}) => AsyncGenerator - -const BRACKET_ENDING_CHARS = [")", "]", "}", ";"] -export const PREFIXES_TO_SKIP = [""] -export const LINES_TO_STOP_AT = ["# End of file.", "", "```"] - -function isBracketEnding(line: string): boolean { - return line - .trim() - .split("") - .some((char) => BRACKET_ENDING_CHARS.includes(char)) -} - -/** - * Validate whether a stop pattern in a line is in a valid context (not inside quotes or identifiers) - * and capture the text before the pattern. - * Internal helper for stopAtLines. - */ -function validatePatternInLine( - line: string, - pattern: string, -): { - isValid: boolean - patternIndex: number - beforePattern: string -} { - const patternIndex = line.indexOf(pattern) - - if (patternIndex === -1) { - return { isValid: false, patternIndex: -1, beforePattern: "" } - } - - // If preceded by a non-whitespace, treat as part of an identifier - if (patternIndex > 0) { - const charBefore = line[patternIndex - 1] - if (charBefore && !charBefore.match(/\s/)) { - return { isValid: false, patternIndex, beforePattern: "" } - } - } - - const beforePattern = line.substring(0, patternIndex) - const singleQuotes = (beforePattern.match(/'/g) || []).length - const doubleQuotes = (beforePattern.match(/"/g) || []).length - - // Odd number of quotes before the pattern - likely inside quotes - if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) { - return { isValid: false, patternIndex, beforePattern } - } - - return { isValid: true, patternIndex, beforePattern } -} - -/** - * Filter out lines starting with "// Path: " which models sometimes echo. - */ -export async function* avoidPathLine(stream: LineStream, comment?: string): LineStream { - for await (const line of stream) { - if (comment && line.startsWith(`${comment} Path: `)) { - continue - } - yield line - } -} - -/** - * Filter out empty comment-only lines. - */ -export async function* avoidEmptyComments(stream: LineStream, comment?: string): LineStream { - for await (const line of stream) { - if (!comment || line.trim() !== comment) { - yield line - } - } -} - -/** - * Insert "\n" separators between streamed lines. - */ -export async function* streamWithNewLines(stream: LineStream): LineStream { - let firstLine = true - for await (const nextLine of stream) { - if (!firstLine) { - yield "\n" - } - firstLine = false - yield nextLine - } -} - -/** - * Yield until a line equals or is very similar to the provided line, then call fullStop. - * If the provided line ends with a bracket/semicolon, allow exact trimmed matches to pass through. - */ -export async function* stopAtSimilarLine( - stream: LineStream, - line: string, - fullStop: () => void, -): AsyncGenerator { - const trimmedLine = line.trim() - const lineIsBracketEnding = isBracketEnding(trimmedLine) - - for await (const nextLine of stream) { - if (trimmedLine === "") { - yield nextLine - continue - } - - if (lineIsBracketEnding && trimmedLine === nextLine.trim()) { - yield nextLine - continue - } - - if (nextLine === line) { - fullStop() - break - } - - if (lineIsRepeated(nextLine, trimmedLine)) { - fullStop() - break - } - - yield nextLine - } -} - -/** - * Yield until any of the stop phrases is encountered in a valid context, then call fullStop. - */ -export async function* stopAtLines( - stream: LineStream, - fullStop: () => void, - linesToStopAt: string[] = LINES_TO_STOP_AT, -): LineStream { - for await (const line of stream) { - let shouldStop = false - - for (const stopAt of linesToStopAt) { - if (line.includes(stopAt)) { - const validation = validatePatternInLine(line, stopAt) - if (!validation.isValid) { - continue - } - - const trimmedLine = line.trimStart() - if (trimmedLine.startsWith(stopAt)) { - shouldStop = true - break - } else { - const contentBeforeStopPhrase = validation.beforePattern.trimEnd() - if (contentBeforeStopPhrase.length < validation.beforePattern.length) { - shouldStop = true - break - } - } - } - } - - if (shouldStop) { - fullStop() - break - } - yield line - } -} - -/** - * On the first line only, strip any configured prefix (e.g. ""). - */ -export async function* skipPrefixes(lines: LineStream): LineStream { - let isFirstLine = true - for await (const line of lines) { - if (isFirstLine) { - const match = PREFIXES_TO_SKIP.find((prefix) => line.startsWith(prefix)) - if (match) { - yield line.slice(match.length) - continue - } - isFirstLine = false - } - yield line - } -} - -/** - * Yield lines until a line repeats 3 times consecutively. Only the first of the repeats is yielded. - */ -export async function* stopAtRepeatingLines(lines: LineStream, fullStop: () => void): LineStream { - let previousLine: string | undefined - let repeatCount = 0 - const MAX_REPEATS = 3 - - for await (const line of lines) { - if (line === previousLine) { - repeatCount++ - if (repeatCount === MAX_REPEATS) { - fullStop() - return - } - } else { - yield line - repeatCount = 1 - } - previousLine = line - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts index c47bcf76619..93abefa3cd0 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts @@ -1,450 +1,111 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { getAllSnippets, getAllSnippetsWithoutRace } from "./getAllSnippets" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { ContextRetrievalService } from "../context/ContextRetrievalService" import { AutocompleteSnippetType } from "../types" import type { HelperVars } from "../util/HelperVars" import type { IDE } from "../../index" -import type { GetLspDefinitionsFunction } from "../types" -import type { ContextRetrievalService } from "../context/ContextRetrievalService" +import { getAllSnippetsWithoutRace } from "./getAllSnippets" -describe("getAllSnippets", () => { - let mockHelper: HelperVars - let mockIde: IDE - let mockGetDefinitionsFromLsp: GetLspDefinitionsFunction - let mockContextRetrievalService: ContextRetrievalService +describe("getAllSnippetsWithoutRace", () => { + let helper: HelperVars + let ide: IDE + let context: ContextRetrievalService beforeEach(() => { - // Create mock helper with minimal required properties - mockHelper = { + helper = { input: { filepath: "/test/file.ts", - recentlyEditedRanges: [ - { - filepath: "/test/recent.ts", - lines: ["const x = 1;", "const y = 2;"], - }, - ], + recentlyEditedRanges: [{ filepath: "/test/recent.ts", lines: ["const x = 1;"] }], recentlyVisitedRanges: [ - { - filepath: "/test/visited.ts", - content: "visited content", - type: AutocompleteSnippetType.Code, - }, + { filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code }, ], }, filepath: "/test/file.ts", - fullPrefix: "const result = ", - fullSuffix: ";", - lang: "typescript", options: { - onlyMyCode: false, useRecentlyEdited: true, useRecentlyOpened: true, experimental_enableStaticContextualization: false, }, - } as any - - // Create mock IDE - mockIde = { - getWorkspaceDirs: vi.fn().mockResolvedValue(["/test"]), - getClipboardContent: vi.fn().mockResolvedValue({ - text: "clipboard content", - copiedAt: "2024-01-01T00:00:00.000Z", - }), + } as HelperVars + ide = { + getClipboardContent: vi.fn().mockResolvedValue({ text: "clipboard", copiedAt: "2024-01-01" }), readFile: vi.fn().mockResolvedValue("file content"), - } as any - - // Create mock LSP function - mockGetDefinitionsFromLsp = vi.fn().mockResolvedValue([]) - - // Create mock context retrieval service - mockContextRetrievalService = { + } as unknown as IDE + context = { getRootPathSnippets: vi.fn().mockResolvedValue([]), getSnippetsFromImportDefinitions: vi.fn().mockResolvedValue([]), getStaticContextSnippets: vi.fn().mockResolvedValue([]), - } as any - }) - - afterEach(() => { - vi.clearAllMocks() + } as unknown as ContextRetrievalService }) - describe("getAllSnippets with race conditions", () => { - it("should return all snippet types", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result).toHaveProperty("rootPathSnippets") - expect(result).toHaveProperty("importDefinitionSnippets") - expect(result).toHaveProperty("ideSnippets") - expect(result).toHaveProperty("recentlyEditedRangeSnippets") - expect(result).toHaveProperty("diffSnippets") - expect(result).toHaveProperty("clipboardSnippets") - expect(result).toHaveProperty("recentlyVisitedRangesSnippets") - expect(result).toHaveProperty("recentlyOpenedFileSnippets") - expect(result).toHaveProperty("staticSnippet") - }) - - it("should collect recently edited snippets synchronously", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyEditedRangeSnippets).toHaveLength(1) - expect(result.recentlyEditedRangeSnippets[0]).toEqual({ - filepath: "/test/recent.ts", - content: "const x = 1;\nconst y = 2;", - type: AutocompleteSnippetType.Code, - }) - }) - - it("should pass through recently visited ranges", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyVisitedRangesSnippets).toEqual(mockHelper.input.recentlyVisitedRanges) - }) - - it("should timeout slow snippet sources after default 100ms", async () => { - // Mock a slow service that takes 200ms - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve([ - { - filepath: "/slow.ts", - content: "slow", - type: AutocompleteSnippetType.Code, - }, - ]), - 200, - ) - }), - ) - - const startTime = Date.now() - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - const duration = Date.now() - startTime - - // Should timeout and return empty array, not wait 200ms - expect(result.rootPathSnippets).toEqual([]) - expect(duration).toBeLessThan(150) // Some buffer for timing - }) - - it("should return results from fast sources even if other sources are slow", async () => { - // Mock one fast and one slow source - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([ - { - filepath: "/fast.ts", - content: "fast", - type: AutocompleteSnippetType.Code, - }, - ]) - mockContextRetrievalService.getSnippetsFromImportDefinitions = vi - .fn() - .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 200))) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // Fast source should return results - expect(result.rootPathSnippets).toHaveLength(1) - expect(result.rootPathSnippets[0].content).toBe("fast") - - // Slow source should timeout and return empty - expect(result.importDefinitionSnippets).toEqual([]) - }) - - it("should collect clipboard snippets", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.clipboardSnippets).toHaveLength(1) - expect(result.clipboardSnippets[0]).toEqual({ - content: "clipboard content", - copiedAt: "2024-01-01T00:00:00.000Z", - type: AutocompleteSnippetType.Clipboard, - }) - }) - - it("should handle empty results from snippet sources", async () => { - // All sources return empty - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([]) - mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockResolvedValue([]) - mockIde.getClipboardContent = vi.fn().mockResolvedValue({ - text: "", - copiedAt: "2024-01-01T00:00:00.000Z", - }) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.rootPathSnippets).toEqual([]) - expect(result.importDefinitionSnippets).toEqual([]) - expect(result.clipboardSnippets).toHaveLength(1) // Still returns clipboard snippet - }) - - it("should return empty array for IDE snippets when disabled", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // IDE_SNIPPETS_ENABLED is false in the implementation - expect(result.ideSnippets).toEqual([]) - expect(mockGetDefinitionsFromLsp).not.toHaveBeenCalled() - }) - - it("should return empty array for diff snippets (temporarily disabled)", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.diffSnippets).toEqual([]) - }) - - it("should handle option useRecentlyEdited = false", async () => { - mockHelper.options.useRecentlyEdited = false - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyEditedRangeSnippets).toEqual([]) - }) - - it("should handle option useRecentlyOpened = false", async () => { - mockHelper.options.useRecentlyOpened = false - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyOpenedFileSnippets).toEqual([]) - }) - - it("should collect static context snippets when experimental flag is enabled", async () => { - mockHelper.options.experimental_enableStaticContextualization = true - mockContextRetrievalService.getStaticContextSnippets = vi.fn().mockResolvedValue([ - { - filepath: "/static.ts", - content: "static", - type: AutocompleteSnippetType.Static, - }, - ]) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.staticSnippet).toHaveLength(1) - expect(result.staticSnippet[0].content).toBe("static") - }) - - it("should return empty static snippets when experimental flag is disabled", async () => { - mockHelper.options.experimental_enableStaticContextualization = false - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.staticSnippet).toEqual([]) - expect(mockContextRetrievalService.getStaticContextSnippets).not.toHaveBeenCalled() + it("collects every active snippet source", async () => { + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + + expect(result).toEqual({ + rootPathSnippets: [], + importDefinitionSnippets: [], + recentlyEditedRangeSnippets: [ + { filepath: "/test/recent.ts", content: "const x = 1;", type: AutocompleteSnippetType.Code }, + ], + recentlyVisitedRangesSnippets: [ + { filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code }, + ], + clipboardSnippets: [{ content: "clipboard", copiedAt: "2024-01-01", type: AutocompleteSnippetType.Clipboard }], + recentlyOpenedFileSnippets: [], + staticSnippet: [], }) }) - describe("error handling", () => { - it("should propagate errors from context retrieval service", async () => { - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error")) - - // Errors are not caught by racePromise - they propagate if they occur before timeout - await expect( - getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }), - ).rejects.toThrow("Service error") - }) - - it("should propagate errors from IDE clipboard", async () => { - mockIde.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error")) - - // Errors are not caught by racePromise - they propagate if they occur before timeout - await expect( - getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }), - ).rejects.toThrow("Clipboard error") - }) - - it("should pass through null from snippet sources", async () => { - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue(null as any) + it("honors disabled recent-context sources", async () => { + helper.options.useRecentlyEdited = false + helper.options.useRecentlyOpened = false - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // racePromise returns null if the promise resolves to null (not converted to []) - expect(result.rootPathSnippets).toBeNull() - }) + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + expect(result.recentlyEditedRangeSnippets).toEqual([]) + expect(result.recentlyOpenedFileSnippets).toEqual([]) }) - describe("getAllSnippetsWithoutRace", () => { - it("should wait for all promises without timeout", async () => { - // Mock a slow service that takes 200ms - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve([ - { - filepath: "/slow.ts", - content: "slow", - type: AutocompleteSnippetType.Code, - }, - ]), - 200, - ) - }), - ) - - const result = await getAllSnippetsWithoutRace({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // Should wait and get results, not timeout - expect(result.rootPathSnippets).toHaveLength(1) - expect(result.rootPathSnippets[0].content).toBe("slow") - }) - - it("should return all snippet types without racing", async () => { - const result = await getAllSnippetsWithoutRace({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result).toHaveProperty("rootPathSnippets") - expect(result).toHaveProperty("importDefinitionSnippets") - expect(result).toHaveProperty("ideSnippets") - expect(result).toHaveProperty("recentlyEditedRangeSnippets") - expect(result).toHaveProperty("diffSnippets") - expect(result).toHaveProperty("clipboardSnippets") - expect(result).toHaveProperty("recentlyVisitedRangesSnippets") - expect(result).toHaveProperty("recentlyOpenedFileSnippets") - expect(result).toHaveProperty("staticSnippet") - }) - - it("should handle errors without race timeout", async () => { - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error")) - - // Should propagate error since no timeout - await expect( - getAllSnippetsWithoutRace({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }), - ).rejects.toThrow("Service error") - }) + it("collects import definitions and enabled static context", async () => { + helper.options.experimental_enableStaticContextualization = true + context.getSnippetsFromImportDefinitions = vi + .fn() + .mockResolvedValue([{ filepath: "/import.ts", content: "imported", type: AutocompleteSnippetType.Code }]) + context.getStaticContextSnippets = vi + .fn() + .mockResolvedValue([{ filepath: "/static.ts", content: "static", type: AutocompleteSnippetType.Static }]) + + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + expect(result.importDefinitionSnippets[0]?.content).toBe("imported") + expect(result.staticSnippet[0]?.content).toBe("static") }) - describe("parallel execution", () => { - it("should execute all snippet collections in parallel", async () => { - const executionOrder: string[] = [] + it("propagates clipboard failures", async () => { + ide.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error")) - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(async () => { - executionOrder.push("rootPath-start") - await new Promise((resolve) => setTimeout(resolve, 50)) - executionOrder.push("rootPath-end") - return [] - }) + await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow( + "Clipboard error", + ) + }) - mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockImplementation(async () => { - executionOrder.push("import-start") - await new Promise((resolve) => setTimeout(resolve, 50)) - executionOrder.push("import-end") - return [] - }) + it("waits for active context sources without a collector timeout", async () => { + context.getRootPathSnippets = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => resolve([{ filepath: "/slow.ts", content: "slow", type: AutocompleteSnippetType.Code }]), + 120, + ) + }), + ) - mockIde.getClipboardContent = vi.fn().mockImplementation(async () => { - executionOrder.push("clipboard-start") - await new Promise((resolve) => setTimeout(resolve, 50)) - executionOrder.push("clipboard-end") - return { text: "test", copiedAt: "2024-01-01T00:00:00.000Z" } - }) + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + expect(result.rootPathSnippets[0]?.content).toBe("slow") + }) - await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) + it("propagates context retrieval failures", async () => { + context.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error")) - // All should start before any complete (parallel execution) - expect(executionOrder[0]).toBe("rootPath-start") - expect(executionOrder[1]).toBe("import-start") - expect(executionOrder[2]).toBe("clipboard-start") - }) + await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow( + "Service error", + ) }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts index b2adc447fb7..be208b4f931 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts @@ -1,215 +1,89 @@ import { IDE } from "../../index" -import { findUriInDirs } from "../../util/uri" import { ContextRetrievalService } from "../context/ContextRetrievalService" -import { GetLspDefinitionsFunction } from "../types" import { HelperVars } from "../util/HelperVars" import { openedFilesLruCache } from "../util/openedFilesLruCache" - import { AutocompleteClipboardSnippet, AutocompleteCodeSnippet, - AutocompleteDiffSnippet, AutocompleteSnippetType, AutocompleteStaticSnippet, } from "../types" -const IDE_SNIPPETS_ENABLED = false // ideSnippets is not used, so it's temporarily disabled - export interface SnippetPayload { rootPathSnippets: AutocompleteCodeSnippet[] importDefinitionSnippets: AutocompleteCodeSnippet[] - ideSnippets: AutocompleteCodeSnippet[] recentlyEditedRangeSnippets: AutocompleteCodeSnippet[] recentlyVisitedRangesSnippets: AutocompleteCodeSnippet[] - diffSnippets: AutocompleteDiffSnippet[] clipboardSnippets: AutocompleteClipboardSnippet[] recentlyOpenedFileSnippets: AutocompleteCodeSnippet[] staticSnippet: AutocompleteStaticSnippet[] } -function racePromise(promise: Promise, timeout = 100): Promise { - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve([]), timeout) - }) - - return Promise.race([promise, timeoutPromise]) -} - -// Some IDEs might have special ways of finding snippets (e.g. JetBrains and VS Code have different "LSP-equivalent" systems, -// or they might separately track recently edited ranges) -async function getIdeSnippets( - helper: HelperVars, - ide: IDE, - getDefinitionsFromLsp: GetLspDefinitionsFunction, -): Promise { - const ideSnippets = await getDefinitionsFromLsp( - helper.input.filepath, - helper.fullPrefix + helper.fullSuffix, - helper.fullPrefix.length, - ide, - helper.lang, - ) - - if (helper.options.onlyMyCode) { - const workspaceDirs = await ide.getWorkspaceDirs() - - return ideSnippets.filter((snippet) => - workspaceDirs.some((dir) => !!findUriInDirs(snippet.filepath, [dir]).foundInDir), - ) - } - - return ideSnippets -} - function getSnippetsFromRecentlyEditedRanges(helper: HelperVars): AutocompleteCodeSnippet[] { - if (helper.options.useRecentlyEdited === false) { - return [] - } + if (helper.options.useRecentlyEdited === false) return [] - return helper.input.recentlyEditedRanges.map((range) => { - return { - filepath: range.filepath, - content: range.lines.join("\n"), - type: AutocompleteSnippetType.Code, - } - }) + return helper.input.recentlyEditedRanges.map((range) => ({ + filepath: range.filepath, + content: range.lines.join("\n"), + type: AutocompleteSnippetType.Code, + })) } const getClipboardSnippets = async (ide: IDE): Promise => { const content = await ide.getClipboardContent() - - return [content].map((item) => { - return { - content: item.text, - copiedAt: item.copiedAt, + return [ + { + content: content.text, + copiedAt: content.copiedAt, type: AutocompleteSnippetType.Clipboard, - } - }) + }, + ] } const getSnippetsFromRecentlyOpenedFiles = async (helper: HelperVars, ide: IDE): Promise => { - if (helper.options.useRecentlyOpened === false) { - return [] - } + if (helper.options.useRecentlyOpened === false) return [] try { - const currentFileUri = `${helper.filepath}` - - // Get all file URIs excluding the current file - const fileUrisToRead = [...openedFilesLruCache.entriesDescending()] - .filter(([fileUri, _]) => fileUri !== currentFileUri) - .map(([fileUri, _]) => fileUri) - - // Create an array of promises that each read a file with timeout - const fileReadPromises = fileUrisToRead.map((fileUri) => { - // Create a promise that resolves to a snippet or null - const readPromise = new Promise((resolve) => { + const current = `${helper.filepath}` + const uris = [...openedFilesLruCache.entriesDescending()].filter(([uri]) => uri !== current).map(([uri]) => uri) + const reads = uris.map((uri) => { + const read = new Promise((resolve) => { ide - .readFile(fileUri) - .then((fileContent) => { - if (!fileContent || fileContent.trim() === "") { + .readFile(uri) + .then((content) => { + if (!content || content.trim() === "") { resolve(null) return } - - resolve({ - filepath: fileUri, - content: fileContent, - type: AutocompleteSnippetType.Code, - }) + resolve({ filepath: uri, content, type: AutocompleteSnippetType.Code }) }) - .catch((e) => { - console.error(`Failed to read file ${fileUri}:`, e) + .catch((err) => { + console.error(`Failed to read file ${uri}:`, err) resolve(null) }) }) - // Cut off at 80ms via racing promises - return Promise.race([readPromise, new Promise((resolve) => setTimeout(() => resolve(null), 80))]) + return Promise.race([read, new Promise((resolve) => setTimeout(() => resolve(null), 80))]) }) - - // Execute all file reads in parallel - const results = await Promise.all(fileReadPromises) - - // Filter out null results + const results = await Promise.all(reads) return results.filter(Boolean) as AutocompleteCodeSnippet[] - } catch (e) { - console.error("Error processing opened files cache:", e) + } catch (err) { + console.error("Error processing opened files cache:", err) return [] } } -export const getAllSnippets = async ({ - helper, - ide, - getDefinitionsFromLsp, - contextRetrievalService, -}: { - helper: HelperVars - ide: IDE - getDefinitionsFromLsp: GetLspDefinitionsFunction - contextRetrievalService: ContextRetrievalService -}): Promise => { - const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper) - - const [ - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - diffSnippets, - clipboardSnippets, - recentlyOpenedFileSnippets, - staticSnippet, - ] = await Promise.all([ - racePromise(contextRetrievalService.getRootPathSnippets(helper)), - racePromise(contextRetrievalService.getSnippetsFromImportDefinitions(helper)), - IDE_SNIPPETS_ENABLED ? racePromise(getIdeSnippets(helper, ide, getDefinitionsFromLsp)) : [], - [], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882, - racePromise(getClipboardSnippets(ide)), - racePromise(getSnippetsFromRecentlyOpenedFiles(helper, ide)), // giving this one a little more time to complete - helper.options.experimental_enableStaticContextualization - ? racePromise(contextRetrievalService.getStaticContextSnippets(helper)) - : [], - ]) - - return { - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - recentlyEditedRangeSnippets, - diffSnippets, - clipboardSnippets, - recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges, - recentlyOpenedFileSnippets, - staticSnippet, - } -} - export const getAllSnippetsWithoutRace = async ({ helper, ide, - getDefinitionsFromLsp, contextRetrievalService, }: { helper: HelperVars ide: IDE - getDefinitionsFromLsp: GetLspDefinitionsFunction contextRetrievalService: ContextRetrievalService }): Promise => { - const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper) - - const [ - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - diffSnippets, - clipboardSnippets, - recentlyOpenedFileSnippets, - staticSnippet, - ] = await Promise.all([ + const [root, imports, clipboard, opened, staticSnippet] = await Promise.all([ contextRetrievalService.getRootPathSnippets(helper), contextRetrievalService.getSnippetsFromImportDefinitions(helper), - IDE_SNIPPETS_ENABLED ? getIdeSnippets(helper, ide, getDefinitionsFromLsp) : [], - [], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882, getClipboardSnippets(ide), getSnippetsFromRecentlyOpenedFiles(helper, ide), helper.options.experimental_enableStaticContextualization @@ -218,14 +92,12 @@ export const getAllSnippetsWithoutRace = async ({ ]) return { - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - recentlyEditedRangeSnippets, - diffSnippets, - clipboardSnippets, + rootPathSnippets: root, + importDefinitionSnippets: imports, + recentlyEditedRangeSnippets: getSnippetsFromRecentlyEditedRanges(helper), recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges, - recentlyOpenedFileSnippets, + clipboardSnippets: clipboard, + recentlyOpenedFileSnippets: opened, staticSnippet, } } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts index 229fdbf5e35..4904928e4af 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts @@ -5,7 +5,7 @@ import { CompletionOptions } from "../../index.js" import { getLastNUriRelativePathParts, getShortestUniqueRelativeUriPaths } from "../../util/uri.js" -import { AutocompleteSnippet, AutocompleteSnippetType } from "../types.js" +import { AutocompleteSnippet } from "../types.js" type TemplateRenderer = ( prefix: string, @@ -49,13 +49,7 @@ const codestralMultifileFimTemplate: AutocompleteTemplate = { ) const otherFiles = snippets - .map((snippet, i) => { - if (snippet.type === AutocompleteSnippetType.Diff) { - return snippet.content - } - - return `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}` - }) + .map((snippet, i) => `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}`) .join("\n\n") return [`${otherFiles}\n\n+++++ ${getFileName(relativePaths[relativePaths.length - 1])}\n${prefix}`, suffix] @@ -93,13 +87,7 @@ const mercuryMultifileFimTemplate: AutocompleteTemplate = { ) const otherFiles = snippets - .map((snippet, i) => { - if (snippet.type === AutocompleteSnippetType.Diff) { - return snippet.content - } - - return `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}` - }) + .map((snippet, i) => `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}`) .join("\n\n") return [ diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts index b54a1e28714..33d3c9c9173 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts @@ -4,7 +4,7 @@ */ import { describe, expect, test } from "vitest" -import { AutocompleteCodeSnippet, AutocompleteDiffSnippet, AutocompleteSnippetType } from "../../types" +import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../types" import { HelperVars } from "../../util/HelperVars" import { formatOpenedFilesContext } from "../formatOpenedFilesContext" @@ -25,11 +25,6 @@ describe("formatOpenedFilesContext main function tests", () => { content, }) - const createDiffSnippet = (content: string): AutocompleteDiffSnippet => ({ - type: AutocompleteSnippetType.Diff, - content, - }) - test("should return empty array when no snippets are provided", () => { const result = formatOpenedFilesContext([], 1000, mockHelper, [], TOKEN_BUFFER) expect(result).toEqual([]) @@ -99,7 +94,7 @@ describe("formatOpenedFilesContext main function tests", () => { createCodeSnippet("file2.ts", "content of file 2"), ] - const alreadyAddedSnippets = [createDiffSnippet("diff content")] + const alreadyAddedSnippets = [createCodeSnippet("added.ts", "added content")] const result = formatOpenedFilesContext(snippets, 1000, mockHelper, alreadyAddedSnippets, TOKEN_BUFFER) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts index c4d0941a7a0..772f2f2ccc1 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts @@ -44,7 +44,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco clipboard: payload.clipboardSnippets, recentlyVisitedRanges: payload.recentlyVisitedRangesSnippets, recentlyEditedRanges: payload.recentlyEditedRangeSnippets, - diff: payload.diffSnippets, recentlyOpenedFiles: payload.recentlyOpenedFileSnippets, base: shuffleArray( filterSnippetsAlreadyInCaretWindow( @@ -88,13 +87,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco defaultPriority: 4, snippets: payload.recentlyEditedRangeSnippets, }, - { - key: "diff", - enabledOrPriority: helper.options.experimental_includeDiff, - defaultPriority: 5, - snippets: payload.diffSnippets, - // TODO: diff is commonly too large, thus anything lower in priority is not included. - }, { key: "base", enabledOrPriority: true, diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts index 276440cb333..92fd0dfa20a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts @@ -1,9 +1,7 @@ -import { IDE, RangeInFileWithContents } from "../index" -import { AutocompleteLanguageInfo } from "./constants/AutocompleteLanguageInfo" +import { RangeInFileWithContents } from "../index" export enum AutocompleteSnippetType { Code = "code", - Diff = "diff", Clipboard = "clipboard", Static = "static", } @@ -18,10 +16,6 @@ export interface AutocompleteCodeSnippet extends BaseAutocompleteSnippet { type: AutocompleteSnippetType.Code } -export interface AutocompleteDiffSnippet extends BaseAutocompleteSnippet { - type: AutocompleteSnippetType.Diff -} - export interface AutocompleteClipboardSnippet extends BaseAutocompleteSnippet { type: AutocompleteSnippetType.Clipboard copiedAt: string @@ -32,20 +26,8 @@ export interface AutocompleteStaticSnippet extends BaseAutocompleteSnippet { filepath: string } -export type AutocompleteSnippet = - | AutocompleteCodeSnippet - | AutocompleteDiffSnippet - | AutocompleteClipboardSnippet - | AutocompleteStaticSnippet +export type AutocompleteSnippet = AutocompleteCodeSnippet | AutocompleteClipboardSnippet | AutocompleteStaticSnippet export type RankedSnippet = RangeInFileWithContents & { score?: number } - -export type GetLspDefinitionsFunction = ( - filepath: string, - contents: string, - cursorIndex: number, - ide: IDE, - lang: AutocompleteLanguageInfo, -) => Promise diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.test.ts deleted file mode 100644 index 58c569c8b1f..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Generated by continue - -import { describe, expect, it, vi } from "vitest" -import { ChatMessage } from "../index" -import { generateLines, streamLines } from "./util" - -describe("streamLines", () => { - it("should split chunks into lines correctly", async () => { - async function* streamCompletion(): AsyncGenerator { - yield "line1\nline" - yield "2\nline3\n" - yield "line4" - } - - const resultLines: string[] = [] - for await (const line of streamLines(streamCompletion())) { - resultLines.push(line) - } - - expect(resultLines).toEqual(["line1", "line2", "line3", "line4"]) - }) - - it("should handle ChatMessage chunks", async () => { - const messageChunk1: ChatMessage = { - role: "assistant", - content: "line1\nline", - } - const messageChunk2: ChatMessage = { - role: "assistant", - content: "2\nline3\n", - } - const messageChunk3: ChatMessage = { - role: "assistant", - content: "line4", - } - - // const spy = vi.spyOn(messageContentModule, "renderChatMessage"); - - async function* streamCompletion(): AsyncGenerator { - yield messageChunk1 - yield messageChunk2 - yield messageChunk3 - } - - const resultLines: string[] = [] - for await (const line of streamLines(streamCompletion())) { - resultLines.push(line) - } - - expect(resultLines).toEqual(["line1", "line2", "line3", "line4"]) - // expect(spy).toHaveBeenCalledTimes(3); - }) - - it("should log lines if log parameter is true", async () => { - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) - - async function* streamCompletion(): AsyncGenerator { - yield "line1\nline2\n" - yield "line3" - } - - const resultLines: string[] = [] - for await (const line of streamLines(streamCompletion(), true)) { - resultLines.push(line) - } - - expect(resultLines).toEqual(["line1", "line2", "line3"]) - expect(consoleSpy).toHaveBeenCalledWith("Streamed lines: ", "line1\nline2\nline3") - - consoleSpy.mockRestore() - }) -}) - -describe("generateLines", () => { - it("should yield the lines provided in the array", async () => { - const lines = ["line1", "line2", "line3"] - const resultLines: string[] = [] - - for await (const line of generateLines(lines)) { - resultLines.push(line) - } - - expect(resultLines).toEqual(lines) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.ts deleted file mode 100644 index d5da1720c3c..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ChatMessage } from "../index.js" -import { renderChatMessage } from "../util/messageContent.js" - -export type LineStream = AsyncGenerator - -/** - * Convert a stream of arbitrary chunks to a stream of lines - */ -export async function* streamLines( - streamCompletion: AsyncGenerator, - log: boolean = false, -): LineStream { - const allLines = [] - let buffer = "" - - try { - for await (const update of streamCompletion) { - const chunk = typeof update === "string" ? update : renderChatMessage(update) - buffer += chunk - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - yield line - allLines.push(line) - } - } - if (buffer.length > 0) { - yield buffer - allLines.push(buffer) - } - } finally { - if (log) { - console.log("Streamed lines: ", allLines.join("\n")) - } - } -} - -export async function* generateLines(lines: T[]): AsyncGenerator { - for (const line of lines) { - yield line - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts index 5585fe8a087..66b868d0c6d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts @@ -531,7 +531,6 @@ export interface TabAutocompleteOptions { experimental_includeClipboard: boolean | number experimental_includeRecentlyVisitedRanges: boolean | number experimental_includeRecentlyEditedRanges: boolean | number - experimental_includeDiff: boolean | number experimental_enableStaticContextualization: boolean } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/messageContent.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/messageContent.ts deleted file mode 100644 index 1b5ed9fa83e..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/messageContent.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ChatMessage, MessageContent, TextMessagePart } from "../index" - -function stripImages(messageContent: MessageContent): string { - if (typeof messageContent === "string") { - return messageContent - } - - return messageContent - .filter((part) => part.type === "text") - .map((part) => (part as TextMessagePart).text) - .join("\n") -} - -export function renderChatMessage(message: ChatMessage): string { - switch (message?.role) { - case "user": - case "assistant": - case "system": - return stripImages(message.content) - default: - return "" - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts index 18ee68de274..403127b0808 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts @@ -25,6 +25,5 @@ export const DEFAULT_AUTOCOMPLETE_OPTS: TabAutocompleteOptions = { experimental_includeClipboard: false, experimental_includeRecentlyVisitedRanges: true, experimental_includeRecentlyEditedRanges: true, - experimental_includeDiff: true, experimental_enableStaticContextualization: false, } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts index 08598703daa..abafd75ae9a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts @@ -13,34 +13,3 @@ export function getRangeInString(content: string, range: Range): string { return [firstLine, ...middleLines, lastLine].join("\n") } - -export function intersection(a: Range, b: Range): Range | null { - const startLine = Math.max(a.start.line, b.start.line) - const endLine = Math.min(a.end.line, b.end.line) - - if (startLine > endLine) { - return null - } - - if (startLine === endLine) { - const startCharacter = Math.max(a.start.character, b.start.character) - const endCharacter = Math.min(a.end.character, b.end.character) - - if (startCharacter > endCharacter) { - return null - } - - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - } - } - - const startCharacter = startLine === a.start.line ? a.start.character : b.start.character - const endCharacter = endLine === a.end.line ? a.end.character : b.end.character - - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.test.ts deleted file mode 100644 index 76e58188f4c..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest" -import { getSymbolsForFile } from "./treeSitter" - -// vibecoded -describe("getSymbolsForFile", () => { - it("should extract symbols from Python code", async () => { - const filepath = "test.py" - const contents = `def greet(name): - return f"Hello, {name}!" - -class Calculator: - def add(self, a, b): - return a + b -` - - const symbols = await getSymbolsForFile(filepath, contents) - - // Verify we get symbols - expect(symbols).toBeDefined() - expect(symbols!.length).toBeGreaterThan(0) - - // Verify function symbol - const greetSymbol = symbols?.find((s) => s.name === "greet") - expect(greetSymbol).toBeDefined() - expect(greetSymbol?.type).toBe("function_definition") - expect(greetSymbol?.filepath).toBe(filepath) - expect(greetSymbol?.range.start.line).toBe(0) - expect(greetSymbol?.content).toContain("def greet") - - // Verify class symbol - const calculatorSymbol = symbols?.find((s) => s.name === "Calculator") - expect(calculatorSymbol).toBeDefined() - expect(calculatorSymbol?.type).toBe("class_definition") - expect(calculatorSymbol?.content).toContain("class Calculator") - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts index a8aa4961f69..a3afdd03a2f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts @@ -6,7 +6,6 @@ type Language = Parser.Language type SyntaxNode = Parser.SyntaxNode type Query = Parser.Query type Tree = Parser.Tree -import { SymbolWithRange } from ".." import { getUriFileExtension } from "./uri" export enum LanguageName { @@ -259,87 +258,3 @@ async function loadLanguageForFileExt(fileExtension: string): Promise return await Language.load(wasmPath) } - -// See https://tree-sitter.github.io/tree-sitter/using-parsers -const GET_SYMBOLS_FOR_NODE_TYPES: SyntaxNode["type"][] = [ - "class_declaration", - "class_definition", - "function_item", // function name = first "identifier" child - "function_definition", - "method_declaration", // method name = first "identifier" child - "method_definition", - "generator_function_declaration", - // property_identifier - // field_declaration - // "arrow_function", -] - -export async function getSymbolsForFile(filepath: string, contents: string): Promise { - //MINIMAL_REPO - continue doesn't use this in autocomplete - const parser = await getParserForFile(filepath) - if (!parser) { - return - } - - let tree: Tree | null - try { - tree = parser.parse(contents) - } catch { - console.log(`Error parsing file: ${filepath}`) - return - } - - if (!tree) { - console.log(`Failed to parse file: ${filepath}`) - return - } - // console.log(`file: ${filepath}`); - - // Function to recursively find all named nodes (classes and functions) - const symbols: SymbolWithRange[] = [] - function findNamedNodesRecursive(node: SyntaxNode) { - // console.log(`node: ${node.type}, ${node.text}`); - if (GET_SYMBOLS_FOR_NODE_TYPES.includes(node.type)) { - // console.log(`parent: ${node.type}, ${node.text.substring(0, 200)}`); - // node.children.forEach((child) => { - // console.log(`child: ${child.type}, ${child.text}`); - // }); - - // Empirically, the actual name is the last identifier in the node - // Especially with languages where return type is declared before the name - // TODO use findLast in newer version of node target - let identifier: SyntaxNode | undefined = undefined - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i] - if (child && (child.type === "identifier" || child.type === "property_identifier")) { - identifier = child - break - } - } - - if (identifier?.text) { - symbols.push({ - filepath, - type: node.type, - name: identifier.text, - range: { - start: { - character: node.startPosition.column, - line: node.startPosition.row, - }, - end: { - character: node.endPosition.column + 1, - line: node.endPosition.row + 1, - }, - }, - content: node.text, - }) - } - } - node.children.forEach((child) => { - if (child) findNamedNodesRecursive(child) - }) - } - findNamedNodesRecursive(tree.rootNode) - return symbols -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts index a037487f2d4..c13ba3a0289 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts @@ -1,22 +1,6 @@ -import { AutocompleteLanguageInfo } from "../../../autocomplete/constants/AutocompleteLanguageInfo" -import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../../autocomplete/types" -import { GetLspDefinitionsFunction } from "../../../autocomplete/types" -import { getAst, getTreePathAtCursor } from "../../../autocomplete/util/ast" -import { intersection } from "../../../util/ranges" - import * as vscode from "vscode" -import type { DocumentSymbol, IDE, Range, RangeInFile, RangeInFileWithContents, SignatureHelp } from "../../../" -import type Parser from "web-tree-sitter" -type SyntaxNode = Parser.SyntaxNode -const FUNCTION_BLOCK_NODE_TYPES = ["block", "statement_block"] -const FUNCTION_DECLARATION_NODE_TYPEs = [ - "method_definition", - "function_definition", - "function_item", - "function_declaration", - "method_declaration", -] +import type { DocumentSymbol, RangeInFile, SignatureHelp } from "../../../" type GotoProviderName = | "vscode.executeDefinitionProvider" @@ -120,283 +104,6 @@ export async function executeGotoProvider(input: GotoInput): Promise boolean, firstN?: number): SyntaxNode[] { - let matchingNodes: SyntaxNode[] = [] - - if (firstN && firstN <= 0) { - return [] - } - - // Check if the current node's type is in the list of types we're interested in - if (predicate(node)) { - matchingNodes.push(node) - } - - // Recursively search for matching types in all children of the current node - for (const child of node.children) { - if (!child) continue - matchingNodes = matchingNodes.concat( - findChildren(child, predicate, firstN ? firstN - matchingNodes.length : undefined), - ) - } - - return matchingNodes -} - -function findTypeIdentifiers(node: SyntaxNode): SyntaxNode[] { - return findChildren( - node, - (childNode) => - childNode.type === "type_identifier" || - (["ERROR"].includes(childNode.parent?.type ?? "") && - childNode.type === "identifier" && - childNode.text[0].toUpperCase() === childNode.text[0]), - ) -} - -async function crawlTypes( - rif: RangeInFile | RangeInFileWithContents, - ide: IDE, - depth: number = 1, - results: RangeInFileWithContents[] = [], - searchedLabels: Set = new Set(), -): Promise { - // Get the file contents if not already attached - const contents = isRifWithContents(rif) ? rif.contents : await ide.readFile(rif.filepath) - - // Parse AST - const ast = await getAst(rif.filepath, contents) - if (!ast) { - return results - } - const astLineCount = ast.rootNode.text.split("\n").length - - // Find type identifiers - const identifierNodes = findTypeIdentifiers(ast.rootNode).filter((node) => !searchedLabels.has(node.text)) - // Don't search for the same type definition more than once - // We deduplicate below to be sure, but this saves calls to the LSP - identifierNodes.forEach((node) => searchedLabels.add(node.text)) - - // Use LSP to get the definitions of those types - const definitions = [] - - for (const node of identifierNodes) { - const [typeDef] = await executeGotoProvider({ - uri: vscode.Uri.parse(rif.filepath), - // TODO: tree-sitter is zero-indexed, but there seems to be an off-by-one - // error at least with the .ts parser sometimes - line: rif.range.start.line + Math.min(node.startPosition.row, astLineCount - 1), - character: rif.range.start.character + node.startPosition.column, - name: "vscode.executeDefinitionProvider", - }) - - if (!typeDef) { - definitions.push(undefined) - continue - } - - const contents = await ide.readRangeInFile(typeDef.filepath, typeDef.range) - - definitions.push({ - ...typeDef, - contents, - }) - } - - // TODO: Filter out if not in our code? - - // Filter out duplicates - for (const definition of definitions) { - if ( - !definition || - results.some( - (result) => result.filepath === definition.filepath && intersection(result.range, definition.range) !== null, - ) - ) { - continue // ;) - } - results.push(definition) - } - - // Recurse - if (depth > 0) { - for (const result of [...results]) { - await crawlTypes(result, ide, depth - 1, results, searchedLabels) - } - } - - return results -} - -async function getDefinitionsForNode( - uri: vscode.Uri, - node: SyntaxNode, - ide: IDE, - lang: AutocompleteLanguageInfo, -): Promise { - const ranges: (RangeInFile | RangeInFileWithContents)[] = [] - switch (node.type) { - case "call_expression": { - // function call -> function definition - const [funDef] = await executeGotoProvider({ - uri, - line: node.startPosition.row, - character: node.startPosition.column, - name: "vscode.executeDefinitionProvider", - }) - if (!funDef) { - return [] - } - - // Don't display a function of more than 15 lines - // We can of course do something smarter here eventually - let funcText = await ide.readRangeInFile(funDef.filepath, funDef.range) - if (funcText.split("\n").length > 15) { - let truncated = false - const funRootAst = await getAst(funDef.filepath, funcText) - if (funRootAst) { - const [funNode] = findChildren( - funRootAst?.rootNode, - (node) => FUNCTION_DECLARATION_NODE_TYPEs.includes(node.type), - 1, - ) - if (funNode) { - const [statementBlockNode] = findChildren( - funNode, - (node) => FUNCTION_BLOCK_NODE_TYPES.includes(node.type), - 1, - ) - if (statementBlockNode) { - funcText = funRootAst.rootNode.text.slice(0, statementBlockNode.startIndex).trim() - truncated = true - } - } - } - if (!truncated) { - funcText = funcText.split("\n")[0] - } - } - - ranges.push(funDef) - - const typeDefs = await crawlTypes( - { - ...funDef, - contents: funcText, - }, - ide, - ) - ranges.push(...typeDefs) - break - } - case "variable_declarator": - // variable assignment -> variable definition/type - // usages of the var that appear after the declaration - break - case "impl_item": - // impl of trait -> trait definition - break - case "new_expression": { - // In 'new MyClass(...)', "MyClass" is the classNameNode - const classNameNode = node.children.find((child) => child && child.type === "identifier") - const [classDef] = await executeGotoProvider({ - uri, - line: (classNameNode ?? node).endPosition.row, - character: (classNameNode ?? node).endPosition.column, - name: "vscode.executeDefinitionProvider", - }) - if (!classDef) { - break - } - const contents = await ide.readRangeInFile(classDef.filepath, classDef.range) - - ranges.push({ - ...classDef, - contents: `${ - classNameNode?.text ? `${lang.singleLineComment} ${classNameNode.text}:\n` : "" - }${contents.trim()}`, - }) - - const definitions = await crawlTypes({ ...classDef, contents }, ide) - ranges.push(...definitions.filter(Boolean)) - - break - } - case "": - // function definition -> implementations? - break - } - return await Promise.all( - ranges.map(async (rif) => { - // Convert the VS Code Range type to ours - const range: Range = { - start: { - line: rif.range.start.line, - character: rif.range.start.character, - }, - end: { - line: rif.range.end.line, - character: rif.range.end.character, - }, - } - rif.range = range - - if (!isRifWithContents(rif)) { - return { - ...rif, - contents: await ide.readRangeInFile(rif.filepath, rif.range), - } - } - return rif - }), - ) -} - -/** - * and other stuff not directly on the path: - * - variables defined on line above - * ...etc... - */ - -export const getDefinitionsFromLsp: GetLspDefinitionsFunction = async ( - filepath: string, - contents: string, - cursorIndex: number, - ide: IDE, - lang: AutocompleteLanguageInfo, -): Promise => { - try { - const ast = await getAst(filepath, contents) - if (!ast) { - return [] - } - - const treePath = await getTreePathAtCursor(ast, cursorIndex) - if (!treePath) { - return [] - } - - const results: RangeInFileWithContents[] = [] - for (const node of treePath.reverse()) { - const definitions = await getDefinitionsForNode(vscode.Uri.parse(filepath), node, ide, lang) - results.push(...definitions) - } - - return results.map((result) => ({ - filepath: result.filepath, - content: result.contents, - type: AutocompleteSnippetType.Code, - })) - } catch (e) { - console.warn("Error getting definitions from LSP: ", e) - return [] - } -} - type SymbolProviderName = "vscode.executeDocumentSymbolProvider" interface SymbolInput { diff --git a/packages/kilo-vscode/src/services/autocomplete/fim.ts b/packages/kilo-vscode/src/services/autocomplete/fim.ts index 8e5fcb429c6..d45ecd65b9d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/fim.ts +++ b/packages/kilo-vscode/src/services/autocomplete/fim.ts @@ -1,6 +1,8 @@ import { ResponseMetaData } from "./types" import type { KiloConnectionService } from "../cli-backend" -import { getAutocompleteModel } from "../../shared/autocomplete-models" +import { getAutocompleteModelById } from "../../shared/autocomplete-models" + +const FIM_MAX_TOKENS = 256 /** * Generate a FIM (Fill-in-the-Middle) completion via the CLI backend. @@ -17,7 +19,7 @@ export async function generateFim( signal?: AbortSignal, ): Promise { const client = await connectionService.getClientAsync() - + const info = getAutocompleteModelById(modelId) let cost = 0 let inputTokens = 0 let outputTokens = 0 @@ -27,15 +29,16 @@ export async function generateFim( // ends the stream. Without this, errors never reach ErrorBackoff. let sseError: Error | undefined - const temp = getAutocompleteModel(modelId).temperature + console.info(`[FIM] request provider=${info.providerID} model=${info.requestModel} url=/kilo/fim`) const { stream } = await client.kilo.fim( { prefix, suffix, - model: modelId, - maxTokens: 256, - temperature: temp, + provider: info.providerID, + model: info.modelID, + maxTokens: FIM_MAX_TOKENS, + temperature: info.temperature, }, { signal, diff --git a/packages/kilo-vscode/src/services/autocomplete/index.ts b/packages/kilo-vscode/src/services/autocomplete/index.ts index 9b8a22eba1f..7b106dc03c1 100644 --- a/packages/kilo-vscode/src/services/autocomplete/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/index.ts @@ -1,12 +1,22 @@ import * as vscode from "vscode" import { AutocompleteServiceManager } from "./AutocompleteServiceManager" import { ensureBackendForAutocomplete } from "./ensure-backend" +import { migrateDefaultAutocompleteSettings } from "./migrate-default" +import { nesLog } from "./next-edit/log" +import { INLINE_COMPLETION_ACCEPTED_COMMAND as NEXT_EDIT_ACCEPTED_COMMAND } from "./next-edit/NextEditInlineCompletionProvider" +import { chainNextPrediction } from "./next-edit/NextEditSuggestionManager" import type { KiloConnectionService } from "../cli-backend" -export const registerAutocompleteProvider = ( +export const registerAutocompleteProvider = async ( context: vscode.ExtensionContext, connectionService: KiloConnectionService, ) => { + // Run before constructing the manager so its initial readSettings() sees + // the cleared state and behaves as "Not set." Awaited because the manager's + // constructor synchronously kicks off readSettings() via load(), which would + // otherwise race with the migration. + await migrateDefaultAutocompleteSettings(context) + const autocompleteManager = new AutocompleteServiceManager(context, connectionService) context.subscriptions.push(autocompleteManager) @@ -42,6 +52,27 @@ export const registerAutocompleteProvider = ( await autocompleteManager.disable() }), ) + // Fired by VSCode when the user accepts a Next Edit same-line ghost. Chains + // the next prediction so users can walk a refactor with repeated Tabs. + context.subscriptions.push( + vscode.commands.registerCommand(NEXT_EDIT_ACCEPTED_COMMAND, () => { + nesLog("suggestion accepted") + if (autocompleteManager.currentMode === "next-edit") chainNextPrediction() + }), + ) + // Tab handler for off-cursor pending suggestions: first press teleports the + // cursor to the predicted edit, second press applies. + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.acceptOrJump", async () => { + await autocompleteManager.nextEditSuggestionManager.acceptOrJump() + }), + ) + // Esc handler: dismiss the pending suggestion without applying. + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.dismiss", () => { + autocompleteManager.nextEditSuggestionManager.clear() + }), + ) // Register AutocompleteServiceManager Code Actions context.subscriptions.push( diff --git a/packages/kilo-vscode/src/services/autocomplete/migrate-default.ts b/packages/kilo-vscode/src/services/autocomplete/migrate-default.ts new file mode 100644 index 00000000000..9a4eeed4145 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/migrate-default.ts @@ -0,0 +1,40 @@ +import * as vscode from "vscode" +import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../shared/autocomplete-models" + +const FLAG = "kilo.autocomplete.defaultClearMigrationV1" + +/** + * One-time migration: clear `kilo-code.new.autocomplete.{provider,model}` when + * they exactly match the current `DEFAULT_AUTOCOMPLETE_MODEL`. Many users have + * the default explicitly stored only because it was the only thing visible in + * the dropdown — leaving it pinned would block them from picking up future + * default changes (e.g. a switch to Mercury Next Edit) silently. After the + * migration runs they show up as "Not set" and follow the resolved default. + * + * Users who picked a different model are untouched. The migration runs once + * per machine and is gated on a globalState flag. + * + * TODO(2026-09): remove this migration. By September 2026 the cohort that + * needed it will either have migrated already or be fine staying pinned. + */ +export async function migrateDefaultAutocompleteSettings(context: vscode.ExtensionContext): Promise { + if (context.globalState.get(FLAG)) return + + const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") + // Read the user/global scope specifically. `get()` returns the merged + // effective value (workspace > global > default), which would let a + // workspace-level pin or the schema default falsely look like a stored + // global default and cause us to no-op while still flipping the flag. + const provider = config.inspect("provider")?.globalValue + const model = config.inspect("model")?.globalValue + + const matchesDefault = + provider === DEFAULT_AUTOCOMPLETE_MODEL.providerID && model === DEFAULT_AUTOCOMPLETE_MODEL.modelID + + if (matchesDefault) { + await config.update("provider", undefined, vscode.ConfigurationTarget.Global) + await config.update("model", undefined, vscode.ConfigurationTarget.Global) + } + + await context.globalState.update(FLAG, true) +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts new file mode 100644 index 00000000000..e1886b8639b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/MercuryEditProvider.ts @@ -0,0 +1,111 @@ +import type { KiloConnectionService } from "../../cli-backend" +import { nesLog, nesWarn } from "./log" +import type { MercuryEditRequestContext, MercuryEditSuggestion } from "./types" + +const MERCURY_MAX_TOKENS = 512 +const DEFAULT_PROVIDER_ID = "inception" +const DEFAULT_MODEL_ID = "mercury-next-edit" + +type EditResponseData = { content?: string; usage?: { prompt_tokens?: number; completion_tokens?: number } } + +export interface MercuryEditProviderOptions { + connectionService: KiloConnectionService + /** Provider id to send to the gateway (e.g. `"kilo"` or `"inception"`). */ + providerId?: string + /** Model id to send to the gateway (e.g. `"inception/mercury-next-edit"`). */ + modelId?: string + /** AbortSignal for cancellation (cursor moves, escape, etc.). */ + signal?: AbortSignal +} + +/** + * Thin wrapper around the SDK's `client.kilo.edit(...)` endpoint (non-streaming). + * The gateway (`packages/kilo-gateway/src/server/edit.ts`) handles auth, routing + * to Mercury's `/v1/edit/completions`, and unwrapping the triple-backtick fence — + * so the VSCode side only deals in already-parsed code. + */ +export class MercuryEditProvider { + constructor(private readonly options: MercuryEditProviderOptions) {} + + async suggest(ctx: MercuryEditRequestContext): Promise { + const start = Date.now() + const provider = this.options.providerId ?? DEFAULT_PROVIDER_ID + const model = this.options.modelId ?? DEFAULT_MODEL_ID + nesLog( + `-> /kilo/edit provider=${provider} model=${model} region=[${ctx.editableRegionStartLine},${ctx.editableRegionEndLine}] diffs=${ctx.editDiffHistory.length} snippets=${ctx.recentlyViewedSnippets.length}`, + ) + + const client = await this.options.connectionService.getClientAsync() + try { + // Send structured editor context; the gateway assembles the Mercury prompt. + const { data, error, response } = await client.kilo.edit( + { + provider, + model, + maxTokens: MERCURY_MAX_TOKENS, + currentFilePath: ctx.currentFilePath, + currentFileContent: ctx.currentFileContent, + cursorLine: ctx.cursorLine, + cursorCharacter: ctx.cursorCharacter, + editableRegionStartLine: ctx.editableRegionStartLine, + editableRegionEndLine: ctx.editableRegionEndLine, + recentlyViewedSnippets: ctx.recentlyViewedSnippets, + editDiffHistory: ctx.editDiffHistory, + }, + { signal: this.options.signal, throwOnError: false }, + ) + const latencyMs = Date.now() - start + if (error) { + // HTTP status lives on the Response object, not the parsed error body. + const status = typeof response?.status === "number" ? response.status : null + nesWarn(`<- error ${status ?? "?"} (${latencyMs}ms): ${safeStringify(error)}`) + throw new MercuryEditError(`Edit request failed: ${status ?? "?"} ${safeStringify(error)}`, status) + } + return this.parseSuccess(ctx, data, latencyMs) + } catch (err) { + if ((err as Error)?.name === "AbortError") throw err + if (err instanceof MercuryEditError) throw err + const msg = err instanceof Error ? err.message : String(err) + nesWarn(`<- transport error: ${msg}`) + throw new MercuryEditError(`Edit request failed: ${msg}`, null) + } + } + + private parseSuccess( + ctx: MercuryEditRequestContext, + data: EditResponseData | undefined, + latencyMs: number, + ): MercuryEditSuggestion | null { + const replacement = data?.content ?? null + const usage = data?.usage + nesLog(`<- ok (${latencyMs}ms) tokens=${usage?.completion_tokens ?? "?"} parsedChars=${replacement?.length ?? 0}`) + if (replacement === null || replacement.length === 0) return null + return { + replacement, + editableRegionStartLine: ctx.editableRegionStartLine, + editableRegionEndLine: ctx.editableRegionEndLine, + latencyMs, + inputTokens: usage?.prompt_tokens, + outputTokens: usage?.completion_tokens, + } + } +} + +export class MercuryEditError extends Error { + constructor( + message: string, + public readonly status: number | null, + ) { + super(message) + this.name = "MercuryEditError" + } +} + +function safeStringify(value: unknown): string { + try { + if (typeof value === "string") return value + return JSON.stringify(value) + } catch { + return String(value) + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts new file mode 100644 index 00000000000..5d613465c5b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -0,0 +1,397 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../cli-backend" +import { computeEditableRegion } from "./editableRegion" +import { EditHistoryTracker } from "./editHistoryTracker" +import { nesLog } from "./log" +import { MercuryEditError, MercuryEditProvider } from "./MercuryEditProvider" +import type { NextEditSuggestionManager } from "./NextEditSuggestionManager" +import type { MercuryEditRequestContext, MercuryRecentSnippet } from "./types" + +const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilo-code.new.autocomplete.nextEdit.accepted" +const DEFAULT_DEBOUNCE_MS = 250 + +export interface NextEditProviderDeps { + /** Routes Mercury calls through the local Kilo gateway (handles auth + BYOK). */ + connectionService: KiloConnectionService + /** Optional source of recently-viewed snippets (kilocode's VisibleCodeTracker can adapt to this). */ + getRecentlyViewedSnippets?: (document: vscode.TextDocument) => MercuryRecentSnippet[] + /** Returns false for files that must not be sent to a server (.env etc). */ + isFileAllowed: (fsPath: string) => Promise + /** Telemetry hook fired on every suggestion result. */ + onSuggestion?: (event: NextEditSuggestionEvent) => void + onFatalError?: (status: number | null) => void + /** Stash for diffs that don't land on the cursor's line — rendered as a jump affordance. */ + suggestionManager?: NextEditSuggestionManager + /** Resolves the currently selected (provider, model) at request time. */ + getModelSelection?: () => { providerId: string; modelId: string } +} + +export interface NextEditSuggestionEvent { + shown: boolean + latencyMs: number + status: "ok" | "no-replacement" | "error" + errorStatus?: number + inputTokens?: number + outputTokens?: number +} + +/** A parsed Mercury suggestion plus the editable region it targets. */ +type SuggestionResult = { + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + latencyMs: number + inputTokens?: number + outputTokens?: number +} + +export class NextEditInlineCompletionProvider implements vscode.InlineCompletionItemProvider, vscode.Disposable { + private readonly editHistoryTracker: EditHistoryTracker + private debounceTimer: NodeJS.Timeout | null = null + private currentAbort: AbortController | null = null + + constructor(private readonly deps: NextEditProviderDeps) { + this.editHistoryTracker = new EditHistoryTracker({ isFileAllowed: deps.isFileAllowed }) + } + + dispose(): void { + this.editHistoryTracker.dispose() + if (this.debounceTimer) clearTimeout(this.debounceTimer) + this.currentAbort?.abort() + } + + async provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + if (document.uri.scheme !== "file") return undefined + if (this.deps.suggestionManager?.isPending()) return undefined + + // Never send a file unless the access policy explicitly approves it. + if (!(await this.allowed(document.uri.fsPath))) return undefined + + const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke + if (!isExplicit) { + await this.debounce(DEFAULT_DEBOUNCE_MS, token) + if (token.isCancellationRequested) return undefined + } + + const abort = this.swapAbortController(token) + const ctx = await this.buildRequestContext(document, position) + const sel = this.deps.getModelSelection?.() + const provider = new MercuryEditProvider({ + connectionService: this.deps.connectionService, + providerId: sel?.providerId, + modelId: sel?.modelId, + signal: abort.signal, + }) + + try { + const suggestion = await provider.suggest(ctx) + if (!suggestion || token.isCancellationRequested) { + this.deps.onSuggestion?.({ shown: false, latencyMs: 0, status: "no-replacement" }) + return undefined + } + return this.toCompletionItems(document, position, suggestion) + } catch (err) { + return this.handleError(err) + } + } + + private async allowed(path: string): Promise { + const allow = this.deps.isFileAllowed + if (!allow) return false + return allow(path).catch(() => false) + } + + private swapAbortController(token: vscode.CancellationToken): AbortController { + this.currentAbort?.abort() + const abort = new AbortController() + this.currentAbort = abort + token.onCancellationRequested(() => abort.abort()) + return abort + } + + private async buildRequestContext( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + const { startLine, endLine } = computeEditableRegion({ + cursorLine: position.line, + totalLines: document.lineCount, + }) + await this.editHistoryTracker.flush(document) + return { + // Mirror classic autocomplete's policy: never send an absolute fsPath upstream. + // Mercury only needs the path for language/context hints, and the workspace-relative + // form is what `recentlyViewedSnippets` already uses (see recentSnippetsAdapter.ts). + currentFilePath: vscode.workspace.asRelativePath(document.uri, false), + currentFileContent: document.getText(), + cursorLine: position.line, + cursorCharacter: position.character, + editableRegionStartLine: startLine, + editableRegionEndLine: endLine, + recentlyViewedSnippets: this.deps.getRecentlyViewedSnippets?.(document) ?? [], + editDiffHistory: await this.editHistoryTracker.getRecentDiffs(), + } + } + + private toCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + suggestion: SuggestionResult, + ): vscode.InlineCompletionItem[] | undefined { + const endLine = Math.min(suggestion.editableRegionEndLine, document.lineCount - 1) + const fullRange = new vscode.Range( + new vscode.Position(suggestion.editableRegionStartLine, 0), + document.lineAt(endLine).range.end, + ) + const currentText = document.getText(fullRange) + if (currentText === suggestion.replacement) { + this.emitNotShown(suggestion) + return undefined + } + + // Trim to minimal diff: skip identical leading and trailing lines. + const currentLines = currentText.split("\n") + const proposedLines = suggestion.replacement.split("\n") + let prefixLines = 0 + while ( + prefixLines < currentLines.length && + prefixLines < proposedLines.length && + currentLines[prefixLines] === proposedLines[prefixLines] + ) + prefixLines++ + let suffixLines = 0 + while ( + suffixLines < currentLines.length - prefixLines && + suffixLines < proposedLines.length - prefixLines && + currentLines[currentLines.length - 1 - suffixLines] === proposedLines[proposedLines.length - 1 - suffixLines] + ) + suffixLines++ + + const diffStartLineInFile = suggestion.editableRegionStartLine + prefixLines + const diffEndLineInFile = suggestion.editableRegionStartLine + currentLines.length - 1 - suffixLines + const trimmedLines = proposedLines.slice(prefixLines, proposedLines.length - suffixLines) + const trimmedReplacement = trimmedLines.join("\n") + + nesLog( + `diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`, + ) + + // VSCode's inline ghost text only renders when the diff starts on the cursor's line. + // For off-cursor diffs, stash the suggestion in the manager — it renders a + // decoration-based "jump to next edit" affordance and Tab handles the move/apply. + const isPureInsertion = diffEndLineInFile < diffStartLineInFile + const removesLines = trimmedLines.length === 0 + if (isPureInsertion || removesLines || diffStartLineInFile !== position.line) { + this.stashOffCursorSuggestion( + document, + diffStartLineInFile, + diffEndLineInFile, + trimmedReplacement, + isPureInsertion, + removesLines, + suggestion, + ) + return undefined + } + // Same-line diff: clear any prior off-cursor pending state so we don't render + // two competing affordances. + this.deps.suggestionManager?.clear() + return this.renderSameLineItem( + document, + position, + proposedLines, + prefixLines, + suffixLines, + diffStartLineInFile, + diffEndLineInFile, + trimmedReplacement, + suggestion, + ) + } + + /** Build the cursor-position ghost-text item for a same-line diff. */ + private renderSameLineItem( + document: vscode.TextDocument, + position: vscode.Position, + proposedLines: string[], + prefixLines: number, + suffixLines: number, + diffStartLine: number, + diffEndLine: number, + trimmedReplacement: string, + suggestion: SuggestionResult, + ): vscode.InlineCompletionItem[] | undefined { + const cursorLineText = document.lineAt(position.line).text + const cursorLineCurrent = cursorLineText.slice(position.character) + const cursorLineProposed = proposedLines[prefixLines] + // A pure deletion at the trim seam has no cursor-line replacement to render. + if (cursorLineProposed === undefined) { + this.emitNotShown(suggestion) + return undefined + } + // Native ghost text cannot alter text before the cursor; present that edit + // through the decoration/apply flow rather than silently discarding it. + if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) { + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) + return undefined + } + const insertText = [ + cursorLineProposed.slice(position.character), + ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines), + ].join("\n") + const renderEndLine = pickRenderEndLine(document, position.line, diffEndLine, insertText) + // A single-line insert spanning non-blank lines below the cursor can't be + // represented as inline ghost text — route it to the decoration path. + if (renderEndLine > position.line && !insertText.includes("\n")) { + this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion) + return undefined + } + const renderRange = new vscode.Range( + position, + new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character), + ) + if (document.getText(renderRange) === cursorLineCurrent && cursorLineCurrent === insertText) return undefined + + const item = new vscode.InlineCompletionItem(insertText, renderRange, { + command: INLINE_COMPLETION_ACCEPTED_COMMAND, + title: "Next Edit Accepted", + }) + nesLog( + `RENDER range=[${renderRange.start.line}:${renderRange.start.character}..${renderRange.end.line}:${renderRange.end.character}] insertChars=${insertText.length}`, + ) + this.deps.onSuggestion?.({ + shown: true, + latencyMs: suggestion.latencyMs, + status: "ok", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + return [item] + } + + private emitNotShown(suggestion: SuggestionResult): void { + this.deps.onSuggestion?.({ + shown: false, + latencyMs: suggestion.latencyMs, + status: "no-replacement", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + } + + private stashOffCursorSuggestion( + document: vscode.TextDocument, + diffStartLine: number, + diffEndLine: number, + trimmedReplacement: string, + isPureInsertion: boolean, + removesLines: boolean, + suggestion: SuggestionResult, + ): void { + const mgr = this.deps.suggestionManager + if (!mgr) { + // Manager wasn't wired — fall through silently. The classic path + // already covers same-line completions; this branch only matters in + // tests or misconfigured embeds. + this.emitNotShown(suggestion) + return + } + if (isPureInsertion) { + // The original text we snapshot must come from the line VSCode will see + // when the user later accepts. For mid-file inserts that's `diffStartLine` + // (the line that gets pushed down). For EOF inserts (diffStartLine === + // lineCount) there is no such line; fall back to lineCount-1 (the last + // line, which will sit just above the inserted content). The + // SuggestionManager's drift guard knows to compare against this anchor. + const isEof = diffStartLine >= document.lineCount + const anchorLine = isEof + ? Math.max(0, document.lineCount - 1) + : Math.max(0, Math.min(diffStartLine, document.lineCount - 1)) + mgr.setPending({ + kind: "insert", + document, + diffStartLine, + diffEndLine: diffStartLine, + replacement: trimmedReplacement + "\n", + originalText: document.lineAt(anchorLine).text, + }) + nesLog( + `insert suggestion stashed at line ${diffStartLine} (anchor=${anchorLine}, eof=${isEof}, ${trimmedReplacement.length} chars)`, + ) + } else { + const originalRange = new vscode.Range( + new vscode.Position(diffStartLine, 0), + new vscode.Position(diffEndLine, document.lineAt(diffEndLine).range.end.character), + ) + mgr.setPending({ + kind: "replace", + document, + diffStartLine, + diffEndLine, + replacement: trimmedReplacement, + removesLines, + originalText: document.getText(originalRange), + }) + nesLog(`replace suggestion stashed at lines [${diffStartLine}..${diffEndLine}]`) + } + this.deps.onSuggestion?.({ + shown: true, + latencyMs: suggestion.latencyMs, + status: "ok", + inputTokens: suggestion.inputTokens, + outputTokens: suggestion.outputTokens, + }) + } + + private handleError(err: unknown): undefined { + if ((err as Error)?.name === "AbortError") return undefined + const status = err instanceof MercuryEditError ? err.status : null + this.deps.onSuggestion?.({ + shown: false, + latencyMs: 0, + status: "error", + errorStatus: status ?? undefined, + }) + if (status === 401 || status === 402) this.deps.onFatalError?.(status) + return undefined + } + + private debounce(ms: number, token: vscode.CancellationToken): Promise { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + return new Promise((resolve) => { + this.debounceTimer = setTimeout(resolve, ms) + token.onCancellationRequested(() => { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + resolve() + }) + }) + } +} + +export { INLINE_COMPLETION_ACCEPTED_COMMAND } + +/** + * VSCode's inline ghost text silently fails to render when the completion's + * range crosses a line boundary but the insert text has no newline (typical + * when Mercury implicitly drops a trailing blank line as file-end + * normalization). When that happens — and the lines past the cursor are + * blank — cap the range at the cursor's line so the ghost renders cleanly. + */ +function pickRenderEndLine( + document: vscode.TextDocument, + cursorLine: number, + diffEndLine: number, + insertText: string, +): number { + if (diffEndLine <= cursorLine) return diffEndLine + if (insertText.includes("\n")) return diffEndLine + for (let l = cursorLine + 1; l <= diffEndLine; l++) { + if (document.lineAt(l).text.trim() !== "") return diffEndLine + } + return cursorLine +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts new file mode 100644 index 00000000000..904b9e20dda --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -0,0 +1,348 @@ +import * as vscode from "vscode" +import { nesLog } from "./log" +import { planInsertion, planReplacement } from "./pendingEdit" + +const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion" +const CHAIN_DELAY_MS = 60 + +export type PendingNextEdit = + | { + kind: "replace" + document: vscode.TextDocument + /** Inclusive start line of the lines being replaced. */ + diffStartLine: number + /** Inclusive end line of the lines being replaced. */ + diffEndLine: number + /** New text to substitute for [diffStartLine, diffEndLine]. */ + replacement: string + /** Whether the suggestion omits complete lines rather than rewriting one as blank. */ + removesLines: boolean + /** Snapshot of the original text — used to detect drift. */ + originalText: string + } + | { + kind: "insert" + document: vscode.TextDocument + /** Existing line before insertion, or `lineCount` when appending at EOF. */ + diffStartLine: number + /** Same as diffStartLine for hint/jump-target purposes. */ + diffEndLine: number + /** Lines to insert. Must end with a newline so existing content gets pushed down. */ + replacement: string + /** Snapshot of the surrounding (single) line — used as a soft drift guard. */ + originalText: string + } + +/** + * Holds the currently-pending out-of-cursor NES suggestion and renders a + * jump-to-next-edit affordance via editor decorations. Same-line diffs are + * still handled by `InlineCompletionItem` (faster, native ghost text) — this + * manager is for everything else. + * + * Lifecycle: at most one pending suggestion at a time. A pending suggestion + * is cleared when the user accepts, dismisses, edits inside the diff range, + * or moves to a different document. + */ +export class NextEditSuggestionManager implements vscode.Disposable { + private pending: PendingNextEdit | null = null + private readonly subscriptions: vscode.Disposable[] = [] + + private readonly removedLineDecoration: vscode.TextEditorDecorationType + private readonly proposedLineDecoration: vscode.TextEditorDecorationType + private readonly hintDecoration: vscode.TextEditorDecorationType + + constructor() { + // Tints + strikethrough on the lines that will be replaced or removed. + this.removedLineDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: new vscode.ThemeColor("diffEditor.removedLineBackground"), + overviewRulerColor: new vscode.ThemeColor("editorInfo.foreground"), + overviewRulerLane: vscode.OverviewRulerLane.Left, + textDecoration: "line-through; opacity: 0.65;", + }) + // Inline `after` text showing the proposed replacement line. + this.proposedLineDecoration = vscode.window.createTextEditorDecorationType({ + after: { + margin: "0 0 0 2em", + color: new vscode.ThemeColor("editorInfo.foreground"), + fontStyle: "italic", + }, + }) + // The one-line user-facing hint. + this.hintDecoration = vscode.window.createTextEditorDecorationType({ + after: { + margin: "0 0 0 2em", + color: new vscode.ThemeColor("editorCodeLens.foreground"), + fontStyle: "italic", + }, + }) + + // Dismiss when the document or selection moves in ways that invalidate + // the prediction. + this.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((e) => { + const p = this.pending + if (!p) return + if (e.document !== p.document) return + // For "insert" we just confirm the anchor line is still there with its + // original content; for "replace" we re-check the full range. + let stillValid = true + try { + if (p.kind === "replace") { + const text = e.document.getText( + new vscode.Range( + new vscode.Position(p.diffStartLine, 0), + new vscode.Position(p.diffEndLine, e.document.lineAt(p.diffEndLine).range.end.character), + ), + ) + stillValid = text === p.originalText + } else { + // Insert mode: only invalidate if the anchor line shifted. + const anchorLine = Math.min(p.diffStartLine, e.document.lineCount - 1) + const anchorText = e.document.lineAt(anchorLine).text + stillValid = anchorText === p.originalText + } + } catch { + stillValid = false + } + if (!stillValid) this.clear() + }), + vscode.window.onDidChangeActiveTextEditor(() => this.clear()), + // When the cursor moves (e.g., post-jump), refresh the hint so it + // flips between "Tab to jump" and "Tab to apply". + vscode.window.onDidChangeTextEditorSelection((e) => { + if (!this.pending) return + if (e.textEditor.document !== this.pending.document) return + this.renderDecorations(this.pending) + }), + ) + } + + public isPending(): boolean { + return this.pending !== null + } + + public setPending(p: PendingNextEdit): void { + this.clearDecorations() + this.pending = p + void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, true) + // Hide any in-flight inline suggestion so it can't compete with our Tab handler. + void vscode.commands.executeCommand("editor.action.inlineSuggest.hide") + this.renderDecorations(p) + } + + public clear(): void { + if (!this.pending) return + this.pending = null + this.clearDecorations() + void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, false) + } + + /** Tab handler — accept if cursor near the diff, else jump. */ + public async acceptOrJump(): Promise { + const p = this.pending + if (!p) return + const editor = vscode.window.activeTextEditor + if (!editor || editor.document !== p.document) { + this.clear() + return + } + const cursor = editor.selection.active + const inside = + p.kind === "replace" + ? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine + : cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1 + if (inside) { + await this.applyPending() + } else { + const targetLine = Math.min(p.diffStartLine, Math.max(0, p.document.lineCount - 1)) + const targetChar = p.document.lineAt(targetLine).firstNonWhitespaceCharacterIndex + const target = new vscode.Position(targetLine, targetChar) + editor.selection = new vscode.Selection(target, target) + editor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenterIfOutsideViewport) + nesLog(`jumped cursor ${cursor.line} -> ${target.line} (pending diff at [${p.diffStartLine}..${p.diffEndLine}])`) + // Refresh hint immediately so "Tab to apply" is shown. + this.renderDecorations(p) + } + } + + private async applyPending(): Promise { + const p = this.pending + if (!p) return + const editor = vscode.window.activeTextEditor + if (!editor || editor.document !== p.document) { + this.clear() + return + } + // Snapshot what we're about to do, then nuke pending state so the upcoming + // document change doesn't re-enter via the invalidation listener. + this.clearDecorations() + this.pending = null + void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, false) + + let ok = false + if (p.kind === "insert") { + // Re-validate before applying: the anchor line must still hold its + // original text. Without this, edits between the anchor and the insertion + // point can shift line numbers and land the insert in the wrong place. + const anchorLine = Math.min(p.diffStartLine, editor.document.lineCount - 1) + const anchorText = anchorLine >= 0 ? editor.document.lineAt(anchorLine).text : undefined + if (anchorText !== p.originalText) { + nesLog(`document drifted since suggestion was made — dropping insert at line ${p.diffStartLine}`) + return + } + const edit = planInsertion(p, { + lineCount: editor.document.lineCount, + end: (line) => editor.document.lineAt(line).range.end.character, + }) + const pos = new vscode.Position(edit.line, edit.character) + ok = await editor.edit((b) => b.insert(pos, edit.text)) + nesLog(`applied insert at line ${pos.line} (${edit.text.length} chars, ok=${ok})`) + } else { + const range = new vscode.Range( + new vscode.Position(p.diffStartLine, 0), + new vscode.Position(p.diffEndLine, p.document.lineAt(p.diffEndLine).range.end.character), + ) + const currentInDoc = editor.document.getText(range) + if (currentInDoc !== p.originalText) { + nesLog(`document drifted since suggestion was made — dropping range [${p.diffStartLine}..${p.diffEndLine}]`) + return + } + const edit = planReplacement(p, { + lineCount: editor.document.lineCount, + end: (line) => editor.document.lineAt(line).range.end.character, + }) + const target = new vscode.Range( + new vscode.Position(edit.start.line, edit.start.character), + new vscode.Position(edit.end.line, edit.end.character), + ) + ok = await editor.edit((b) => b.replace(target, edit.text)) + nesLog(`applied replace at lines [${p.diffStartLine}..${p.diffEndLine}] (ok=${ok})`) + } + if (ok) chainNextPrediction() + } + + private renderDecorations(p: PendingNextEdit): void { + // Same document can be open in multiple splits — paint all of them so the + // user sees the decoration regardless of which split has focus. + const editors = vscode.window.visibleTextEditors.filter((e) => e.document === p.document) + if (editors.length === 0) return + + const removedRanges: vscode.Range[] = [] + const proposedAnnotations: vscode.DecorationOptions[] = [] + + if (p.kind === "replace") { + const originalLines = p.originalText.split("\n") + const proposedLines = p.replacement.split("\n") + const minLen = Math.min(originalLines.length, proposedLines.length) + for (let i = 0; i < minLen; i++) { + if (originalLines[i] === proposedLines[i]) continue + const lineNo = p.diffStartLine + i + const lineRange = p.document.lineAt(lineNo).range + removedRanges.push(lineRange) + proposedAnnotations.push({ + range: new vscode.Range(lineRange.end, lineRange.end), + renderOptions: { after: { contentText: `→ ${visualize(proposedLines[i])}` } }, + }) + } + // Pure deletions inside a replace + for (let i = minLen; i < originalLines.length; i++) { + const lineNo = p.diffStartLine + i + const lineRange = p.document.lineAt(lineNo).range + removedRanges.push(lineRange) + proposedAnnotations.push({ + range: new vscode.Range(lineRange.end, lineRange.end), + renderOptions: { after: { contentText: `→ (removed)` } }, + }) + } + // Additions inside a replace — anchor on last shared line + if (proposedLines.length > originalLines.length) { + const tailLineNo = p.diffStartLine + originalLines.length - 1 + const safeLine = Math.max(p.diffStartLine, Math.min(tailLineNo, p.diffEndLine)) + const tailRange = p.document.lineAt(safeLine).range + const added = proposedLines.slice(originalLines.length).map(visualize).join(" ⏎ ") + proposedAnnotations.push({ + range: new vscode.Range(tailRange.end, tailRange.end), + renderOptions: { after: { contentText: `+ ${added}` } }, + }) + } + } else { + // Pure insertion: anchor the ghost text on the existing line, no strikethrough. + const anchorLine = Math.min(p.diffStartLine, p.document.lineCount - 1) + const safeAnchor = Math.max(0, anchorLine) + const anchorRange = p.document.lineAt(safeAnchor).range + // Strip the trailing \n we appended for insertion semantics, then show each + // inserted line collapsed with a small separator. + const lines = p.replacement.replace(/\n$/, "").split("\n").map(visualize) + const inserted = lines.join(" ⏎ ") + proposedAnnotations.push({ + range: new vscode.Range(anchorRange.end, anchorRange.end), + renderOptions: { after: { contentText: `+ ${inserted}` } }, + }) + } + + // Hint anchor + cursor check use the active editor if it's one of ours, + // else fall back to the first visible editor for this document. + const active = vscode.window.activeTextEditor + const referenceEditor = active && editors.includes(active) ? active : editors[0] + const hintAnchor = Math.min(p.diffStartLine, p.document.lineCount - 1) + const hintLineEnd = p.document.lineAt(Math.max(0, hintAnchor)).range.end + const cursor = referenceEditor.selection.active + const cursorAtDiff = + p.kind === "replace" + ? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine + : cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1 + const hintText = cursorAtDiff ? " ↳ Tab to apply · Esc to dismiss" : " ↳ Tab to jump here · Esc to dismiss" + const hintOptions: vscode.DecorationOptions[] = [ + { + range: new vscode.Range(hintLineEnd, hintLineEnd), + renderOptions: { after: { contentText: hintText } }, + }, + ] + + for (const editor of editors) { + editor.setDecorations(this.removedLineDecoration, removedRanges) + editor.setDecorations(this.proposedLineDecoration, proposedAnnotations) + editor.setDecorations(this.hintDecoration, hintOptions) + } + } + + private clearDecorations(): void { + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(this.removedLineDecoration, []) + editor.setDecorations(this.proposedLineDecoration, []) + editor.setDecorations(this.hintDecoration, []) + } + } + + public dispose(): void { + this.clear() + for (const s of this.subscriptions) s.dispose() + this.subscriptions.length = 0 + this.removedLineDecoration.dispose() + this.proposedLineDecoration.dispose() + this.hintDecoration.dispose() + } +} + +/** + * Re-invoke VSCode's inline-suggest UI after an accept so the provider fires + * again and surfaces the next prediction without the user having to type. + * This is the "Tab-Tab-Tab" walk-through-a-refactor UX from Cursor. + * + * A short delay lets the document change settle before we re-enter + * `provideInlineCompletionItems`, and gives the user a moment to abandon the + * chain by typing or moving the cursor. + */ +export function chainNextPrediction(delayMs = CHAIN_DELAY_MS): void { + setTimeout(() => { + void vscode.commands.executeCommand("editor.action.inlineSuggest.trigger") + }, delayMs) +} + +function visualize(line: string): string { + // VSCode after-text decorations don't support newlines — collapse just in case. + // Also surface leading whitespace explicitly so it isn't visually swallowed. + const collapsed = line.replace(/\s+$/g, "").replace(/^\t+/, (t) => " ".repeat(t.length)) + return collapsed.length > 120 ? collapsed.slice(0, 117) + "…" : collapsed +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts new file mode 100644 index 00000000000..cf7ec8d4e09 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/constants.ts @@ -0,0 +1,9 @@ +/** + * Editable-region sizing for Next Edit. Per the Mercury docs, region size + * dominates output latency; centering [-5, +10] around the cursor is the + * recommended starting point. (The Mercury prompt sentinel tokens live in the + * gateway — see packages/kilo-gateway/src/edit-prompt.ts.) + */ +export const DEFAULT_EDITABLE_REGION_TOP_MARGIN = 5 +export const DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN = 10 +export const MAX_EDITABLE_REGION_LINES = 25 diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts new file mode 100644 index 00000000000..9edebc298f6 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -0,0 +1,168 @@ +import { createPatch } from "diff" +import * as vscode from "vscode" + +const DEFAULT_DEBOUNCE_MS = 1500 +const DEFAULT_MAX_DIFFS = 5 + +type Options = { + debounceMs?: number + maxDiffs?: number + isFileAllowed: (fsPath: string) => Promise +} + +type Diff = { + key: string + patch: string +} + +/** + * Tracks per-file snapshots and emits a workspace-wide chronological stream + * of range-based unidiffs after a short idle window. Cross-file history is + * intentional: Mercury uses recent edits from any file to infer user intent. + * + * Diffs are produced lazily; the tracker holds the previously-emitted state + * per file and computes the diff against the current document content when + * the debounce fires. + */ +export class EditHistoryTracker implements vscode.Disposable { + private readonly snapshots = new Map() + private readonly pendingTimers = new Map() + private readonly diffs: Diff[] = [] + private readonly subscriptions: vscode.Disposable[] = [] + + constructor(private readonly options: Options) { + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS + + // Seed snapshots on open so the FIRST edit in a freshly-opened file is + // captured in the diff history (otherwise the common "open, type, trigger" + // flow ships an empty edit-history block). Access checks happen before + // reading text so ignored documents are never retained as edit context. + this.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((doc) => { + if (doc.uri.scheme !== "file") return + void this.seed(doc) + }), + ) + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.scheme === "file") void this.seed(doc) + } + this.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.uri.scheme !== "file") return + if (event.contentChanges.length === 0) return + void this.scheduleSnapshotDiff(event.document, debounceMs) + }), + ) + this.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((doc) => { + const key = doc.uri.fsPath + const t = this.pendingTimers.get(key) + if (t) clearTimeout(t) + this.pendingTimers.delete(key) + this.snapshots.delete(key) + }), + ) + } + + /** + * Force the pending diff (if any) for `document` to be emitted now. Call + * this immediately before building a request so the freshest user edit + * makes it into the prompt. + */ + public async flush(document: vscode.TextDocument): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } + const t = this.pendingTimers.get(key) + if (t) clearTimeout(t) + this.pendingTimers.delete(key) + await this.emitDiffNow(document) + } + + /** Workspace-wide oldest to newest, matching the Mercury prompt-history convention. */ + public async getRecentDiffs(): Promise { + const kept = ( + await Promise.all(this.diffs.map(async (diff) => ((await this.allowed(diff.key)) ? diff : undefined))) + ).filter((diff): diff is Diff => diff !== undefined) + this.diffs.splice(0, this.diffs.length, ...kept) + return kept.map((diff) => diff.patch) + } + + public dispose(): void { + for (const t of this.pendingTimers.values()) clearTimeout(t) + this.pendingTimers.clear() + for (const s of this.subscriptions) s.dispose() + this.subscriptions.length = 0 + } + + private async seed(document: vscode.TextDocument): Promise { + const key = document.uri.fsPath + if (this.snapshots.has(key)) return + if (!(await this.allowed(key))) { + this.reject(key) + return + } + if (!this.snapshots.has(key)) this.snapshots.set(key, document.getText()) + } + + private async scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } + if (!this.snapshots.has(key)) { + // Fallback seed for documents we never saw open (e.g. opened before the + // tracker existed). The triggering change is lost, but subsequent edits + // produce useful diffs. + this.snapshots.set(key, document.getText()) + return + } + const existing = this.pendingTimers.get(key) + if (existing) clearTimeout(existing) + const timer = setTimeout(() => { + this.pendingTimers.delete(key) + void this.emitDiffNow(document) + }, debounceMs) + this.pendingTimers.set(key, timer) + } + + private async emitDiffNow(document: vscode.TextDocument): Promise { + const key = document.uri.fsPath + if (!(await this.allowed(key))) { + this.reject(key) + return + } + const previous = this.snapshots.get(key) + if (previous === undefined) return + const current = document.getText() + if (current === previous) return + + const filename = vscode.workspace.asRelativePath(document.uri, false) + const patch = createPatch(filename, previous, current, undefined, undefined, { context: 1 }) + // `createPatch` returns "" for identical inputs; guard anyway. + if (patch && patch.trim().length > 0) { + this.diffs.push({ key, patch }) + const maxDiffs = this.options.maxDiffs ?? DEFAULT_MAX_DIFFS + if (this.diffs.length > maxDiffs) this.diffs.shift() + } + this.snapshots.set(key, current) + } + + private async allowed(key: string): Promise { + const allow = this.options.isFileAllowed + if (!allow) return false + return allow(key).catch(() => false) + } + + private reject(key: string): void { + const timer = this.pendingTimers.get(key) + if (timer) clearTimeout(timer) + this.pendingTimers.delete(key) + this.snapshots.delete(key) + const kept = this.diffs.filter((diff) => diff.key !== key) + this.diffs.splice(0, this.diffs.length, ...kept) + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts new file mode 100644 index 00000000000..16be4a992b5 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editableRegion.ts @@ -0,0 +1,43 @@ +import { + DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN, + DEFAULT_EDITABLE_REGION_TOP_MARGIN, + MAX_EDITABLE_REGION_LINES, +} from "./constants" + +export interface EditableRegionInputs { + cursorLine: number + totalLines: number + topMargin?: number + bottomMargin?: number +} + +export interface EditableRegion { + startLine: number + endLine: number +} + +/** + * Editable region selection per the Mercury docs: center [-top, +bottom] around + * the cursor, clipped to file bounds. Capped to MAX_EDITABLE_REGION_LINES (~25) + * because output tokens dominate latency. + */ +export function computeEditableRegion({ + cursorLine, + totalLines, + topMargin = DEFAULT_EDITABLE_REGION_TOP_MARGIN, + bottomMargin = DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN, +}: EditableRegionInputs): EditableRegion { + if (totalLines <= 0) return { startLine: 0, endLine: 0 } + + const lastLine = totalLines - 1 + let start = Math.max(0, cursorLine - topMargin) + let end = Math.min(lastLine, cursorLine + bottomMargin) + + const span = end - start + 1 + if (span > MAX_EDITABLE_REGION_LINES) { + const overflow = span - MAX_EDITABLE_REGION_LINES + // Prefer trimming below the cursor, where we have less semantic context. + end = Math.max(start, end - overflow) + } + return { startLine: start, endLine: end } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts new file mode 100644 index 00000000000..7e7c3239ba3 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/log.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode" + +const CHANNEL_NAME = "Kilo Code · Next Edit" + +let channel: vscode.OutputChannel | null = null + +function getChannel(): vscode.OutputChannel { + if (!channel) channel = vscode.window.createOutputChannel(CHANNEL_NAME) + return channel +} + +function debugEnabled(): boolean { + // Toggled via env only — deliberately not a VSCode setting, to avoid adding + // new autocomplete config (config is migrating to the backend). + return process.env.KILO_NES_DEBUG === "1" +} + +/** + * Append a single log line to the dedicated NES output channel. Always goes to + * the channel (so a user troubleshooting can flip it on without rebuilding); + * `console.log` is mirrored only when the debug setting is enabled. + */ +export function nesLog(message: string): void { + getChannel().appendLine(`[${new Date().toISOString()}] ${message}`) + if (debugEnabled()) console.log(`[NES] ${message}`) +} + +/** Equivalent of `console.warn` for the channel. */ +export function nesWarn(message: string): void { + getChannel().appendLine(`[${new Date().toISOString()}] WARN ${message}`) + if (debugEnabled()) console.warn(`[NES] ${message}`) +} + +export function disposeLog(): void { + channel?.dispose() + channel = null +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts new file mode 100644 index 00000000000..bea2c9c1d86 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts @@ -0,0 +1,43 @@ +type Document = { + lineCount: number + end(line: number): number +} + +type Insertion = { + diffStartLine: number + replacement: string +} + +type Replacement = Insertion & { + diffEndLine: number + removesLines: boolean +} + +export function planInsertion(input: Insertion, document: Document) { + if (input.diffStartLine < document.lineCount) { + return { line: input.diffStartLine, character: 0, text: input.replacement } + } + const line = Math.max(0, document.lineCount - 1) + const text = input.replacement.endsWith("\n") ? input.replacement.slice(0, -1) : input.replacement + return { line, character: document.end(line), text: `\n${text}` } +} + +export function planReplacement(input: Replacement, document: Document) { + const end = { line: input.diffEndLine, character: document.end(input.diffEndLine) } + if (!input.removesLines) { + return { start: { line: input.diffStartLine, character: 0 }, end, text: input.replacement } + } + if (input.diffEndLine < document.lineCount - 1) { + return { + start: { line: input.diffStartLine, character: 0 }, + end: { line: input.diffEndLine + 1, character: 0 }, + text: input.replacement, + } + } + if (input.diffStartLine === 0) return { start: { line: 0, character: 0 }, end, text: input.replacement } + return { + start: { line: input.diffStartLine - 1, character: document.end(input.diffStartLine - 1) }, + end, + text: input.replacement, + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts new file mode 100644 index 00000000000..b7295ded353 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/recentSnippetsAdapter.ts @@ -0,0 +1,52 @@ +import type { AutocompleteCodeSnippet } from "../continuedev/core/autocomplete/types" +import * as vscode from "vscode" +import type { MercuryRecentSnippet } from "./types" + +const MAX_SNIPPET_LINES = 20 +const MAX_SNIPPETS = 5 + +/** + * Convert kilocode's already-collected `RecentlyVisitedRangesService` output + * into the shape Mercury Edit expects for the `<|recently_viewed_code_snippets|>` + * block. Per docs: 3–5 snippets × ~20 lines, oldest → newest, excluding the + * currently active file (the service already filters that out). + * + * `RecentlyVisitedRangesService.getSnippets()` returns snippets newest→oldest; + * we reverse so Mercury sees them in chronological order. + */ +export function toMercuryRecentSnippets( + snippets: ReadonlyArray>, +): MercuryRecentSnippet[] { + return snippets + .slice(0, MAX_SNIPPETS) + .reverse() + .map((s) => ({ + filepath: shortenPath(s.filepath), + content: trimToLines(s.content, MAX_SNIPPET_LINES), + })) +} + +export function toAllowedMercuryRecentSnippets( + snippets: ReadonlyArray>, + allowed: (filepath: string) => boolean, +): MercuryRecentSnippet[] { + return toMercuryRecentSnippets(snippets.filter((snippet) => allowed(snippet.filepath))) +} + +function trimToLines(content: string, maxLines: number): string { + const lines = content.split("\n") + if (lines.length <= maxLines) return content + // Center the trim window — keep the most semantically meaningful core. + const start = Math.floor((lines.length - maxLines) / 2) + return lines.slice(start, start + maxLines).join("\n") +} + +function shortenPath(uri: string): string { + // Convert file:// URI strings to workspace-relative paths so the prompt is compact. + try { + const parsed = vscode.Uri.parse(uri) + return vscode.workspace.asRelativePath(parsed, false) + } catch { + return uri + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts new file mode 100644 index 00000000000..a88e346a360 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/types.ts @@ -0,0 +1,26 @@ +export interface MercuryRecentSnippet { + filepath: string + content: string +} + +export interface MercuryEditRequestContext { + currentFilePath: string + currentFileContent: string + cursorLine: number + cursorCharacter: number + editableRegionStartLine: number + editableRegionEndLine: number + recentlyViewedSnippets: MercuryRecentSnippet[] + editDiffHistory: string[] +} + +export interface MercuryEditSuggestion { + /** The replacement text for lines [editableRegionStartLine, editableRegionEndLine]. */ + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + /** Latency in milliseconds from request send to response parse. */ + latencyMs: number + inputTokens?: number + outputTokens?: number +} diff --git a/packages/kilo-vscode/src/services/autocomplete/settings.ts b/packages/kilo-vscode/src/services/autocomplete/settings.ts index 9ec341406dc..0a3f189d58b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/settings.ts +++ b/packages/kilo-vscode/src/services/autocomplete/settings.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import { AUTOCOMPLETE_MODELS, getAutocompleteModel } from "../../shared/autocomplete-models" +import { validAutocompleteModel, validAutocompleteProvider } from "../../shared/autocomplete-models" type Message = { type: string @@ -18,13 +18,17 @@ export async function routeAutocompleteMessage(message: Message, post: Post): Pr export function buildAutocompleteSettingsMessage() { const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") + // Pass through provider/model as-is (null when unset) so the webview can + // distinguish "user hasn't picked" from "user picked the current default." + // The runtime resolves null → DEFAULT_AUTOCOMPLETE_MODEL via getAutocompleteModel(). return { type: "autocompleteSettingsLoaded" as const, settings: { enableAutoTrigger: config.get("enableAutoTrigger", true), enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding", false), enableChatAutocomplete: config.get("enableChatAutocomplete", false), - model: getAutocompleteModel(config.get("model") ?? "").id, + provider: config.get("provider") ?? null, + model: config.get("model") ?? null, }, } } @@ -40,8 +44,14 @@ export function watchAutocompleteConfig(post: Post): vscode.Disposable { export function validAutocompleteSetting(key: string, value: unknown) { if (key === "model") { - if (typeof value !== "string") return false - return AUTOCOMPLETE_MODELS.some((m) => m.id === value) + // Allow clearing back to the server-side default. + if (value === null || value === undefined) return true + return validAutocompleteModel(value) + } + + if (key === "provider") { + if (value === null || value === undefined) return true + return validAutocompleteProvider(value) } if (key === "enableAutoTrigger") return typeof value === "boolean" diff --git a/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts b/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts index 9b0e3d9957c..27ec377a334 100644 --- a/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts +++ b/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts @@ -23,7 +23,6 @@ function toPosix(filePath: string): string { export class FileIgnoreController { private workspacePath: string private ignoreInstance: Ignore = ignore() - private loadedContents: Array<{ file: string; content: string }> = [] private readonly realpathCache = new Map() constructor(workspacePath?: string) { @@ -32,7 +31,6 @@ export class FileIgnoreController { async initialize(): Promise { this.ignoreInstance = ignore() - this.loadedContents = [] this.realpathCache.clear() if (!this.workspacePath) { @@ -48,7 +46,6 @@ export class FileIgnoreController { if (kilocodeignoreContent.trim()) { this.ignoreInstance.add(kilocodeignoreContent) this.ignoreInstance.add(KILOCODEIGNORE) - this.loadedContents.push({ file: KILOCODEIGNORE, content: kilocodeignoreContent }) return } } @@ -59,7 +56,6 @@ export class FileIgnoreController { const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8") if (gitignoreContent.trim()) { this.ignoreInstance.add(gitignoreContent) - this.loadedContents.push({ file: GITIGNORE, content: gitignoreContent }) } } @@ -127,31 +123,7 @@ export class FileIgnoreController { return !this.ignoreInstance.ignores(relative) } - /** - * Filter a list of candidate paths to those allowed. - * When no workspace path was provided, returns an empty array. - */ - filterPaths(paths: string[]): string[] { - if (!this.workspacePath) { - return [] - } - return paths.filter((candidate) => this.validateAccess(candidate)) - } - - /** - * Returns user-facing instructions explaining why access is restricted. - */ - getInstructions(): string | undefined { - if (this.loadedContents.length === 0) { - return undefined - } - - const sections = this.loadedContents.map(({ file, content }) => `# ${file}\n\n${content.trimEnd()}`) - return sections.join("\n\n") - } - dispose(): void { - this.loadedContents = [] this.realpathCache.clear() this.ignoreInstance = ignore() } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts b/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts index ed7ca9bf3c1..5afdf98a8dd 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts @@ -1,13 +1,12 @@ import * as vscode from "vscode" -import type { KiloClient, McpStatus } from "@kilocode/sdk/v2/client" +import type { KiloClient } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../cli-backend" -export type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected" +type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected" export class BrowserAutomationService implements vscode.Disposable { private state: BrowserAutomationState = "disabled" private disposables: vscode.Disposable[] = [] - private stateListeners: Array<(state: BrowserAutomationState) => void> = [] // MCP server name used when registering with the CLI backend private static readonly MCP_SERVER_NAME = "kilo-playwright" @@ -23,22 +22,6 @@ export class BrowserAutomationService implements vscode.Disposable { ) } - /** Current state */ - getState(): BrowserAutomationState { - return this.state - } - - /** Subscribe to state changes */ - onStateChange(listener: (state: BrowserAutomationState) => void): () => void { - this.stateListeners.push(listener) - return () => { - const idx = this.stateListeners.indexOf(listener) - if (idx >= 0) { - this.stateListeners.splice(idx, 1) - } - } - } - /** * Read settings and enable/disable accordingly. * Called on construction and when settings change. @@ -150,24 +133,6 @@ export class BrowserAutomationService implements vscode.Disposable { this.setState("disabled") } - /** - * Get the current MCP server status from the CLI backend. - */ - async getServerStatus(): Promise { - const client = this.getClient() - if (!client) { - return null - } - - try { - const directory = this.getWorkspaceDirectory() - const { data: allStatus } = await client.mcp.status({ directory }, { throwOnError: true }) - return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null - } catch { - return null - } - } - private getClient(): KiloClient | null { try { return this.connectionService.getClient() @@ -190,9 +155,6 @@ export class BrowserAutomationService implements vscode.Disposable { } console.log(`[Kilo New] BrowserAutomationService: State ${this.state} → ${state}`) this.state = state - for (const listener of this.stateListeners) { - listener(state) - } } dispose(): void { @@ -200,6 +162,5 @@ export class BrowserAutomationService implements vscode.Disposable { d.dispose() } this.disposables = [] - this.stateListeners = [] } } diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index acb58ffeed2..41f92c86e61 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,6 +1,50 @@ import { describe, expect, test } from "bun:test" import { KiloConnectionService } from "./connection-service" +describe("KiloConnectionService viewed sessions", () => { + test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => { + const service = new KiloConnectionService({} as any) + const calls: Array<{ focused: string[]; open?: string[] }> = [] + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let active = 0 + let max = 0 + + ;(service as any).remoteService = { getState: () => ({ enabled: true }) } + ;(service as any).client = { + session: { + viewed: async (input: { focused: string[]; open?: string[] }) => { + calls.push(input) + active += 1 + max = Math.max(max, active) + if (calls.length === 1) await gate + active -= 1 + }, + }, + } + + service.registerFocused("agent-manager", "am-1") + service.registerOpen("agent-manager", ["am-1", "am-2"]) + await Bun.sleep(175) + expect(calls).toEqual([{ focused: ["am-1"], open: ["am-2"] }]) + + service.registerFocused("sidebar", "side-1") + await Bun.sleep(175) + expect(calls).toHaveLength(1) + + release() + await Bun.sleep(10) + expect(max).toBe(1) + expect(calls[1]).toEqual({ focused: ["am-1", "side-1"], open: ["am-2"] }) + + service.unregisterFocused("sidebar") + await Bun.sleep(175) + expect(calls[2]).toEqual({ focused: ["am-1"], open: ["am-2"] }) + }) +}) + describe("KiloConnectionService drainPendingPrompts", () => { test("ignores stale NotFoundError replies while draining permissions", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 768f6bb6c78..eff52fb64cc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -1,14 +1,14 @@ import * as vscode from "vscode" import { ServerManager } from "./server-manager" -import { createKiloClient, type KiloClient, type Event } from "@kilocode/sdk/v2/client" -import { SdkSSEAdapter } from "./sdk-sse-adapter" +import { createKiloClient, type KiloClient } from "@kilocode/sdk/v2/client" +import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" -type SSEEventListener = (event: Event, directory?: string) => void -type StateListener = (state: ConnectionState) => void -type SSEEventFilter = (event: Event, directory?: string) => boolean +type SSEEventListener = (event: SSEPayload, directory?: string) => void +type StateListener = (state: ConnectionState, error?: Error) => void +type SSEEventFilter = (event: SSEPayload, directory?: string) => boolean type NotificationDismissListener = (notificationId: string) => void type LanguageChangeListener = (locale: string) => void type ProfileChangeListener = (data: unknown) => void @@ -21,9 +21,11 @@ function isNotFound(err: unknown) { if (!err || typeof err !== "object") return false const obj = err as Record if (obj.name === "NotFoundError") return true + if (obj._tag === "NotFound") return true if (obj.status === 404) return true if (obj.data && typeof obj.data === "object") { - return (obj.data as Record).name === "NotFoundError" + const data = obj.data as Record + return data.name === "NotFoundError" || data._tag === "NotFound" } return false } @@ -32,24 +34,13 @@ function isNotFound(err: unknown) { // This provides a second detection channel for server death independent of the SSE heartbeat. const HEALTH_POLL_INTERVAL_MS = 10_000 -/** - * Reject all pending network-offline waits for a given directory. - * The network namespace is not yet in the SDK KiloClient type (pending SDK regeneration), - * so we access it via a type assertion. - */ +/** Reject all pending network-offline waits for a given directory. */ async function drainNetworkWaits(client: KiloClient, dir: string) { - const net = (client as any).network as - | { - list: (p: { directory: string }) => Promise<{ data?: { id: string }[]; error?: unknown }> - reject: (p: { requestID: string; directory: string }) => Promise<{ error?: unknown }> - } - | undefined - if (!net) return - const { data: waits, error: err } = await net.list({ directory: dir }) + const { data: waits, error: err } = await client.network.list({ directory: dir }) if (err) throw new Error(`Failed to list network waits for ${dir}: ${String(err)}`) if (!waits) return for (const w of waits) { - const { error } = await net.reject({ requestID: w.id, directory: dir }) + const { error } = await client.network.reject({ requestID: w.id, directory: dir }) if (error) throw new Error(`Failed to reject network wait ${w.id}: ${String(error)}`) } } @@ -65,6 +56,7 @@ export class KiloConnectionService { private info: { port: number } | null = null private config: ServerConfig | null = null private state: ConnectionState = "disconnected" + private error: Error | null = null private connectPromise: Promise | null = null private healthPollTimer: ReturnType | null = null private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null @@ -78,6 +70,9 @@ export class KiloConnectionService { private readonly favoritesChangeListeners: Set = new Set() private readonly clearPendingPromptsListeners: Set = new Set() private readonly directoryProviders: Set = new Set() + private readonly permissionDirectories: Map = new Map() + private readonly questionDirectories: Map = new Map() + private questionRevision = 0 /** * Shared mapping used to resolve session scope for events that don't reliably include a sessionID. @@ -90,10 +85,12 @@ export class KiloConnectionService { /** Provider key → all open (background) session IDs. */ private readonly opened: Map = new Map() private debounceTimer: ReturnType | null = null + private viewedSending = false + private viewedDirty = false private unsubRemote: (() => void) | null = null constructor(context: vscode.ExtensionContext) { - this.serverManager = new ServerManager(context) + this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code)) } /** @@ -115,7 +112,7 @@ export class KiloConnectionService { await this.connectPromise } catch (error) { // If doConnect() fails before SSE can emit a state transition, avoid leaving consumers stuck in "connecting". - this.setState("error") + this.setState("error", this.error ?? (error instanceof Error ? error : new Error(String(error)))) throw error } finally { this.connectPromise = null @@ -126,7 +123,7 @@ export class KiloConnectionService { * Get the shared SDK client. Throws if not connected. */ getClient(): KiloClient { - if (!this.client) { + if (!this.client || this.state !== "connected") { throw new Error("Not connected — call connect() first") } return this.client @@ -139,11 +136,11 @@ export class KiloConnectionService { * or if the connection fails. */ async getClientAsync(dir?: string): Promise { - if (this.client) return this.client + if (this.client && this.state === "connected") return this.client const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) throw new Error("No workspace folder open") await this.connect(root) - return this.client! + return this.getClient() } /** @@ -189,6 +186,13 @@ export class KiloConnectionService { return this.state } + /** + * Last connection error. Cleared when a new connection attempt begins. + */ + getConnectionError(): Error | null { + return this.error + } + /** * Subscribe to SSE events. Returns unsubscribe function. */ @@ -237,7 +241,7 @@ export class KiloConnectionService { * Best-effort sessionID extraction for an SSE event. * Returns undefined for global events. */ - resolveEventSessionId(event: Event): string | undefined { + resolveEventSessionId(event: SSEPayload): string | undefined { return resolveEventSessionIdPure( event, (messageId) => this.messageSessionIdsByMessageId.get(messageId), @@ -245,6 +249,63 @@ export class KiloConnectionService { ) } + recordPermissionDirectory(requestID: string, directory: string): void { + if (!requestID || !directory) { + return + } + this.permissionDirectories.set(requestID, directory) + } + + getPermissionDirectory(requestID: string): string | undefined { + return this.permissionDirectories.get(requestID) + } + + clearPermissionDirectory(requestID: string): void { + this.permissionDirectories.delete(requestID) + } + + prunePermissionDirectories(active: Set, dirs?: Set): void { + for (const [id, dir] of this.permissionDirectories) { + if (active.has(id)) { + continue + } + if (dirs && !dirs.has(dir)) { + continue + } + this.permissionDirectories.delete(id) + } + } + + recordQuestionDirectory(requestID: string, directory: string): void { + if (!requestID || !directory) { + return + } + this.questionDirectories.set(requestID, directory) + } + + getQuestionDirectory(requestID: string): string | undefined { + return this.questionDirectories.get(requestID) + } + + clearQuestionDirectory(requestID: string): void { + this.questionDirectories.delete(requestID) + // A resolved request must invalidate an in-flight recovery scan so stale list data cannot repost it. + this.questionRevision += 1 + } + + getQuestionRevision(): number { + return this.questionRevision + } + + pruneQuestionDirectories(active: Set, dirs: Set): void { + const size = this.questionDirectories.size + for (const [id, dir] of this.questionDirectories) { + if (active.has(id) || !dirs.has(dir)) continue + this.questionDirectories.delete(id) + } + if (this.questionDirectories.size !== size) this.questionRevision += 1 + } + /** * Subscribe to notification dismiss events broadcast from any KiloProvider. Returns unsubscribe function. */ @@ -404,7 +465,7 @@ export class KiloConnectionService { if (qs) { for (const q of qs) { const { error } = await this.client.question.reject({ requestID: q.id, directory: dir }) - if (error) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`) + if (error && !isNotFound(error)) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`) } } await drainSuggestions(this.client, dir) @@ -461,19 +522,40 @@ export class KiloConnectionService { if (this.debounceTimer) clearTimeout(this.debounceTimer) this.debounceTimer = setTimeout(() => { this.debounceTimer = null - const focus = new Set(this.focused.values()) - const open = new Set() - for (const ids of this.opened.values()) { - for (const id of ids) { - if (!focus.has(id)) open.add(id) - } - } - this.client?.session - .viewed({ focused: [...focus], open: [...open] }) - .catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err)) + this.sendViewed() }, 150) } + private sendViewed(): void { + if (!this.isRemoteEnabled()) { + this.viewedDirty = false + return + } + if (this.viewedSending) { + this.viewedDirty = true + return + } + if (!this.client) return + + const focus = new Set(this.focused.values()) + const open = new Set() + for (const ids of this.opened.values()) { + for (const id of ids) { + if (!focus.has(id)) open.add(id) + } + } + + this.viewedSending = true + this.viewedDirty = false + void this.client.session + .viewed({ focused: [...focus], open: [...open] }) + .catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err)) + .finally(() => { + this.viewedSending = false + if (this.viewedDirty) this.sendViewed() + }) + } + /** * Clean up everything: kill server, close SSE, clear listeners. */ @@ -490,12 +572,16 @@ export class KiloConnectionService { this.clearPendingPromptsListeners.clear() this.directoryProviders.clear() this.messageSessionIdsByMessageId.clear() + this.permissionDirectories.clear() + this.questionDirectories.clear() + this.questionRevision += 1 this.focused.clear() this.opened.clear() if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = null } + this.viewedDirty = false this.unsubRemote?.() this.unsubRemote = null this.client = null @@ -503,12 +589,14 @@ export class KiloConnectionService { this.config = null this.info = null this.state = "disconnected" + this.error = null } - private setState(state: ConnectionState): void { + private setState(state: ConnectionState, error?: Error): void { this.state = state + this.error = state === "error" ? (error ?? this.error) : null for (const listener of this.stateListeners) { - listener(state) + listener(state, this.error ?? undefined) } } @@ -558,10 +646,31 @@ export class KiloConnectionService { } } - private async doConnect(workspaceDir: string): Promise { - // If we reconnect, ensure the previous SSE connection is cleaned up first. + private resetConnection(): void { this.stopHealthPoll() - this.sseClient?.dispose() + const sse = this.sseClient + this.sseClient = null + sse?.disconnect() + this.client = null + this.config = null + this.info = null + this.permissionDirectories.clear() + this.questionDirectories.clear() + this.questionRevision += 1 + } + + private handleServerExit(code: number | null): void { + console.warn("[Kilo New] ConnectionService: CLI background process exited:", code) + this.resetConnection() + this.setState( + "error", + new Error(`CLI background process exited with code ${code ?? "unknown"}. Retry to reconnect.`), + ) + } + + private async doConnect(workspaceDir: string): Promise { + // Never expose a stale SDK client while its replacement server is starting. + this.resetConnection() const server = await this.serverManager.getServer() this.info = { port: server.port } @@ -575,14 +684,15 @@ export class KiloConnectionService { // Create SDK client with Basic Auth header const authHeader = `Basic ${Buffer.from(`kilo:${server.password}`).toString("base64")}` - this.client = createKiloClient({ + const client = createKiloClient({ baseUrl: config.baseUrl, headers: { Authorization: authHeader, }, }) - - this.sseClient = new SdkSSEAdapter(this.client) + const sse = new SdkSSEAdapter(client) + this.client = client + this.sseClient = sse // Wait until SSE yields its first server event before resolving connect(). // Initial stream failures are handled by the adapter reconnect loop. @@ -596,18 +706,31 @@ export class KiloConnectionService { let didConnect = false // Wire SSE events → broadcast to all registered listeners - this.sseClient.onEvent((event, directory) => { + sse.onEvent((event, directory) => { + if (this.sseClient !== sse) return + this.handlePermissionEvent(event, directory) + this.handleQuestionEvent(event, directory) for (const listener of this.eventListeners) { listener(event, directory) } }) - this.sseClient.onError(() => { - this.setState("error") + sse.onError((error) => { + if (this.sseClient !== sse) return + this.setState("error", error) }) // Wire SSE state → broadcast to all registered state listeners - this.sseClient.onStateChange((sseState) => { + sse.onStateChange((sseState) => { + if (this.sseClient !== sse) { + if (!didConnect && sseState === "disconnected") { + rejectConnected?.(new Error(`SSE connection ended in state: ${sseState}`)) + resolveConnected = null + rejectConnected = null + } + return + } + this.setState(sseState) if (sseState === "connected") { @@ -625,13 +748,34 @@ export class KiloConnectionService { } }) - this.sseClient.connect() + sse.connect() await connectedPromise // Start the independent health poll once we are confirmed connected. this.startHealthPoll(config.baseUrl, config.password) } + + private handlePermissionEvent(event: SSEPayload, directory?: string): void { + if (event.type === "permission.asked" && directory) { + this.recordPermissionDirectory(event.properties.id, directory) + return + } + if (event.type === "permission.replied") { + this.clearPermissionDirectory(event.properties.requestID) + } + } + + private handleQuestionEvent(event: SSEPayload, directory?: string): void { + if (event.type === "question.asked" && directory) { + this.questionRevision += 1 + this.recordQuestionDirectory(event.properties.id, directory) + return + } + if (event.type === "question.replied" || event.type === "question.rejected") { + this.clearQuestionDirectory(event.properties.requestID) + } + } } async function drainSuggestions(client: KiloClient, directory: string): Promise { diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 787311ed03b..308beb15bcc 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -1,65 +1,61 @@ -import type { Event } from "@kilocode/sdk/v2/client" +import type { GlobalEvent } from "@kilocode/sdk/v2/client" + +export type SSEPayload = GlobalEvent["payload"] +type SyncPayload = Extract +type TransientPayload = Exclude /** * Pure session ID resolution for SSE events. - * The lookupMessageSessionId callback is used for message.part.updated fallback lookup, - * and onMessageUpdated is called when message.updated is encountered so the caller can - * record the messageID -> sessionID mapping. + * The lookupMessageSessionId callback remains part of the public resolver contract for + * transient events that may only carry a message ID, and onMessageUpdated records the + * messageID -> sessionID mapping from versioned message updates. */ export function resolveEventSessionId( - event: Event, + event: SSEPayload, lookupMessageSessionId: (messageId: string) => string | undefined, onMessageUpdated?: (messageId: string, sessionId: string) => void, ): string | undefined { + if (event.type === "sync") { + return resolveSyncSessionId(event, onMessageUpdated) + } + + void lookupMessageSessionId + return resolveTransientSessionId(event) +} + +function resolveSyncSessionId( + event: SyncPayload, + onMessageUpdated?: (messageId: string, sessionId: string) => void, +): string | undefined { + if (event.name === "message.updated.1") { + onMessageUpdated?.(event.data.info.id, event.data.sessionID) + } + return event.data.sessionID +} + +function resolveTransientSessionId(event: TransientPayload): string | undefined { switch (event.type) { - case "session.created": - case "session.updated": - return event.properties.info.id case "session.status": + case "session.turn.open": + case "session.turn.close": case "session.idle": case "session.error": case "todo.updated": - return event.properties.sessionID - case "message.updated": - onMessageUpdated?.(event.properties.info.id, event.properties.info.sessionID) - return event.properties.info.sessionID - case "message.part.updated": { - const part = event.properties.part as { messageID?: string; sessionID?: string } - if (part.sessionID) { - return part.sessionID - } - if (!part.messageID) { - return undefined - } - return lookupMessageSessionId(part.messageID) - } case "message.part.delta": - return event.properties.sessionID - case "message.part.removed": - return event.properties.sessionID case "permission.asked": case "permission.replied": case "question.asked": case "question.replied": case "question.rejected": - return event.properties.sessionID - default: - return resolveSuggestionSessionId(event) - } -} - -function resolveSuggestionSessionId(event: Event): string | undefined { - switch (event.type) { case "suggestion.shown": case "suggestion.accepted": case "suggestion.dismissed": + case "session.network.asked": + case "session.network.replied": + case "session.network.rejected": + case "session.network.restored": return event.properties.sessionID default: - // session.network.* events are not yet in the SDK Event type union - // (pending SDK regeneration). Handle them via string comparison. - if ((event.type as string).startsWith("session.network.")) { - return (event.properties as { sessionID: string }).sessionID - } return undefined } } diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts index 494688559ec..82c8c9358a5 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts @@ -7,6 +7,7 @@ import { dict as de } from "./de" import { dict as en } from "./en" import { dict as es } from "./es" import { dict as fr } from "./fr" +import { dict as it } from "./it" import { dict as ja } from "./ja" import { dict as ko } from "./ko" import { dict as no } from "./no" @@ -29,6 +30,7 @@ const bundles: Record> = { en, es, fr, + it, ja, ko, no, diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/it.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/it.ts new file mode 100644 index 00000000000..f032870a431 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/it.ts @@ -0,0 +1,6 @@ +export const dict = { + "server.processExited": "Il processo CLI è uscito con codice {{code}} prima dell'avvio del server", + "server.startupTimeout": "Timeout di avvio del server dopo {{seconds}} secondi", + "remote.connected": "Kilo Remote: connesso", + "remote.connecting": "Kilo Remote: connessione...", +} as const diff --git a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts index f2ba427c83f..9e7b8cc191d 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts @@ -1,6 +1,7 @@ -import type { KiloClient, GlobalEvent, Event } from "@kilocode/sdk/v2/client" +import type { KiloClient, GlobalEvent } from "@kilocode/sdk/v2/client" -export type SSEEventHandler = (event: Event, directory?: string) => void +export type SSEPayload = GlobalEvent["payload"] +export type SSEEventHandler = (event: SSEPayload, directory?: string) => void export type SSEErrorHandler = (error: Error) => void export type SSEStateHandler = (state: "connecting" | "connected" | "disconnected") => void @@ -179,13 +180,7 @@ export class SdkSSEAdapter { this.notifyState("connected") } - // The SDK yields GlobalEvent = { directory, payload: Event }. - const globalEvent = event as GlobalEvent - const type = (globalEvent.payload as { type: string }).type - if (type !== "server.heartbeat") { - console.log("[Kilo New] SSE: 📨 Event:", type) - } - this.notifyEvent(globalEvent.payload as Event, globalEvent.directory) + this.notifyEvent(event.payload, event.directory) } console.log( @@ -241,7 +236,7 @@ export class SdkSSEAdapter { // ── Notify helpers ───────────────────────────────────────────────── - private notifyEvent(event: Event, directory?: string): void { + private notifyEvent(event: SSEPayload, directory?: string): void { for (const handler of this.handlers) { try { handler(event, directory) diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 40a132e8eaa..a2657b33c77 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -17,6 +17,7 @@ export interface ServerInstance { const STARTUP_TIMEOUT_SECONDS = 30 type WorkspaceFolderLike = { uri: { fsPath: string } } +type ServerExitListener = (code: number | null) => void export function resolveServerCwd(folders: readonly WorkspaceFolderLike[] | undefined, storage: string): string { return folders?.[0]?.uri.fsPath ?? storage @@ -27,11 +28,18 @@ export function resolveIndexingEnv(folders: readonly WorkspaceFolderLike[] | und return { KILO_DISABLE_CODEBASE_INDEXING: "vscode-no-workspace" } } +export function resolveManagedServerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...env, KILO_DISABLE_CHANNEL_DB: "true" } +} + export class ServerManager { private instance: ServerInstance | null = null private startupPromise: Promise | null = null - constructor(private readonly context: vscode.ExtensionContext) {} + constructor( + private readonly context: vscode.ExtensionContext, + private readonly onExit?: ServerExitListener, + ) {} /** * Get or start the server instance @@ -104,7 +112,7 @@ export class ServerManager { NODE_USE_SYSTEM_CA: "1", ...(extraCaCerts && { NODE_EXTRA_CA_CERTS: extraCaCerts }), ...(!proxyStrictSSL && { NODE_TLS_REJECT_UNAUTHORIZED: "0" }), - ...process.env, + ...resolveManagedServerEnv(process.env), // VS Code's http.proxy / http.noProxy settings are not reflected in // process.env, so spawned children bypass the user's configured proxy // and fail behind corporate firewalls. Forward them as the standard @@ -171,6 +179,7 @@ export class ServerManager { console.log("[Kilo New] ServerManager: 🛑 Process exited with code:", code) if (this.instance?.process === serverProcess) { this.instance = null + this.onExit?.(code) } if (!resolved) { const { userMessage, userDetails } = toErrorMessage( diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts new file mode 100644 index 00000000000..84b1d34fc5c --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -0,0 +1,163 @@ +import * as path from "path" +import * as vscode from "vscode" +import type { KiloConnectionService } from "../cli-backend" +import { retry } from "../cli-backend/retry" +import type { MarketplaceService } from "." +import type { + InstallMarketplaceItemOptions, + InstallResult, + MarketplaceDataResponse, + MarketplaceItem, + MarketplaceItemRef, + RemoveResult, +} from "./types" + +export interface MarketplaceActionContext { + connection: KiloConnectionService + marketplace: MarketplaceService + storage?: vscode.Uri +} + +export interface MarketplaceRemoveContext { + connection: KiloConnectionService + storage?: vscode.Uri + remove: (item: MarketplaceItemRef, scope: "project" | "global", project?: string) => Promise +} + +export async function fetchMarketplaceData( + ctx: MarketplaceActionContext, + project: string | undefined, + dir: string | undefined, +): Promise { + const skills = dir ? await fetchSkills(ctx, dir) : undefined + return ctx.marketplace.fetchData(project, skills) +} + +export async function installMarketplaceItem( + ctx: MarketplaceActionContext, + item: MarketplaceItem, + opts: InstallMarketplaceItemOptions, + project: string | undefined, + dir: string, +): Promise { + const scope = opts.target ?? "project" + if (scope === "project" && !project) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope install" } + } + + try { + const result = await ctx.marketplace.install(item, opts, project) + if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir) + return result + } catch (err) { + return { success: false, slug: item.id, error: String(err) } + } +} + +export async function removeMarketplaceItem( + ctx: MarketplaceActionContext, + item: MarketplaceItem, + scope: "project" | "global", + project: string | undefined, + dir: string, +): Promise { + if (scope === "project" && !project) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } + } + + try { + if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, scope) + const result = await ctx.marketplace.remove(item, scope, project) + if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir) + return result + } catch (err) { + return { success: false, slug: item.id, error: String(err) } + } +} + +export async function removeMarketplaceItemFromAllScopes( + ctx: MarketplaceRemoveContext, + item: MarketplaceItemRef, + project: string | undefined, + dir: string, +): Promise { + try { + if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, "all") + const local = project ? await ctx.remove(item, "project", project) : undefined + const global = await ctx.remove(item, "global", project) + if (!local?.success && !global.success) return false + await invalidate(ctx, global.success ? "global" : "project", global.success ? dir : project!) + return true + } catch (err) { + console.warn("[Kilo New] Marketplace removal failed:", err) + return false + } +} + +async function fetchSkills(ctx: MarketplaceActionContext, dir: string) { + try { + const client = await ctx.connection.getClientAsync(dir) + const { data } = await retry(() => client.app.skills({ directory: dir }, { throwOnError: true })) + return data + } catch (err) { + console.warn("[Kilo New] Failed to fetch CLI skills for marketplace:", err) + return undefined + } +} + +async function invalidate( + ctx: { connection: KiloConnectionService }, + scope: "project" | "global", + dir: string, +): Promise { + const client = await ctx.connection.getClientAsync(dir).catch((err: unknown) => { + console.warn("[Kilo New] Marketplace CLI invalidation deferred:", err) + return null + }) + if (!client) return + + if (scope === "global") { + await client.global.config.update({ config: {} }).catch((err: unknown) => { + console.warn("[Kilo New] global.config.update after marketplace change failed:", err) + }) + } + await client.instance.dispose({ directory: dir }).catch((err: unknown) => { + console.warn("[Kilo New] instance.dispose() after marketplace change failed:", err) + }) +} + +async function removeLegacyMcp( + ctx: { storage?: vscode.Uri }, + name: string, + project: string | undefined, + scope: "project" | "global" | "all", +): Promise { + const files: vscode.Uri[] = [] + if (project && scope !== "global") { + files.push(vscode.Uri.file(path.join(project, ".kilo", "mcp.json"))) + files.push(vscode.Uri.file(path.join(project, ".kilocode", "mcp.json"))) + } + + if (ctx.storage && scope !== "project") files.push(vscode.Uri.joinPath(ctx.storage, "settings", "mcp_settings.json")) + + let removed = false + for (const uri of files) { + const bytes = await vscode.workspace.fs.readFile(uri).then( + (data) => data, + () => null, + ) + if (!bytes) continue + + try { + const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record + const servers = parsed.mcpServers as Record | undefined + if (!servers?.[name]) continue + delete servers[name] + await vscode.workspace.fs.writeFile(uri, Buffer.from(JSON.stringify(parsed, null, 2), "utf8")) + removed = true + } catch (err) { + console.warn("[Kilo New] Failed to remove legacy MCP from", uri.fsPath, err) + } + } + return removed +} diff --git a/packages/kilo-vscode/src/services/marketplace/api.ts b/packages/kilo-vscode/src/services/marketplace/api.ts index c687d9f5997..8dbd3c5a9b8 100644 --- a/packages/kilo-vscode/src/services/marketplace/api.ts +++ b/packages/kilo-vscode/src/services/marketplace/api.ts @@ -1,5 +1,5 @@ import { parse as parseYaml } from "yaml" -import type { MarketplaceItem, McpMarketplaceItem, ModeMarketplaceItem, SkillMarketplaceItem, RawSkill } from "./types" +import type { MarketplaceItem, McpMarketplaceItem, AgentMarketplaceItem, SkillMarketplaceItem, RawSkill } from "./types" const BASE_URL = "https://api.kilo.ai/api/marketplace" const CACHE_TTL = 300_000 @@ -76,18 +76,6 @@ export class MarketplaceApiClient { this.cache.set(key, { data, timestamp: Date.now() }) } - private async fetchModes(): Promise { - const cached = this.getCached("modes") - if (cached) return cached as ModeMarketplaceItem[] - - const text = await fetchWithRetry(`${BASE_URL}/modes`) - const parsed = parseResponse(text) as { items?: unknown[] } - const items = (parsed.items ?? []) as Array> - const result = items.map((item) => ({ ...item, type: "mode" as const }) as ModeMarketplaceItem) - this.setCache("modes", result) - return result - } - private async fetchMcps(): Promise { const cached = this.getCached("mcps") if (cached) return cached as McpMarketplaceItem[] @@ -100,6 +88,18 @@ export class MarketplaceApiClient { return result } + private async fetchAgents(): Promise { + const cached = this.getCached("agents") + if (cached) return cached as AgentMarketplaceItem[] + + const text = await fetchWithRetry(`${BASE_URL}/agents`) + const parsed = parseResponse(text) as { items?: unknown[] } + const items = (parsed.items ?? []) as Array> + const result = items.map((item) => ({ ...item, type: "agent" as const }) as AgentMarketplaceItem) + this.setCache("agents", result) + return result + } + private async fetchSkills(): Promise { const cached = this.getCached("skills") if (cached) return cached as SkillMarketplaceItem[] @@ -116,9 +116,9 @@ export class MarketplaceApiClient { const errors: string[] = [] const settled = await Promise.all([ - this.fetchModes().catch((err: unknown) => { - errors.push(`Failed to fetch modes: ${err instanceof Error ? err.message : String(err)}`) - return [] as ModeMarketplaceItem[] + this.fetchAgents().catch((err: unknown) => { + errors.push(`Failed to fetch agents: ${err instanceof Error ? err.message : String(err)}`) + return [] as AgentMarketplaceItem[] }), this.fetchMcps().catch((err: unknown) => { errors.push(`Failed to fetch mcps: ${err instanceof Error ? err.message : String(err)}`) @@ -136,10 +136,6 @@ export class MarketplaceApiClient { } } - clearCache(): void { - this.cache.clear() - } - dispose(): void { this.cache.clear() } diff --git a/packages/kilo-vscode/src/services/marketplace/detection.ts b/packages/kilo-vscode/src/services/marketplace/detection.ts index 9f203d3ceb1..fb2030beb7d 100644 --- a/packages/kilo-vscode/src/services/marketplace/detection.ts +++ b/packages/kilo-vscode/src/services/marketplace/detection.ts @@ -16,6 +16,7 @@ export class InstallationDetector { /** * Detect installed marketplace items. * + * Agents are detected from .kilo/agents/*.md files. * MCP servers and modes are detected from kilo.json config files. * Skills come from the CLI backend (via GET /skill), which is the * authoritative source — it scans all skill directories. @@ -23,12 +24,14 @@ export class InstallationDetector { async detect(workspace?: string, skills?: CliSkill[]): Promise { const project = workspace ? Object.fromEntries([ + ...(await this.detectAgentFiles("project", workspace)), ...(await this.detectFromConfig(this.paths.configPath("project", workspace))), ...this.skillEntries(skills, workspace, true), ]) : {} const global = Object.fromEntries([ + ...(await this.detectAgentFiles("global")), ...(await this.detectFromConfig(this.paths.configPath("global"))), ...this.skillEntries(skills, workspace, false), ]) @@ -52,6 +55,20 @@ export class InstallationDetector { .map((s) => [s.name, { type: "skill" }]) } + /** Scan .kilo/agents/*.md files to detect installed marketplace agents. */ + private async detectAgentFiles(scope: "project" | "global", workspace?: string): Promise { + const dir = this.paths.agentsDir(scope, workspace) + try { + const files = await fs.readdir(dir) + return files.filter((f) => f.endsWith(".md")).map((f) => [path.basename(f, ".md"), { type: "agent" }] as Entry) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn(`Failed to detect agent files from ${dir}:`, err) + } + return [] + } + } + /** Read mcp and agent entries from a kilo.json config file. */ private async detectFromConfig(filepath: string): Promise { try { @@ -67,7 +84,7 @@ export class InstallationDetector { if (parsed?.agent && typeof parsed.agent === "object") { for (const key of Object.keys(parsed.agent)) { - entries.push([key, { type: "mode" }]) + entries.push([key, { type: "agent" }]) } } diff --git a/packages/kilo-vscode/src/services/marketplace/index.ts b/packages/kilo-vscode/src/services/marketplace/index.ts index cb2272e7168..148ec9e6d89 100644 --- a/packages/kilo-vscode/src/services/marketplace/index.ts +++ b/packages/kilo-vscode/src/services/marketplace/index.ts @@ -65,6 +65,7 @@ export class MarketplaceService { export type { MarketplaceItem, + AgentMarketplaceItem, InstallMarketplaceItemOptions, MarketplaceDataResponse, InstallResult, diff --git a/packages/kilo-vscode/src/services/marketplace/installer.ts b/packages/kilo-vscode/src/services/marketplace/installer.ts index eff254f1a06..7f8d1d05cf9 100644 --- a/packages/kilo-vscode/src/services/marketplace/installer.ts +++ b/packages/kilo-vscode/src/services/marketplace/installer.ts @@ -1,13 +1,15 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" +import { randomUUID } from "crypto" import * as yaml from "yaml" import { exec } from "../../util/process" import type { MarketplaceItem, + MarketplaceItemRef, SkillMarketplaceItem, McpMarketplaceItem, - ModeMarketplaceItem, + AgentMarketplaceItem, McpInstallationMethod, InstallMarketplaceItemOptions, InstallResult, @@ -26,7 +28,7 @@ export class MarketplaceInstaller { const scope = options.target ?? "project" if (item.type === "skill") return this.installSkill(item, scope, workspace) if (item.type === "mcp") return this.installMcp(item, options, scope, workspace) - return this.installMode(item, scope, workspace) + return this.installAgent(item, scope, workspace) } // ── MCP ───────────────────────────────────────────────────────────── @@ -81,10 +83,10 @@ export class MarketplaceInstaller { return normalizeMcpEntry(raw) } - // ── Mode ──────────────────────────────────────────────────────────── + // ── Agent ─────────────────────────────────────────────────────────── - async installMode( - item: ModeMarketplaceItem, + async installAgent( + item: AgentMarketplaceItem, scope: "project" | "global", workspace?: string, ): Promise { @@ -92,16 +94,76 @@ export class MarketplaceInstaller { return { success: false, slug: item.id, error: "No workspace directory for project-scope install" } } + if (!isSafeId(item.id)) { + return { success: false, slug: item.id, error: "Invalid agent id" } + } + + const dir = this.paths.agentsDir(scope, workspace) + await fs.mkdir(dir, { recursive: true }) + + const filepath = path.join(dir, `${item.id}.md`) + if (!contains(dir, filepath)) { + return { success: false, slug: item.id, error: "Invalid agent id" } + } + + try { + await fs.access(filepath) + return { success: false, slug: item.id, error: "Agent already installed. Remove it first." } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err + } + + const { prompt, ...front } = item.content + const frontmatter = yaml.stringify(front).trimEnd() + const content = `---\n${frontmatter}\n---\n\n${prompt}\n` + await fs.writeFile(filepath, content, "utf-8") + + // Migration: remove stale kilo.json agent entry with same id if present const config = await this.readConfig(scope, workspace) - if (!config.agent) config.agent = {} + if (config.agent?.[item.id]) { + delete (config.agent as Record)[item.id] + if (Object.keys(config.agent as object).length === 0) delete config.agent + await this.writeConfig(scope, workspace, config) + } - if (config.agent[item.id]) { - return { success: false, slug: item.id, error: "Mode already installed. Remove it first." } + return { success: true, slug: item.id, filePath: filepath, line: 1 } + } + + async removeAgent( + item: Pick, + scope: "project" | "global", + workspace?: string, + ): Promise { + if (scope === "project" && !workspace) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } } - config.agent[item.id] = convertModeToAgent(item.content) + if (!isSafeId(item.id)) { + return { success: false, slug: item.id, error: "Invalid agent id" } + } + + const dir = this.paths.agentsDir(scope, workspace) + const filepath = path.join(dir, `${item.id}.md`) + if (!contains(dir, filepath)) { + return { success: false, slug: item.id, error: "Invalid agent id" } + } + + try { + await fs.unlink(filepath) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + return { success: false, slug: item.id, error: String(err) } + } + } + + // Also clean up any stale kilo.json agent entry + const config = await this.readConfig(scope, workspace) + if (config.agent?.[item.id]) { + delete (config.agent as Record)[item.id] + if (Object.keys(config.agent as object).length === 0) delete config.agent + await this.writeConfig(scope, workspace, config) + } - await this.writeConfig(scope, workspace, config) return { success: true, slug: item.id } } @@ -112,6 +174,10 @@ export class MarketplaceInstaller { scope: "project" | "global", workspace?: string, ): Promise { + if (scope === "project" && !workspace) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope install" } + } + if (!item.content) { return { success: false, slug: item.id, error: "Skill has no tarball URL" } } @@ -122,22 +188,18 @@ export class MarketplaceInstaller { const base = this.paths.skillsDir(scope, workspace) const dir = path.join(base, item.id) - if (!path.resolve(dir).startsWith(path.resolve(base))) { + if (!contains(base, dir)) { return { success: false, slug: item.id, error: "Invalid skill id" } } - try { - await fs.access(dir) + if (await exists(dir)) { return { success: false, slug: item.id, error: "Skill already installed. Uninstall it before installing again." } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err } - const stamp = Date.now() - const tarball = path.join(os.tmpdir(), `kilo-skill-${item.id}-${stamp}.tar.gz`) // Stage under `base` (not os.tmpdir()) so fs.rename() never crosses filesystems (EXDEV). await fs.mkdir(base, { recursive: true }) - const staging = path.join(base, `.staging-${item.id}-${stamp}`) + const staging = await fs.mkdtemp(path.join(base, `.staging-${item.id}-`)) + const tarball = path.join(os.tmpdir(), `kilo-skill-${item.id}-${randomUUID()}.tar.gz`) try { const response = await fetch(item.content) @@ -147,14 +209,11 @@ export class MarketplaceInstaller { const buffer = Buffer.from(await response.arrayBuffer()) await fs.writeFile(tarball, buffer) - - await fs.mkdir(staging, { recursive: true }) await exec("tar", ["-xzf", tarball, "--strip-components=1", "-C", staging]) const escaped = await findEscapedPaths(staging) if (escaped.length > 0) { console.warn(`Skill archive ${item.id} contains escaped paths:`, escaped) - await fs.rm(staging, { recursive: true }) return { success: false, slug: item.id, error: "Skill archive contains unsafe paths" } } @@ -162,7 +221,6 @@ export class MarketplaceInstaller { await fs.access(path.join(staging, "SKILL.md")) } catch { console.warn(`Extracted skill ${item.id} missing SKILL.md, rolling back`) - await fs.rm(staging, { recursive: true }) return { success: false, slug: item.id, error: "Extracted archive missing SKILL.md" } } @@ -170,31 +228,47 @@ export class MarketplaceInstaller { return { success: true, slug: item.id, filePath: path.join(dir, "SKILL.md"), line: 1 } } catch (err) { - console.warn(`Failed to install skill ${item.id}:`, err) - try { - await fs.rm(staging, { recursive: true }) - } catch { - console.warn(`Failed to clean up staging directory ${staging}`) + if (await exists(dir)) { + return { + success: false, + slug: item.id, + error: "Skill already installed. Uninstall it before installing again.", + } } + console.warn(`Failed to install skill ${item.id}:`, err) return { success: false, slug: item.id, error: String(err) } } finally { - try { - await fs.unlink(tarball) - } catch { - console.warn(`Failed to clean up temp file ${tarball}`) - } + await Promise.all([ + fs.rm(staging, { recursive: true, force: true }).catch((err) => { + console.warn(`Failed to clean up staging directory ${staging}:`, err) + }), + fs.rm(tarball, { force: true }).catch((err) => { + console.warn(`Failed to clean up temp file ${tarball}:`, err) + }), + ]) } } // ── Remove ────────────────────────────────────────────────────────── - async remove(item: MarketplaceItem, scope: "project" | "global", workspace?: string): Promise { + async remove(item: MarketplaceItemRef, scope: "project" | "global", workspace?: string): Promise { + if (scope === "project" && !workspace) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } + } if (item.type === "skill") return this.removeSkill(item, scope, workspace) if (item.type === "mcp") return this.removeMcp(item, scope, workspace) - return this.removeMode(item, scope, workspace) + return this.removeAgent(item, scope, workspace) } - async removeMcp(item: McpMarketplaceItem, scope: "project" | "global", workspace?: string): Promise { + async removeMcp( + item: Pick, + scope: "project" | "global", + workspace?: string, + ): Promise { + if (scope === "project" && !workspace) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } + } + const config = await this.readConfig(scope, workspace) if (!config.mcp?.[item.id]) { return { success: true, slug: item.id } @@ -205,28 +279,21 @@ export class MarketplaceInstaller { return { success: true, slug: item.id } } - async removeMode(item: ModeMarketplaceItem, scope: "project" | "global", workspace?: string): Promise { - const config = await this.readConfig(scope, workspace) - if (!config.agent?.[item.id]) { - return { success: true, slug: item.id } - } - delete config.agent[item.id] - if (Object.keys(config.agent).length === 0) delete config.agent - await this.writeConfig(scope, workspace, config) - return { success: true, slug: item.id } - } - async removeSkill( - item: SkillMarketplaceItem, + item: Pick, scope: "project" | "global", workspace?: string, ): Promise { + if (scope === "project" && !workspace) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } + } + if (!isSafeId(item.id)) { return { success: false, slug: item.id, error: "Invalid skill id" } } const base = this.paths.skillsDir(scope, workspace) const dir = path.join(base, item.id) - if (!path.resolve(dir).startsWith(path.resolve(base))) { + if (!contains(base, dir)) { return { success: false, slug: item.id, error: "Invalid skill id" } } try { @@ -271,6 +338,20 @@ export class MarketplaceInstaller { // ── Helpers ───────────────────────────────────────────────────────── +async function exists(filepath: string): Promise { + try { + await fs.access(filepath) + return true + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false + throw err + } +} + +function contains(dir: string, filepath: string): boolean { + return path.resolve(filepath).startsWith(path.resolve(dir) + path.sep) +} + /** * Normalize a marketplace MCP entry from the old Kilocode format to the CLI's expected format. * @@ -314,51 +395,11 @@ function normalizeMcpEntry(raw: Record): Record = { - read: "read", - edit: "edit", - browser: "bash", - command: "bash", - mcp: "mcp", -} -const ALL_PERMISSIONS = ["read", "edit", "bash", "mcp"] - -function convertModeToAgent(content: string): Record { - const mode = yaml.parse(content) as Record - const groups = (mode.groups ?? []) as Array]> - - const permission: Record = {} - const allowed = new Set() - for (const group of groups) { - if (typeof group === "string") { - const key = GROUP_PERMISSIONS[group] ?? group - allowed.add(key) - permission[key] = "allow" - } else if (Array.isArray(group)) { - const [name, cfg] = group - const key = GROUP_PERMISSIONS[name] ?? name - allowed.add(key) - permission[key] = cfg?.fileRegex ? { [String(cfg.fileRegex)]: "allow", "*": "deny" } : "allow" - } - } - for (const perm of ALL_PERMISSIONS) { - if (!allowed.has(perm)) permission[perm] = "deny" - } - - const prompt = [mode.roleDefinition, mode.customInstructions].filter(Boolean).join("\n\n") - return { - mode: "primary", - description: mode.description ?? mode.whenToUse ?? mode.name, - prompt, - permission, - } -} - function escapeJsonValue(raw: string): string { return raw .replace(/\\/g, "\\\\") diff --git a/packages/kilo-vscode/src/services/marketplace/paths.ts b/packages/kilo-vscode/src/services/marketplace/paths.ts index 112c6cad23d..d7c8c19582b 100644 --- a/packages/kilo-vscode/src/services/marketplace/paths.ts +++ b/packages/kilo-vscode/src/services/marketplace/paths.ts @@ -17,6 +17,12 @@ export class MarketplacePaths { return path.join(globalConfigDir(), "kilo.json") } + /** Agent install directory (where marketplace agents are written as .md files). */ + agentsDir(scope: "project" | "global", workspace?: string): string { + if (scope === "project") return path.join(workspace!, ".kilo", "agents") + return path.join(globalConfigDir(), "agents") + } + /** Skill install directory (where the marketplace installer writes to). */ skillsDir(scope: "project" | "global", workspace?: string): string { if (scope === "project") return path.join(workspace!, ".kilo", "skills") diff --git a/packages/kilo-vscode/src/services/marketplace/types.ts b/packages/kilo-vscode/src/services/marketplace/types.ts index c35ad472419..81f1c6f031f 100644 --- a/packages/kilo-vscode/src/services/marketplace/types.ts +++ b/packages/kilo-vscode/src/services/marketplace/types.ts @@ -29,9 +29,17 @@ export interface McpMarketplaceItem extends MarketplaceItemBase { parameters?: McpParameter[] } -export interface ModeMarketplaceItem extends MarketplaceItemBase { - type: "mode" - content: string +export interface AgentContent { + mode: "primary" | "subagent" | "all" + description: string + prompt: string + options?: Record + permission?: Record +} + +export interface AgentMarketplaceItem extends MarketplaceItemBase { + type: "agent" + content: AgentContent } export interface RawSkill { @@ -51,7 +59,8 @@ export interface SkillMarketplaceItem extends MarketplaceItemBase { displayCategory: string } -export type MarketplaceItem = McpMarketplaceItem | ModeMarketplaceItem | SkillMarketplaceItem +export type MarketplaceItem = McpMarketplaceItem | AgentMarketplaceItem | SkillMarketplaceItem +export type MarketplaceItemRef = Pick export interface InstallMarketplaceItemOptions { target?: "global" | "project" diff --git a/packages/kilo-vscode/src/services/telemetry/types.ts b/packages/kilo-vscode/src/services/telemetry/types.ts index 5993151d037..a4a587362d7 100644 --- a/packages/kilo-vscode/src/services/telemetry/types.ts +++ b/packages/kilo-vscode/src/services/telemetry/types.ts @@ -29,6 +29,8 @@ export enum TelemetryEventName { // UI Interactions TAB_SHOWN = "Tab Shown", TITLE_BUTTON_CLICKED = "Title Button Clicked", + WORK_STYLE_ONBOARDING_SHOWN = "Work Style Onboarding Shown", + WORK_STYLE_SELECTED = "Work Style Selected", PROMPT_ENHANCED = "Prompt Enhanced", // Marketplace @@ -65,6 +67,7 @@ export enum TelemetryEventName { // Kilo-specific COMMIT_MSG_GENERATED = "Commit Message Generated", AGENT_MANAGER_OPENED = "Agent Manager Opened", + AGENT_MANAGER_BUTTON_CLICKED = "Agent Manager Button Clicked", AGENT_MANAGER_SESSION_STARTED = "Agent Manager Session Started", AGENT_MANAGER_SESSION_COMPLETED = "Agent Manager Session Completed", AGENT_MANAGER_SESSION_STOPPED = "Agent Manager Session Stopped", diff --git a/packages/kilo-vscode/src/session-status.ts b/packages/kilo-vscode/src/session-status.ts index e596603e704..67856f81f54 100644 --- a/packages/kilo-vscode/src/session-status.ts +++ b/packages/kilo-vscode/src/session-status.ts @@ -1,17 +1,5 @@ import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" -/** - * Returns the number of sessions currently in "busy" state. - * Used to warn users before operations that will interrupt running sessions. - */ -export function getBusySessionCount(map: Map): number { - let count = 0 - for (const status of map.values()) { - if (status === "busy") count++ - } - return count -} - /** * Fetch all current session statuses and seed the provided map + webview. * Called on connect so the Settings panel knows about already-running sessions diff --git a/packages/kilo-vscode/src/shared/autocomplete-models.ts b/packages/kilo-vscode/src/shared/autocomplete-models.ts index 6f46b56512b..e67350a1313 100644 --- a/packages/kilo-vscode/src/shared/autocomplete-models.ts +++ b/packages/kilo-vscode/src/shared/autocomplete-models.ts @@ -1,52 +1,10 @@ -/** - * Single source of truth for autocomplete FIM model definitions. - * - * Shared between extension code (src/) and webview code (webview-ui/). - * When adding a new model, update ONLY this file and package.json's - * `kilo-code.new.autocomplete.model` enum. - */ - -export interface AutocompleteModelDef { - /** Full model ID sent to the gateway, e.g. "mistralai/codestral-2508" */ - readonly id: string - /** Human-readable label shown in the settings dropdown */ - readonly label: string - /** Provider display name for status bar / telemetry */ - readonly provider: string - /** FIM request temperature */ - readonly temperature: number -} - -const models: AutocompleteModelDef[] = [ - { - id: "mistralai/codestral-2508", - label: "Codestral (Mistral AI)", - provider: "Mistral AI", - temperature: 0.2, - }, - { - id: "inception/mercury-edit-2", - label: "Mercury Edit 2 (Inception)", - provider: "Inception", - temperature: 0, - }, -] - -export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models - -export const DEFAULT_AUTOCOMPLETE_MODEL: AutocompleteModelDef = models[0]! - -// Map inception/mercury-edit to inception/mercury-edit-2 so users who -// already have the old id saved in settings.json keep working without a -// silent fallback to Codestral. Not exposed in the dropdown. -const aliases: Record = { - "inception/mercury-edit": "inception/mercury-edit-2", -} - -export function getAutocompleteModel(id: string): AutocompleteModelDef { - const resolved = aliases[id] ?? id - for (const m of models) { - if (m.id === resolved) return m - } - return DEFAULT_AUTOCOMPLETE_MODEL -} +export { + AUTOCOMPLETE_MODELS, + DEFAULT_AUTOCOMPLETE_MODEL, + getAutocompleteModel, + getAutocompleteModelById, + validAutocompleteModel, + validAutocompleteProvider, + type AutocompleteModelDef, + type AutocompleteProviderID, +} from "@kilocode/kilo-gateway/autocomplete" diff --git a/packages/kilo-vscode/src/shared/custom-provider.ts b/packages/kilo-vscode/src/shared/custom-provider.ts index b8ed2dfa294..8cadf7d07a9 100644 --- a/packages/kilo-vscode/src/shared/custom-provider.ts +++ b/packages/kilo-vscode/src/shared/custom-provider.ts @@ -1,5 +1,6 @@ import { z } from "zod" -import { CUSTOM_PROVIDER_PACKAGE, PROVIDER_ID_PATTERN } from "./provider-model" +import { CUSTOM_PROVIDER_PACKAGE, CUSTOM_PROVIDER_PACKAGES, PROVIDER_ID_PATTERN } from "./provider-model" +import type { CustomProviderPackage } from "./provider-model" const INVALID_PROVIDER_ID = "Invalid provider ID" const INVALID_ENV = "Invalid environment variable name" @@ -13,8 +14,10 @@ export const EnvSchema = z const VariantConfigSchema = z.object({ enable_thinking: z.boolean().optional(), - thinking: z.object({ type: z.enum(["enabled", "disabled"]) }).optional(), + thinking: z.object({ type: z.enum(["enabled", "disabled", "adaptive"]) }).optional(), + reasoning_split: z.boolean().optional(), reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(), + effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(), chat_template_args: z.object({ enable_thinking: z.boolean() }).optional(), }) @@ -22,7 +25,7 @@ export type VariantConfig = z.infer export const CustomProviderConfigSchema = z .object({ - npm: z.string().optional(), + npm: z.enum(CUSTOM_PROVIDER_PACKAGES).default(CUSTOM_PROVIDER_PACKAGE), name: z.string().trim().min(1).max(200), env: z.array(EnvSchema).max(1).optional(), options: z @@ -53,7 +56,7 @@ export const CustomProviderConfigSchema = z .strict() export type SanitizedProviderConfig = { - npm: typeof CUSTOM_PROVIDER_PACKAGE + npm: CustomProviderPackage name: string env?: string[] options: { @@ -118,7 +121,7 @@ export function normalizeCustomProviderConfig( : undefined return { - npm: CUSTOM_PROVIDER_PACKAGE, + npm: config.npm, name: config.name.trim(), ...(config.env ? { env: config.env.map((item) => item.trim()) } : {}), options: { @@ -149,21 +152,33 @@ export function sanitizeCustomProviderConfig(provider: unknown): { value: Saniti } type AnyRecord = Record +type VariantPatch = Partial<{ [Key in keyof VariantConfig]: VariantConfig[Key] | null }> +type ProviderPatch = Omit & { + models: Record< + string, + null | { + name: string + reasoning?: true | null + variants?: Record + } + > +} function isRecord(v: unknown): v is AnyRecord { return !!v && typeof v === "object" && !Array.isArray(v) } /** - * Build a provider patch that includes null sentinels for models and variants - * that existed in the previous config but are absent from the new one. The CLI - * `config.update` endpoint deep-merges the payload with the existing config; - * without explicit nulls, removed entries would persist on disk. + * Build a provider patch that includes null sentinels for model properties, + * variants, and variant options that existed in the previous config but are + * absent from the new one. The CLI `config.update` endpoint deep-merges the + * payload with the existing config; without explicit nulls, removed entries + * would persist on disk. */ export function withCustomProviderDeletions(existing: unknown, next: SanitizedProviderConfig): SanitizedProviderConfig { if (!isRecord(existing)) return next const oldModels = isRecord(existing.models) ? existing.models : {} - const patched: AnyRecord = { ...next.models } + const patched: ProviderPatch["models"] = { ...next.models } for (const id of Object.keys(oldModels)) { if (!(id in patched)) { @@ -171,15 +186,30 @@ export function withCustomProviderDeletions(existing: unknown, next: SanitizedPr continue } const oldModel = oldModels[id] - const oldVariants = isRecord(oldModel) && isRecord(oldModel.variants) ? oldModel.variants : {} const newModel = patched[id] - if (!isRecord(newModel)) continue + if (!isRecord(oldModel) || !isRecord(newModel)) continue + const oldVariants = isRecord(oldModel.variants) ? oldModel.variants : {} const newVariants = isRecord(newModel.variants) ? newModel.variants : {} - const removedVariants = Object.keys(oldVariants).filter((v) => !(v in newVariants)) - if (removedVariants.length === 0) continue - const nulls = Object.fromEntries(removedVariants.map((v) => [v, null])) - patched[id] = { ...newModel, variants: { ...newVariants, ...nulls } } + const changes: Record = {} + for (const [name, oldVariant] of Object.entries(oldVariants)) { + if (!(name in newVariants)) { + changes[name] = null + continue + } + const newVariant = newVariants[name] + if (!isRecord(oldVariant) || !isRecord(newVariant)) continue + const removed = Object.keys(oldVariant).filter((key) => !(key in newVariant)) + if (removed.length === 0) continue + const nulls = Object.fromEntries(removed.map((key) => [key, null])) + changes[name] = { ...newVariant, ...nulls } as VariantPatch + } + const variants = Object.keys(changes).length > 0 ? { ...newVariants, ...changes } : newModel.variants + patched[id] = { + ...newModel, + ...(variants ? { variants } : {}), + ...(oldModel.reasoning !== undefined && newModel.reasoning === undefined ? { reasoning: null } : {}), + } } - return { ...next, models: patched as SanitizedProviderConfig["models"] } + return { ...next, models: patched } as SanitizedProviderConfig } diff --git a/packages/kilo-vscode/src/shared/provider-model.ts b/packages/kilo-vscode/src/shared/provider-model.ts index 21de283075f..66d8e9099f1 100644 --- a/packages/kilo-vscode/src/shared/provider-model.ts +++ b/packages/kilo-vscode/src/shared/provider-model.ts @@ -1,18 +1,25 @@ export const KILO_PROVIDER_ID = "kilo" export const KILO_AUTO = { providerID: KILO_PROVIDER_ID, modelID: "kilo-auto/free" } as const -export const CUSTOM_PROVIDER_PACKAGE = "@ai-sdk/openai-compatible" +export const CUSTOM_PROVIDER_PACKAGES = ["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"] as const +export type CustomProviderPackage = (typeof CUSTOM_PROVIDER_PACKAGES)[number] +export const CUSTOM_PROVIDER_PACKAGE: CustomProviderPackage = "@ai-sdk/openai-compatible" export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/ +// Legacy/static fallback for provider objects created before backend metadata is available. export const PROVIDER_PRIORITY = [ KILO_PROVIDER_ID, "anthropic", - "github-copilot", + "deepseek", "openai", "google", "openrouter", "vercel", ] as const +export function isCustomProviderPackage(value: unknown): value is CustomProviderPackage { + return CUSTOM_PROVIDER_PACKAGES.includes(value as CustomProviderPackage) +} + export function parseModelString(raw: string | undefined | null) { if (!raw) return null const slash = raw.indexOf("/") @@ -31,6 +38,11 @@ export function createKiloFallbackProvider() { name: "Kilo Gateway", source: "custom" as const, env: ["KILO_API_KEY"], + metadata: { + noteKey: "settings.providers.note.kilo", + icon: KILO_PROVIDER_ID, + priority: 0, + }, models: {}, } } diff --git a/packages/kilo-vscode/src/shared/review-comments.ts b/packages/kilo-vscode/src/shared/review-comments.ts new file mode 100644 index 00000000000..1e5ae19d653 --- /dev/null +++ b/packages/kilo-vscode/src/shared/review-comments.ts @@ -0,0 +1,118 @@ +export interface ReviewCommentData { + id: string + file: string + side: "additions" | "deletions" + line: number + comment: string + selectedText: string +} + +export interface ReviewMessageData { + version: 1 + comments: ReviewCommentData[] +} + +interface ReviewMessageView { + data: ReviewMessageData + body: string +} + +const LIMIT = 100 +const TOTAL_LIMIT = 1_000_000 +const TEXT_LIMIT = 100_000 +const SELECTION_LIMIT = 200_000 + +function escapeInline(value: string): string { + return value.replace(/([\\`*_\[\]{}()#+\-!|])/g, "\\$1") +} + +export function formatReviewCommentMarkdown(comment: ReviewCommentData): string { + const lines = [`**${escapeInline(comment.file)}** (line ${comment.line}):`] + if (comment.selectedText) { + const matches = comment.selectedText.match(/`+/g) ?? [] + const longest = matches.reduce((max, item) => Math.max(max, item.length), 0) + const fence = "`".repeat(Math.max(3, longest + 1)) + lines.push(fence, comment.selectedText, fence) + } + lines.push(comment.comment) + return lines.join("\n") +} + +export function formatReviewCommentsMarkdown(comments: ReviewCommentData[]): string { + const lines = ["## Review Comments", ""] + for (const item of comments) { + lines.push(formatReviewCommentMarkdown(item), "") + } + return lines.join("\n").trimEnd() +} + +function record(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return value as Record +} + +function text(value: unknown, limit: number): string | undefined { + if (typeof value !== "string" || value.length > limit) return undefined + return value +} + +function parseComment(value: unknown): ReviewCommentData | undefined { + const item = record(value) + if (!item) return undefined + + const id = text(item.id, 512) + const file = text(item.file, 4_096) + const comment = text(item.comment, TEXT_LIMIT) + const selectedText = text(item.selectedText, SELECTION_LIMIT) + const side = item.side + const line = item.line + if (!id || !file || comment === undefined || selectedText === undefined) return undefined + const absolute = file.startsWith("/") || file.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(file) + const traversal = file.split(/[\\/]/).includes("..") + if (absolute || traversal || file.includes("\0")) return undefined + if (side !== "additions" && side !== "deletions") return undefined + if (typeof line !== "number" || !Number.isInteger(line) || line < 1) return undefined + + return { id, file, side, line, comment, selectedText } +} + +function view(value: unknown, content: string): ReviewMessageView | undefined { + const data = record(value) + if (!data || data.version !== 1 || !Array.isArray(data.comments)) return undefined + if (data.comments.length === 0 || data.comments.length > LIMIT) return undefined + + const comments: ReviewCommentData[] = [] + for (const value of data.comments) { + const item = parseComment(value) + if (!item) return undefined + comments.push(item) + } + const size = comments.reduce( + (total, item) => total + item.id.length + item.file.length + item.comment.length + item.selectedText.length, + 0, + ) + if (size > TOTAL_LIMIT) return undefined + + const prefix = formatReviewCommentsMarkdown(comments) + if (content === prefix) return { data: { version: 1, comments }, body: "" } + if (!content.startsWith(`${prefix}\n\n`)) return undefined + return { data: { version: 1, comments }, body: content.slice(prefix.length + 2) } +} + +export function parseReview(value: unknown, content: string): ReviewMessageData | undefined { + return view(value, content)?.data +} + +export function reviewMetadata(review: ReviewMessageData): Record { + return { kilo: { review } } +} + +export function reviewBody(review: ReviewMessageData, content: string): string | undefined { + return view(review, content)?.body +} + +export function partReview(metadata: unknown, content: string): ReviewMessageView | undefined { + const root = record(metadata) + const kilo = record(root?.kilo) + return view(kilo?.review, content) +} diff --git a/packages/kilo-vscode/src/shared/session-title.ts b/packages/kilo-vscode/src/shared/session-title.ts new file mode 100644 index 00000000000..a86e01360d7 --- /dev/null +++ b/packages/kilo-vscode/src/shared/session-title.ts @@ -0,0 +1,16 @@ +export const SESSION_TITLE_LIMIT = 200 + +// Block terminal/display controls and bidi marks that can visually spoof a title. +const unsafe = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/u + +type SessionTitleIssue = "invalid" | "required" | "too_long" | "control" +type SessionTitleResult = { value: string } | { error: SessionTitleIssue } + +export function parseSessionTitle(raw: unknown): SessionTitleResult { + if (typeof raw !== "string") return { error: "invalid" } + const value = raw.trim() + if (!value) return { error: "required" } + if (value.length > SESSION_TITLE_LIMIT) return { error: "too_long" } + if (unsafe.test(value)) return { error: "control" } + return { value } +} diff --git a/packages/kilo-vscode/src/shared/work-style-presets.ts b/packages/kilo-vscode/src/shared/work-style-presets.ts new file mode 100644 index 00000000000..4bcfecc6748 --- /dev/null +++ b/packages/kilo-vscode/src/shared/work-style-presets.ts @@ -0,0 +1,160 @@ +type PermissionLevel = "allow" | "ask" | "deny" +type PermissionRule = PermissionLevel | null | Record +type PermissionConfig = Partial> + +export interface WorkStyleConfig { + permission?: PermissionConfig + terminal_command_display?: "expanded" | "collapsed" + auto_collapse_reasoning?: boolean +} + +export type WorkStyle = "human-in-the-loop" | "autonomous" +export type WorkStyleState = WorkStyle | "skipped" | "unset" + +export interface WorkStyleSettings { + showTaskTimeline: boolean +} + +export interface WorkStylePreset { + style: WorkStyle + config: WorkStyleConfig + settings: WorkStyleSettings +} + +export interface WorkStyleApplyPlan { + config: WorkStyleConfig + settings: Partial +} + +const BASH: Record = { + "*": "ask", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "less *": "allow", + "ls *": "allow", + "tree *": "allow", + "pwd *": "allow", + "echo *": "allow", + "wc *": "allow", + "which *": "allow", + "type *": "allow", + "file *": "allow", + "diff *": "allow", + "du *": "allow", + "df *": "allow", + "date *": "allow", + "uname *": "allow", + "whoami *": "allow", + "printenv *": "allow", + "man *": "allow", + "grep *": "allow", + "rg *": "allow", + "ag *": "allow", + "uniq *": "allow", + "cut *": "allow", + "tr *": "allow", + "jq *": "allow", + "*>*": "ask", +} + +export const WORK_STYLE_CHOICES: WorkStyle[] = ["human-in-the-loop", "autonomous"] + +export const WORK_STYLE_PRESETS: Record = { + "human-in-the-loop": { + style: "human-in-the-loop", + config: { + terminal_command_display: "expanded", + auto_collapse_reasoning: false, + permission: { + "*": "ask", + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + grep: "allow", + glob: "allow", + list: "allow", + question: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + external_directory: "ask", + edit: "ask", + bash: BASH, + doom_loop: "ask", + }, + }, + settings: { + showTaskTimeline: true, + }, + }, + autonomous: { + style: "autonomous", + config: { + terminal_command_display: "collapsed", + auto_collapse_reasoning: true, + }, + settings: { + showTaskTimeline: false, + }, + }, +} + +export function getWorkStylePreset(style: WorkStyle): WorkStylePreset { + return WORK_STYLE_PRESETS[style] +} + +export function getInitialWorkStyle(hasSessions: boolean): WorkStyleState { + return hasSessions ? "skipped" : "unset" +} + +export function hasPermissionConfig(config: WorkStyleConfig): boolean { + return Object.keys(config.permission ?? {}).length > 0 +} + +function stripPermission(config: PermissionConfig): PermissionConfig { + const result: PermissionConfig = {} + for (const [key, rule] of Object.entries(config)) { + if (rule === null || rule === undefined) continue + if (typeof rule === "string") { + result[key] = rule + continue + } + const next: Record = {} + for (const [pattern, action] of Object.entries(rule)) { + if (action !== null && action !== undefined) next[pattern] = action + } + if (Object.keys(next).length > 0) result[key] = next as PermissionRule + } + return result +} + +export function buildWorkStyleApplyPlan(input: { + style: WorkStyle + config: WorkStyleConfig + settingDefault?: (key: keyof WorkStyleSettings) => boolean +}): WorkStyleApplyPlan { + const preset = getWorkStylePreset(input.style) + const next: WorkStyleConfig = {} + + if (preset.config.permission && !hasPermissionConfig(input.config)) { + next.permission = stripPermission(preset.config.permission) + } + if (input.config.terminal_command_display === undefined) { + next.terminal_command_display = preset.config.terminal_command_display + } + if (input.config.auto_collapse_reasoning === undefined) { + next.auto_collapse_reasoning = preset.config.auto_collapse_reasoning + } + + const settingDefault = input.settingDefault ?? (() => true) + return { + config: next, + settings: { + ...(settingDefault("showTaskTimeline") ? { showTaskTimeline: preset.settings.showTaskTimeline } : {}), + }, + } +} diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts index 92f127dc2c9..03efa67aaff 100644 --- a/packages/kilo-vscode/src/speech-to-text/capture.ts +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -313,7 +313,29 @@ async function listDshowAudioDevices(bin: string): Promise { export function parseDshowAudioDevices(raw: string): string[] { const devices = new Set() - for (const match of raw.matchAll(/"([^"]+)"\s+\(audio\)/g)) devices.add(match[1]!) + const state = { audio: false } + const legacy = /"([^"]+)"\s+\(audio\)/ + const quoted = /"([^"]+)"/ + const section = (line: string) => /DirectShow audio devices/i.test(line) + const other = (line: string) => /DirectShow (video|external) devices/i.test(line) + const alt = (line: string) => /\]\s+Alternative name\s+"/i.test(line) + for (const line of raw.split(/\r?\n/)) { + const match = legacy.exec(line) + if (match) devices.add(match[1]!) + + if (section(line)) { + state.audio = true + continue + } + if (other(line)) { + if (!state.audio) continue + state.audio = false + break + } + if (!state.audio || alt(line)) continue + const found = quoted.exec(line) + if (found) devices.add(found[1]!) + } return [...devices] } diff --git a/packages/kilo-vscode/src/speech-to-text/models.ts b/packages/kilo-vscode/src/speech-to-text/models.ts index be872d81c96..7eab1dfa181 100644 --- a/packages/kilo-vscode/src/speech-to-text/models.ts +++ b/packages/kilo-vscode/src/speech-to-text/models.ts @@ -6,6 +6,11 @@ export interface SpeechToTextModelDef { } const models: SpeechToTextModelDef[] = [ + { + id: "openai/whisper-large-v3-turbo", + label: "Whisper Large V3 Turbo", + provider: "OpenAI-compatible", + }, { id: "openai/gpt-4o-mini-transcribe", label: "GPT-4o Mini Transcribe", @@ -23,11 +28,6 @@ const models: SpeechToTextModelDef[] = [ label: "Whisper 1", provider: "OpenAI", }, - { - id: "openai/whisper-large-v3-turbo", - label: "Whisper Large V3 Turbo", - provider: "OpenAI-compatible", - }, { id: "openai/whisper-large-v3", label: "Whisper Large V3", diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 1936575c222..87ff464e251 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -37,6 +37,7 @@ export function buildWebviewHtml( scriptUri: vscode.Uri styleUri: vscode.Uri iconsBaseUri: vscode.Uri + workerUri: vscode.Uri title: string port?: number extraStyles?: string @@ -81,7 +82,7 @@ export function buildWebviewHtml(
        - + ` diff --git a/packages/kilo-vscode/src/webview-html-utils.ts b/packages/kilo-vscode/src/webview-html-utils.ts index afca8884703..ffe6e0b7168 100644 --- a/packages/kilo-vscode/src/webview-html-utils.ts +++ b/packages/kilo-vscode/src/webview-html-utils.ts @@ -26,6 +26,8 @@ export function buildCspString(cspSource: string, nonce: string, port?: number): "default-src 'none'", `style-src 'unsafe-inline' ${cspSource}`, `script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`, + // Allow the bundled Shiki highlighting worker (loaded as a webview resource). + `worker-src ${cspSource}`, `font-src ${cspSource}`, `connect-src ${cspSource} ${connectSrc}`, `img-src ${cspSource} data: https:`, diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts new file mode 100644 index 00000000000..fead3c81f38 --- /dev/null +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -0,0 +1,182 @@ +import AxeBuilder from "@axe-core/playwright" +import { expect, test, type Locator, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const RULES = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"] + +// Explicitly ratchet in repaired/stable workflows rather than making existing +// untriaged Storybook findings block unrelated webview changes. +const STORIES = [ + { id: "profile--not-logged-in", name: "Profile / not logged in" }, + { id: "profile--logged-in-personal", name: "Profile / personal account" }, + { id: "profile--logged-in", name: "Profile / organization account" }, + { id: "settings--providers-configure", name: "Settings / providers empty state" }, + { id: "marketplace--skills-tab-empty", name: "Marketplace / skills empty state" }, + { id: "marketplace--agents-tab-empty", name: "Marketplace / agents empty state" }, + { id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" }, +] + +function url(id: string) { + return `/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}` +} + +async function open(page: Page, id: string) { + await page.goto(url(id), { waitUntil: "load" }) + await page.waitForSelector("#storybook-root *", { state: "attached" }) +} + +async function scan(page: Page) { + const result = await new AxeBuilder({ page }).include("#storybook-root").withTags(RULES).analyze() + const details = result.violations + .map((item) => `${item.id}: ${item.help}\n${item.nodes.map((node) => ` ${node.target.join(" ")}`).join("\n")}`) + .join("\n") + + expect(result.violations, details).toEqual([]) +} + +async function reach(page: Page, target: Locator) { + for (let step = 0; step < 10; step++) { + await page.keyboard.press("Tab") + if (await target.evaluate((node) => node === document.activeElement)) return + } +} + +test.describe("webview accessibility ratchet", () => { + for (const story of STORIES) { + test(`${story.name} passes automated WCAG checks`, async ({ page }) => { + await open(page, story.id) + await scan(page) + }) + } + + test("Agent Manager keeps virtualized transcript fragments laid out", async ({ page }) => { + await open(page, "agentmanager--sidebar-search-open") + + const visibility = await page.locator("#storybook-root").evaluate((root) => { + const layout = document.createElement("div") + layout.className = "am-layout" + root.append(layout) + const names = ["assistant-message", "tool-trigger", "file", "code", "diff"] + const values = names.map((name) => { + const node = document.createElement("div") + node.dataset.component = name + layout.append(node) + return getComputedStyle(node).contentVisibility + }) + layout.remove() + return values + }) + + expect(visibility).toEqual(["visible", "visible", "visible", "visible", "visible"]) + }) + + test("Agent Manager avoids generated separator text in updating tool rows", async ({ page }) => { + await open(page, "agentmanager--sidebar-search-open") + + const content = await page.locator("#storybook-root").evaluate((root) => { + const layout = document.createElement("div") + layout.className = "am-layout" + const wrapper = document.createElement("div") + wrapper.dataset.component = "tool-part-wrapper" + wrapper.dataset.partType = "tool" + const collapsible = document.createElement("div") + collapsible.className = "tool-collapsible" + collapsible.dataset.component = "collapsible" + const title = document.createElement("span") + title.dataset.slot = "basic-tool-tool-title" + const subtitle = document.createElement("span") + subtitle.dataset.slot = "basic-tool-tool-subtitle" + collapsible.append(title, subtitle) + wrapper.append(collapsible) + layout.append(wrapper) + root.append(layout) + const value = getComputedStyle(subtitle, "::before").content + layout.remove() + return value + }) + + expect(content).toBe("none") + }) + + test("sidebar keeps transcript announcements while Agent Manager bounds them", async ({ page }) => { + await open(page, "chat--chat-view-with-messages") + await expect(page.locator(".message-list")).toHaveAttribute("role", "log") + await expect(page.locator(".message-list")).toHaveAttribute("aria-live", "polite") + + await open(page, "chat--chat-view-agent-manager-completed") + await expect(page.locator(".message-list")).not.toHaveAttribute("role") + await expect(page.locator(".message-list")).not.toHaveAttribute("aria-live") + await expect(page.locator('.sr-only[role="status"]')).toHaveAttribute("aria-live", "polite") + }) + + test("Profile login exposes a keyboard-operable named control", async ({ page }) => { + await open(page, "profile--not-logged-in") + + const login = page.getByRole("button", { name: "Login with Kilo Code" }) + await reach(page, login) + await expect(login).toBeFocused() + + await login.evaluate((node) => { + node.addEventListener("click", () => node.setAttribute("data-keyboard-activated", "true"), { once: true }) + }) + await page.keyboard.press("Enter") + await expect(login).toHaveAttribute("data-keyboard-activated", "true") + }) + + test("Agent Manager sidebar search filters and selects with the keyboard", async ({ page }) => { + await open(page, "agentmanager--sidebar-search-open") + + const trigger = page.getByRole("button", { name: "Search worktrees and sessions" }) + const input = page.getByPlaceholder("Search worktrees and sessions", { exact: true }) + const prompt = page.getByRole("textbox", { name: "Story prompt" }) + await expect(input).toBeFocused() + await expect(page.locator('[data-slot="list-header"]')).toHaveText(["SESSIONS", "LOCAL & WORKTREES"]) + + await input.fill("Render") + await expect(page.locator('[data-slot="sidebar-search-result"]').first()).toContainText( + "Render images in diff viewer", + ) + await input.fill("rndr img") + await expect(page.locator('[data-slot="sidebar-search-result"]').first()).toContainText( + "Render images in diff viewer", + ) + + await input.fill("main") + await expect(page.locator('[data-slot="sidebar-search-result"]').first()).toContainText("local") + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="sidebar-search-selection"]')).toHaveText("local") + await expect(prompt).toBeFocused() + + await trigger.click() + await input.fill("Build grouped") + await expect(page.getByText("Build grouped worktree search", { exact: true })).toBeVisible() + + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="sidebar-search-selection"]')).toHaveText("session:session-build") + await expect(input).toBeHidden() + await expect(prompt).toBeFocused() + + await trigger.click() + await input.fill("local indexing") + await expect(page.getByText("Investigate local indexing", { exact: true })).toBeVisible() + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="sidebar-search-selection"]')).toHaveText("session:session-local") + + await trigger.click() + await input.fill("does not exist") + await expect(page.locator('[data-slot="list-empty-state"]')).toBeVisible() + await page.keyboard.press("Escape") + await expect(prompt).toBeFocused() + + await trigger.click() + await expect(input).toBeFocused() + await page.locator(".am-section-label").click() + await expect(prompt).toBeFocused() + + await trigger.hover() + await expect( + page.getByText("Searches the local workspace, local sessions, worktrees, and their sessions", { exact: true }), + ).toBeVisible() + await expect(page.getByText("⌘F", { exact: true })).toBeVisible() + }) +}) diff --git a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts new file mode 100644 index 00000000000..17f22186c52 --- /dev/null +++ b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts @@ -0,0 +1,152 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const STORY_ID = "agentmanager--full-screen-diff-agent-edit-scroll" + +function storyUrl() { + return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}` +} + +async function disableAnimations(page: Page) { + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + `, + }) +} + +async function openStory(page: Page) { + await page.setViewportSize({ width: 800, height: 720 }) + await page.addInitScript(() => { + const win = window as Window & { nativeIntersectionObserver?: typeof IntersectionObserver } + win.nativeIntersectionObserver = window.IntersectionObserver + Object.defineProperty(window, "IntersectionObserver", { configurable: true, value: undefined, writable: true }) + }) + await page.goto(storyUrl(), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + const first = page.locator('[data-file-path="src/agent-edit.ts"] [data-component="diff"]') + await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(3_000) + return first +} + +test("preserves diff scroll position while an agent edit refreshes a file", async ({ page }) => { + const first = await openStory(page) + const scroller = page.locator(".am-review-diff") + const target = page.locator('[data-file-path="src/target.ts"]') + + // The initial tall diff rendered eagerly. Restore the real observer before + // moving it offscreen so an unfixed row remount takes the deferred path. + await page.evaluate(() => { + const win = window as Window & { nativeIntersectionObserver?: typeof IntersectionObserver } + Object.defineProperty(window, "IntersectionObserver", { + configurable: true, + value: win.nativeIntersectionObserver, + writable: true, + }) + }) + + await scroller.evaluate((el) => { + const target = el.querySelector('[data-file-path="src/target.ts"]') + if (!(target instanceof HTMLElement)) throw new Error("Target diff row not found") + el.scrollTop += target.getBoundingClientRect().top - el.getBoundingClientRect().top - 24 + }) + + const before = await scroller.evaluate((el) => el.scrollTop) + const top = await target.evaluate((el) => el.getBoundingClientRect().top) + expect(before).toBeGreaterThan(3_000) + + await page.getByRole("button", { name: "Apply agent edit" }).click() + await expect(page.getByTestId("agent-edit-version")).toHaveText("after") + await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(3_000) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + const after = await scroller.evaluate((el) => el.scrollTop) + const next = await target.evaluate((el) => el.getBoundingClientRect().top) + expect(after).toBeCloseTo(before, 0) + expect(next).toBeCloseTo(top, 0) +}) + +test("preserves scroll while adding and editing a review comment", async ({ page }) => { + await openStory(page) + const scroller = page.locator(".am-review-diff") + const target = page.locator('[data-file-path="src/target.ts"]') + + const align = async () => { + await scroller.evaluate((el) => { + const target = el.querySelector('[data-file-path="src/target.ts"]') + if (!(target instanceof HTMLElement)) throw new Error("Target diff row not found") + el.scrollTop += target.getBoundingClientRect().top - el.getBoundingClientRect().top - 24 + }) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + } + await align() + await align() + + const line = target.locator('[data-line="1"]').last() + await line.hover() + await target.locator("[data-utility-button]").last().click() + await expect(target.locator(".am-annotation-textarea")).toBeVisible() + await target.locator(".am-annotation-textarea").fill("Keep this stable") + const top = await target.evaluate((el) => el.getBoundingClientRect().top) + const before = await scroller.evaluate((el) => el.scrollTop) + + await page.getByRole("button", { name: "Apply agent edit" }).click() + await expect(page.getByTestId("agent-edit-version")).toHaveText("after") + await expect(target.locator(".am-annotation-textarea")).toHaveValue("Keep this stable") + await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeCloseTo(before, 0) + await expect.poll(async () => target.evaluate((el) => el.getBoundingClientRect().top)).toBeCloseTo(top, 0) + + await target.getByRole("button", { name: "Comment" }).click() + await expect(target.getByText("Keep this stable")).toBeVisible() + const saved = await scroller.evaluate((el) => el.scrollTop) + + await target.getByTitle("Edit").click() + await target.locator(".am-annotation-textarea").fill("Still stable") + await target.getByRole("button", { name: "Save" }).click() + await expect(target.getByText("Still stable")).toBeVisible() + await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeCloseTo(saved, 0) +}) + +test("resets virtual measurements and scroll when the review context changes", async ({ page }) => { + const first = await openStory(page) + const scroller = page.locator(".am-review-diff") + await page.evaluate(() => { + class IdleObserver { + readonly root = null + readonly rootMargin = "0px" + readonly thresholds = [] + + disconnect() {} + observe() {} + takeRecords() { + return [] + } + unobserve() {} + } + + Object.defineProperty(window, "IntersectionObserver", { + configurable: true, + value: IdleObserver, + writable: true, + }) + }) + + // Move away from the origin so the context switch must reset both the + // virtualizer's cached measurements and the shared scroller position. + await scroller.evaluate((el) => { + el.scrollTop = 2_000 + }) + await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBeGreaterThan(1_000) + + await page.getByRole("button", { name: "Switch review context" }).click() + await expect(page.getByTestId("review-context")).toHaveText("changed-context") + await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBe(1_200) + await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0) +}) diff --git a/packages/kilo-vscode/tests/history-accessibility.spec.ts b/packages/kilo-vscode/tests/history-accessibility.spec.ts new file mode 100644 index 00000000000..554dd3ee00d --- /dev/null +++ b/packages/kilo-vscode/tests/history-accessibility.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" + +function story(page: Page, id: string) { + return page.goto(`/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) +} + +test.describe("history session accessibility", () => { + test("opens a selected session through a standalone named row control", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const row = page.getByRole("button", { name: /Refactor authentication module.*Current session/ }) + await expect(row).toHaveAttribute("data-selected", "true") + await expect(page.locator('[data-slot="list-item"] button')).toHaveCount(0) + + await row.focus() + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="selected-session"]')).toHaveText("s1") + }) + + test("announces the active filtered result before Enter opens it", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const search = page.getByPlaceholder("Search sessions...") + await search.fill("screenshot") + await expect(search).toBeFocused() + await expect(page.locator('[data-slot="session-list-status"]')).toHaveText("Add screenshot test coverage") + + await page.keyboard.press("Enter") + await expect(page.locator('[data-slot="selected-session"]')).toHaveText("s2") + }) + + test("focuses row actions without opening a session during rename", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const selected = page.locator('[data-slot="selected-session"]') + const rename = page.getByRole("button", { name: "Rename: Add screenshot test coverage" }) + await rename.focus() + await expect(rename).toBeFocused() + await page.keyboard.press("Enter") + const input = page.getByRole("textbox", { name: "Rename" }) + await expect(input).toBeFocused() + await expect(selected).toBeEmpty() + + await input.fill("Updated screenshot test coverage") + await page.keyboard.press("Enter") + await expect(input).toBeHidden() + await expect(selected).toBeEmpty() + + const renamed = page.getByRole("button", { name: "Rename: Add screenshot test coverage" }) + await renamed.focus() + await page.keyboard.press("Enter") + await expect(input).toBeFocused() + await page.keyboard.press("Escape") + await expect(input).toBeHidden() + await expect(selected).toBeEmpty() + }) + + test("deleting does not open a session and restores focus after cancel", async ({ page }) => { + await story(page, "history-sessionlist--with-items") + + const selected = page.locator('[data-slot="selected-session"]') + const remove = page.getByRole("button", { name: "Delete session: Add screenshot test coverage" }) + await remove.focus() + await expect(remove).toBeFocused() + await page.keyboard.press("Enter") + await expect(page.getByRole("dialog", { name: "Delete session" })).toBeVisible() + await expect(selected).toBeEmpty() + + await page.getByRole("button", { name: "Cancel" }).click() + await expect(page.getByRole("dialog", { name: "Delete session" })).toBeHidden() + await expect(remove).toBeFocused() + await expect(selected).toBeEmpty() + }) + + test("exposes Local and Cloud as keyboard navigable selected tabs", async ({ page }) => { + await story(page, "history-sessionlist--sources") + + const local = page.getByRole("tab", { name: "Local" }) + const cloud = page.getByRole("tab", { name: "Cloud" }) + await expect(page.getByRole("tablist", { name: "History source" })).toBeVisible() + await expect(local).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Local" })).toBeVisible() + + await local.focus() + await page.keyboard.press("ArrowRight") + await expect(cloud).toBeFocused() + await expect(local).toHaveAttribute("aria-selected", "true") + await page.keyboard.press("Enter") + await expect(cloud).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Cloud" })).toBeVisible() + await expect(page.getByPlaceholder("Search sessions...")).toBeFocused() + + await cloud.focus() + await page.keyboard.press("ArrowLeft") + await expect(local).toBeFocused() + await page.keyboard.press("Enter") + await expect(local).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Local" })).toBeVisible() + }) +}) diff --git a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts index 3b5b35beeae..eb4830ec6c0 100644 --- a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts +++ b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts @@ -10,15 +10,21 @@ if (IS_DARWIN) { const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" const STORY_ID = "settings--indexing-provider-blur-race" +const KILO_STORY_ID = "settings--indexing-kilo-model-preset" +const KILO_LOADING_STORY_ID = "settings--indexing-kilo-catalog-loading" +const SCOPE_STORY_ID = "settings--indexing-scope-switch" type Saved = { provider?: string + model?: string | null + dimension?: number | null openai?: { apiKey?: string } gemini?: { apiKey?: string } + qdrant?: { url?: string; apiKey?: string } } -function storyUrl() { - return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}` +function storyUrl(id = STORY_ID) { + return `/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}` } async function disableAnimations(page: Page) { @@ -34,6 +40,10 @@ async function disableAnimations(page: Page) { }) } +function field(page: Page, title: string) { + return page.locator('[data-slot="settings-row"]', { hasText: title }).locator("input") +} + test("provider switch writes to selected provider bucket", async ({ page }) => { await page.setViewportSize({ width: 420, height: 720 }) await page.goto(storyUrl(), { waitUntil: "load" }) @@ -58,6 +68,97 @@ test("provider switch writes to selected provider bucket", async ({ page }) => { const cfg = JSON.parse(text) as Saved expect(cfg.provider).toBe("gemini") + expect(cfg.model).toBeNull() + expect(cfg.dimension).toBeNull() expect(cfg.openai?.apiKey ?? "").toBe("") expect(cfg.gemini?.apiKey ?? "").toBe("") + + const model = field(page, "Embedding model").first() + await expect(model).toHaveValue("") + await expect(model).toHaveAttribute("placeholder", "Enter model ID") +}) + +test("scope switching preserves raw overrides and commits blur to the original scope", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(storyUrl(SCOPE_STORY_ID), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + await expect(page.locator('[data-slot="settings-row"] [data-component="tag"]')).toHaveCount(0) + + const url = field(page, "Qdrant URL").first() + await url.fill("http://edited-global:6333") + await page.getByRole("button", { name: "Local", exact: true }).click() + + const global = page.getByTestId("indexing-global-save") + await expect + .poll(async () => { + const cfg = JSON.parse(((await global.textContent()) ?? "{}").trim()) as Saved + return cfg.qdrant?.url + }) + .toBe("http://edited-global:6333") + + const project = JSON.parse(((await page.getByTestId("indexing-project-save").textContent()) ?? "{}").trim()) as Saved + expect(project.qdrant?.url).toBeUndefined() + + await expect(field(page, "Embedding model").first()).toHaveValue("") + await expect(url).toHaveValue("http://edited-global:6333") + const urlRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant URL" }) + const keyRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant API key" }) + const modelRow = page.locator('[data-slot="settings-row"]', { hasText: "Embedding model" }) + const tuningRow = page.locator('[data-slot="settings-row"]', { hasText: "Search max results" }) + await expect(urlRow.locator('[data-component="tag"]')).toHaveText("Global") + await expect(keyRow.locator('[data-component="tag"]')).toHaveText("Local") + await expect(modelRow.locator('[data-component="tag"]')).toHaveText("Local") + await expect(tuningRow.locator('[data-component="tag"]')).toHaveText("Default") +}) + +test("Kilo exposes only supported embedding model presets", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(storyUrl(KILO_STORY_ID), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + await expect(page.getByText("Kilo model preset", { exact: true })).toBeVisible() + await expect(page.getByText("Embedding model", { exact: true })).toHaveCount(0) + await expect(page.getByText("Vector dimension", { exact: true })).toBeVisible() + + const preset = page.locator('[data-component="select"] [data-slot="select-select-trigger"]').nth(1) + await expect(preset).toContainText("Provider Model") + + const dimension = field(page, "Vector dimension").first() + await expect(dimension).toHaveValue("") + + await preset.click() + await page.locator('[data-slot="select-select-item-label"]', { hasText: "Provider Compact" }).click() + await expect(preset).toContainText("Provider Compact") +}) + +test("enabling Kilo before its catalog loads does not store an empty model", async ({ page }) => { + const saved = page.getByTestId("indexing-kilo-loading-save") + const cfg = async () => JSON.parse(((await saved.textContent()) ?? "{}").trim()) as Saved + const verify = async () => { + await expect.poll(async () => (await cfg()).provider).toBe("kilo") + expect((await cfg()).model).toBeNull() + expect((await cfg()).dimension).toBeNull() + } + + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + await page.getByRole("button", { name: "Local", exact: true }).click() + await page + .locator('[data-slot="settings-row"]', { hasText: "Enable for this project" }) + .locator('[data-slot="switch-control"]') + .click() + await verify() + + await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" }) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + await page + .locator('[data-slot="settings-row"]', { hasText: "Enable globally" }) + .locator('[data-slot="switch-control"]') + .click() + await verify() }) diff --git a/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts new file mode 100644 index 00000000000..afc75f77b45 --- /dev/null +++ b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts @@ -0,0 +1,208 @@ +import { expect, test } from "@playwright/test" +import { build } from "esbuild" +import { fileURLToPath } from "node:url" + +const source = fileURLToPath(new URL("../../ui/src/kilocode/markdown-incremental-dom.ts", import.meta.url)) +const bundle = await build({ + stdin: { + contents: ` + import { createIncrementalMarkdown } from ${JSON.stringify(source)} + globalThis.createIncrementalMarkdown = createIncrementalMarkdown + `, + resolveDir: fileURLToPath(new URL(".", import.meta.url)), + }, + bundle: true, + format: "iife", + platform: "browser", + write: false, +}) +const script = bundle.outputFiles[0]!.text + +test.beforeEach(async ({ page }) => { + await page.goto("about:blank") + await page.addScriptTag({ content: script }) +}) + +test("keeps completed Markdown nodes while replacing only the live tail", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const heading = { key: "0", hash: "heading", html: "

        Heading

        ", mode: "full" as const } + const tail = { key: "1", hash: "tail-a", html: "

        Tail A

        ", mode: "live" as const } + + renderer.update(container, [heading, tail], labels) + const stable = container.children[0] + const previous = container.children[1] + renderer.update(container, [heading, { ...tail, hash: "tail-b", html: "

        Tail B

        " }], labels) + + return { + stable: container.children[0] === stable, + replaced: container.children[1] !== previous, + tags: Array.from(container.children).map((child) => child.tagName), + text: container.textContent, + } + }) + + expect(result).toEqual({ stable: true, replaced: true, tags: ["H2", "P"], text: "HeadingTail B" }) +}) + +test("promotes an unchanged tail and appends the next block without wrappers", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const heading = { key: "0", hash: "heading", html: "

        Heading

        ", mode: "full" as const } + const paragraph = { key: "1", hash: "paragraph", html: "

        Stable

        ", mode: "live" as const } + + renderer.update(container, [heading, paragraph], labels) + const stable = container.children[1] + renderer.update( + container, + [ + heading, + { ...paragraph, mode: "full" }, + { key: "2", hash: "tail", html: "
        • Next
        ", mode: "live" as const }, + ], + labels, + ) + + return { + stable: container.children[1] === stable, + tags: Array.from(container.children).map((child) => child.tagName), + } + }) + + expect(result).toEqual({ stable: true, tags: ["H2", "P", "UL"] }) +}) + +test("runs streaming hooks only when incremental rendering handles the update", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: ( + decorate: () => void, + hooks: { + cancel: () => void + ready: (container: HTMLDivElement, labels: { copy: string; copied: string }, context: string) => void + }, + ) => { + render: ( + streaming: boolean, + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + context: string, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const calls: string[] = [] + const renderer = create(() => {}, { + cancel: () => calls.push("cancel"), + ready: (_container, _labels, context) => calls.push(context), + }) + const stable = { key: "0", hash: "stable", html: "

        Stable

        ", mode: "full" as const } + const tail = { key: "1", hash: "tail", html: "

        Tail

        ", mode: "live" as const } + + const completed = renderer.render(false, container, [stable, tail], labels, "ready") + const unsupported = renderer.render(true, container, [tail], labels, "unsupported") + const streaming = renderer.render(true, container, [stable, tail], labels, "ready") + return { calls, completed, streaming, unsupported } + }) + + expect(result).toEqual({ calls: ["cancel", "ready"], completed: false, streaming: true, unsupported: false }) +}) + +test("falls back when there is no stable prefix", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + const renderer = create(() => {}) + const first = { key: "0", hash: "first", html: "

        First

        ", mode: "live" as const } + const second = { key: "1", hash: "second", html: "

        Second

        ", mode: "live" as const } + + return { + multiple: renderer.update(container, [first, second], labels), + single: renderer.update(container, [first], labels), + } + }) + + expect(result).toEqual({ multiple: false, single: false }) +}) + +test("rebuilds safely when a Markdown boundary disappears", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const blocks = [ + { key: "0", hash: "stable", html: "

        Stable

        ", mode: "full" as const }, + { key: "1", hash: "middle", html: "

        Middle

        ", mode: "full" as const }, + { key: "2", hash: "tail", html: "

        Tail

        ", mode: "live" as const }, + ] + + renderer.update(container, blocks, labels) + const comments = Array.from(container.childNodes).filter((node) => node.nodeType === Node.COMMENT_NODE) + comments.at(-1)?.remove() + const updated = renderer.update(container, [blocks[0]!, { ...blocks[1]!, mode: "live" }], labels) + + return { + updated, + tags: Array.from(container.children).map((child) => child.tagName), + text: container.textContent, + } + }) + + expect(result).toEqual({ updated: true, tags: ["P", "P"], text: "StableMiddle" }) +}) diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts new file mode 100644 index 00000000000..c818e6b9a34 --- /dev/null +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -0,0 +1,242 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" + +function story(id: string) { + return `/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}` +} + +async function load(page: Page, id: string) { + await page.goto(story(id), { waitUntil: "load" }) + await page.waitForSelector("#storybook-root *", { state: "attached" }) +} + +test("model selector exposes combobox relationships and active option movement", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + const tree = page.getByRole("tree", { name: "Review model" }) + const alpha = page.getByRole("treeitem", { name: "Alpha" }) + const bravo = page.getByRole("treeitem", { name: "Bravo" }) + + await expect(combobox).toBeFocused() + await expect(combobox).toHaveAttribute("aria-expanded", "true") + await expect(combobox).toHaveAttribute("aria-haspopup", "tree") + await expect(combobox).toHaveAttribute("aria-controls", await tree.getAttribute("id")) + await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id")) + await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.") + await expect(alpha.locator("button")).toHaveCount(0) + await expect(page.getByRole("button", { name: "Add to favorites: Alpha" })).toBeVisible() + + await combobox.press("ArrowDown") + await expect(combobox).toBeFocused() + await expect(combobox).toHaveAttribute("aria-activedescendant", await bravo.getAttribute("id")) + + const expand = page.getByRole("button", { name: "Expand" }) + const controls = await expand.getAttribute("aria-controls") + const preview = page.locator(`[id="${controls}"]`) + await expect(expand).toHaveAttribute("aria-expanded", "false") + await expect(preview).toHaveAttribute("aria-hidden", "true") + await expect(preview.locator("button, a, [tabindex]")).toHaveCount(0) + await expand.click() + const collapse = page.getByRole("button", { name: "Collapse", exact: true }) + await expect(collapse).toHaveAttribute("aria-controls", controls!) + await expect(collapse).toHaveAttribute("aria-expanded", "true") + await expect(preview).toHaveAttribute("aria-hidden", "false") + await expect(preview.getByRole("button", { name: "Add to favorites" })).toBeVisible() +}) + +test("typing a provider initial moves the active descendant to matching results", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + await combobox.fill("N") + + const nova = page.getByRole("treeitem", { name: "Nova" }) + await expect(nova).toBeVisible() + await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id")) + await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveAttribute("aria-expanded", "true") +}) + +test("provider groups collapse, expand, and skip their model rows", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + const kilo = page.getByRole("treeitem", { name: "Kilo" }) + const nvidia = page.getByRole("treeitem", { name: "NVIDIA" }) + + await combobox.press("ArrowDown") + await combobox.press("ArrowLeft") + await expect(combobox).toHaveAttribute("aria-activedescendant", await kilo.getAttribute("id")) + await combobox.press("ArrowLeft") + await expect(kilo).toHaveAttribute("aria-expanded", "false") + await expect(page.getByRole("treeitem", { name: "Bravo" })).toBeHidden() + + await combobox.press("ArrowDown") + await expect(combobox).toHaveAttribute("aria-activedescendant", await nvidia.getAttribute("id")) + await combobox.press("ArrowLeft") + await expect(nvidia).toHaveAttribute("aria-expanded", "false") + await combobox.press("ArrowRight") + await expect(nvidia).toHaveAttribute("aria-expanded", "true") + await combobox.press("ArrowRight") + await expect(combobox).toHaveAttribute( + "aria-activedescendant", + await page.getByRole("treeitem", { name: "Nemotron" }).getAttribute("id"), + ) +}) + +test("active descendant always identifies a visible tree item", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + const active = async () => { + await expect.poll(() => combobox.getAttribute("aria-activedescendant")).toBeTruthy() + const id = await combobox.getAttribute("aria-activedescendant") + await expect(page.locator(`[id="${id}"]`)).toBeVisible() + } + + await active() + await combobox.press("ArrowDown") + await active() + await combobox.press("ArrowLeft") + await active() + await combobox.press("ArrowRight") + await active() + await combobox.fill("N") + await active() + await combobox.press("ArrowLeft") + await combobox.press("ArrowDown") + await combobox.press("ArrowLeft") + await active() + await combobox.fill("no matching model") + await active() +}) + +test("expanded preview waits for explicit pointer selection", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + await page.getByRole("button", { name: "Expand" }).click() + await page.getByRole("treeitem", { name: "Bravo" }).click() + + await expect(page.getByTestId("model-selector-value")).toHaveText("alpha") + await expect(page.getByRole("combobox", { name: "Review model: Alpha. Search models" })).toBeVisible() + await expect(page.locator(".model-selector-preview")).toContainText("Bravo") + + await page.getByRole("button", { name: "Select: Bravo" }).click() + await expect(page.getByTestId("model-selector-value")).toHaveText("bravo") +}) + +test("selected favorite remains selected when its duplicate group is collapsed", async ({ page }) => { + await load(page, "shared--model-selector-selected-favorite") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + const alpha = page.getByRole("treeitem", { name: "Alpha" }) + const favorites = page.getByRole("treeitem", { name: "Favorites" }) + await expect(alpha.first()).toHaveAttribute("aria-selected", "true") + await expect.poll(() => favorites.evaluate((el) => getComputedStyle(el).borderTopStyle)).toBe("solid") + + await favorites.click() + await expect(alpha).toHaveCount(1) + await expect(alpha).toHaveAttribute("aria-selected", "true") + await expect(combobox).toHaveAttribute("aria-activedescendant", await favorites.getAttribute("id")) +}) + +test("Enter selects the active option and Escape restores selector focus", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + await combobox.press("ArrowDown") + await combobox.press("Enter") + + const trigger = page.getByRole("button", { name: "Review model: Bravo" }) + await expect(page.getByTestId("model-selector-value")).toHaveText("bravo") + await expect(trigger).toBeFocused() + + await trigger.click() + const reopened = page.getByRole("combobox", { name: "Review model: Bravo. Search models" }) + await reopened.press("ArrowDown") + await reopened.press("Escape") + + await expect(page.getByTestId("model-selector-value")).toHaveText("bravo") + await expect(trigger).toBeFocused() +}) + +test("no-match search announces the empty result and can choose the default option", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) + await combobox.fill("no matching model") + + await expect(page.locator(".model-selector-empty")).toHaveText("No model results") + const clear = page.getByRole("treeitem", { name: "Use default model" }) + await expect(combobox).toHaveAttribute("aria-activedescendant", await clear.getAttribute("id")) + await combobox.press("Enter") + + await expect(page.getByTestId("model-selector-value")).toHaveText("default") + await expect(page.getByRole("button", { name: "Review model: Use default model" })).toBeFocused() +}) + +test("settings and mode editing expose distinct model field purposes", async ({ page }) => { + await load(page, "settings--models-accessible-labels") + + await expect(page.getByRole("button", { name: "Default Model: Not set" })).toHaveAccessibleDescription( + "Primary model for conversations", + ) + await expect(page.getByRole("button", { name: "Small Model: Not set" })).toHaveAccessibleDescription( + /Lightweight model/, + ) + await expect(page.getByRole("button", { name: "Subagent Model: Not set" })).toHaveAccessibleDescription( + /Default model and reasoning effort/, + ) + await expect(page.getByRole("button", { name: "Autocomplete model: Not set" })).toHaveAccessibleDescription( + "Select the model used for inline code completions", + ) + await expect(page.getByRole("button", { name: "Model per Mode: code: Not set" })).toHaveAccessibleDescription( + /Override the default model for specific modes/, + ) + + await load(page, "settings--models-speech-to-text") + const speech = page.getByRole("button", { name: "Speech to Text Model: Chirp 3" }) + await expect(speech).toBeEnabled() + await speech.click() + await page.getByRole("option", { name: "GPT-4o Mini Transcribe (OpenAI)" }).click() + await expect(page.getByRole("button", { name: "Speech to Text Model: GPT-4o Mini Transcribe" })).toBeVisible() + + await load(page, "settings--mode-edit-export") + await expect(page.getByRole("button", { name: /Model Override:/ })).toHaveAccessibleDescription( + "Override the default model for this agent", + ) +}) + +test("chat picker Escape returns focus to the prompt", async ({ page }) => { + await load(page, "prompt-input--default-420") + + await page.getByRole("button", { name: /^Select model:/ }).click() + const combobox = page.getByRole("combobox", { name: /^Select model:.*Search models$/ }) + await expect(combobox).toBeFocused() + await combobox.press("Escape") + + await expect(page.locator("textarea.prompt-input")).toBeFocused() +}) + +test("slash model picker Escape returns focus to the prompt", async ({ page }) => { + await load(page, "prompt-input--default-420") + + const prompt = page.locator("textarea.prompt-input") + await prompt.evaluate((el) => el.setAttribute("aria-disabled", "false")) + await prompt.fill("/model") + await prompt.press("Enter") + const combobox = page.getByRole("combobox", { name: /^Select model:.*Search models$/ }) + await expect(combobox).toBeFocused() + await combobox.press("Escape") + + await expect(prompt).toBeFocused() +}) diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index c4f0fc8039c..ba0cfb51cf9 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.3.8", + "version": "7.3.54", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/permission-diff.spec.ts b/packages/kilo-vscode/tests/permission-diff.spec.ts new file mode 100644 index 00000000000..15697a0b95d --- /dev/null +++ b/packages/kilo-vscode/tests/permission-diff.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from "@playwright/test" + +const STORY_ID = "composite-webview--permission-dock-edit" +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" + +test("edit approval diff shows line numbers in compact viewer", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + + const number = page.locator('[data-slot="permission-diff-content"] [data-column-number]').first() + await expect(number).toBeVisible() +}) diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts new file mode 100644 index 00000000000..b655b29e610 --- /dev/null +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -0,0 +1,74 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const NAMES = [ + "Models", + "Providers", + "Agent Behaviour", + "Auto-Approve", + "Browser", + "Checkpoints", + "Display", + "Autocomplete", + "Notifications", + "Context", + "Commit Message", + "Experimental", + "Language", + "About Kilo Code", +] + +function story(page: Page) { + return page.goto(`/iframe.html?id=settings--settings-panel&viewMode=story&globals=${GLOBALS}`, { + waitUntil: "load", + }) +} + +test.describe("settings tab accessibility", () => { + test("exposes named tabs and selected state in the compact sidebar", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await story(page) + + const tabs = page.getByRole("tab") + await expect(tabs).toHaveCount(NAMES.length) + await expect(page.getByRole("tab", { name: "Sandboxing" })).toHaveCount(0) + for (const name of NAMES) { + await expect(page.getByRole("tab", { name, exact: true })).toBeVisible() + } + + const models = page.getByRole("tab", { name: "Models" }) + const providers = page.getByRole("tab", { name: "Providers" }) + await expect(models).toHaveAttribute("aria-selected", "true") + await expect(providers).toHaveAttribute("aria-selected", "false") + await expect(page.getByRole("tabpanel", { name: "Models" })).toBeVisible() + + await models.focus() + await page.keyboard.press("ArrowDown") + await expect(providers).toBeFocused() + await expect(providers).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Providers" })).toBeVisible() + + await page.keyboard.press("ArrowUp") + await expect(models).toBeFocused() + await expect(models).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Models" })).toBeVisible() + }) + + test("shows sandboxing controls when the feature flag and experiment are enabled", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(`/iframe.html?id=settings--sandboxing-panel&viewMode=story&globals=${GLOBALS}`, { + waitUntil: "load", + }) + + const tab = page.getByRole("tab", { name: "Sandboxing" }) + await expect(tab).toBeVisible() + await expect(tab).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Sandboxing" })).toBeVisible() + const network = page.getByRole("switch", { name: "Restrict Network Access" }) + await expect(network).toHaveAccessibleDescription(/Local MCP servers and plugin hooks run outside this restriction/) + await expect(network).toBeChecked() + await page.locator('[data-slot="switch-control"]').click() + await expect(network).not.toBeChecked() + await expect(page.locator(".settings-save-bar")).toBeVisible() + }) +}) diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index b8f21107116..ee234232d41 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -46,6 +46,10 @@ const mockVscode = { version: "1.90.0", workspace: { workspaceFolders: [{ uri: { fsPath: "/repo" } }], + textDocuments: [] as Array, + onDidOpenTextDocument: () => ({ dispose: noop }), + onDidChangeTextDocument: () => ({ dispose: noop }), + onDidCloseTextDocument: () => ({ dispose: noop }), getConfiguration: () => ({ get: (_key: string, value?: T) => value, update: async () => {}, @@ -119,6 +123,12 @@ const mockVscode = { Workspace: 2, WorkspaceFolder: 3, }, + FileType: { + Unknown: 0, + File: 1, + Directory: 2, + SymbolicLink: 64, + }, TabInputText: class { constructor(public uri: { scheme: string; fsPath: string }) {} }, @@ -134,6 +144,13 @@ const mockVscode = { public end: { line: number; character: number }, ) {} }, + InlineCompletionItem: class { + constructor( + public insertText: string, + public range?: unknown, + public command?: unknown, + ) {} + }, Disposable: class { constructor(private callback: () => void = noop) {} dispose() { diff --git a/packages/kilo-vscode/tests/unit/abort-state.test.ts b/packages/kilo-vscode/tests/unit/abort-state.test.ts new file mode 100644 index 00000000000..1b37396b2da --- /dev/null +++ b/packages/kilo-vscode/tests/unit/abort-state.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test" +import { createAbortState } from "../../webview-ui/src/context/abort-state" + +describe("pending prompt abort state", () => { + it("waits for the same submission to become cancellable", () => { + const aborts = createAbortState() + + expect(aborts.request("draft", "idle", "message")).toBe(false) + expect(aborts.update("draft", "busy")).toBe(true) + expect(aborts.update("draft", "busy")).toBe(false) + expect(aborts.update("draft", "idle")).toBe(false) + expect(aborts.update("draft", "busy")).toBe(false) + }) + + it("moves pending cancellation to the created session", () => { + const aborts = createAbortState() + + expect(aborts.request("draft", "idle", "message")).toBe(false) + aborts.move("draft", "session") + + expect(aborts.update("draft", "busy")).toBe(false) + expect(aborts.update("session", "busy")).toBe(true) + }) + + it("does not retain cancellation after an idle terminal status", () => { + const aborts = createAbortState() + + expect(aborts.request("session", "idle", "message")).toBe(false) + expect(aborts.update("session", "idle")).toBe(false) + expect(aborts.update("session", "busy")).toBe(false) + }) + + it("allows retrying an abort while the session remains active", () => { + const aborts = createAbortState() + + expect(aborts.request("session", "busy")).toBe(true) + expect(aborts.request("session", "busy")).toBe(true) + expect(aborts.update("session", "idle")).toBe(false) + expect(aborts.request("session", "busy")).toBe(true) + }) + + it("clears cancellation when the matching submission finishes", () => { + const aborts = createAbortState() + + expect(aborts.request("session", "idle", "message")).toBe(false) + aborts.finish("other") + expect(aborts.update("session", "busy")).toBe(true) + + expect(aborts.update("session", "idle")).toBe(false) + expect(aborts.request("session", "idle", "message")).toBe(false) + aborts.finish("message") + expect(aborts.update("session", "busy")).toBe(false) + }) + + it("preserves active destination state during duplicate draft migration", () => { + const aborts = createAbortState() + + expect(aborts.request("draft", "idle", "message")).toBe(false) + expect(aborts.request("session", "busy")).toBe(true) + aborts.move("draft", "session") + + expect(aborts.request("session", "busy")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index 126333e6a4e..e971a1211b8 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { abortSession } from "../../src/kilo-provider/abort" +import { abortSession, SessionAbort } from "../../src/kilo-provider/abort" function client(calls: unknown[], fail = false) { return { @@ -14,6 +14,53 @@ function client(calls: unknown[], fail = false) { } as unknown as KiloClient } +describe("SessionAbort", () => { + it("stops the active owner and current mapped directory", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(true) + expect(calls).toEqual([ + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo" }, + opts: { throwOnError: true }, + }, + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo/worktree" }, + opts: { throwOnError: true }, + }, + ]) + }) + + it("forgets an owner when its instance becomes idle", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + aborts.observe("session_1", "idle", "/repo") + + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(false) + expect(calls).toEqual([ + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo/worktree" }, + opts: { throwOnError: true }, + }, + ]) + }) + + it("deduplicates equivalent directory paths", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo/worktree") + + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toBe(true) + expect(calls).toHaveLength(1) + }) +}) + describe("abortSession", () => { it("calls session.abort with the session id and directory", async () => { const calls: unknown[] = [] diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index fc4de9e19f8..6eaf459b1f8 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -23,20 +23,22 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), - path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx"), - path.join(ROOT, "webview-ui/agent-manager/MarkdownDiffView.tsx"), - path.join(ROOT, "webview-ui/agent-manager/MarkdownAnnotationLayer.tsx"), - path.join(ROOT, "webview-ui/agent-manager/markdown-comment-ranges.ts"), - path.join(ROOT, "webview-ui/agent-manager/DiffEndMarker.tsx"), - path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"), - path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"), - path.join(ROOT, "webview-ui/agent-manager/review-annotation-speech.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/MarkdownDiffView.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/MarkdownAnnotationLayer.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/markdown-comment-ranges.ts"), + path.join(ROOT, "webview-ui/diff-viewer/DiffEndMarker.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/FileTree.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/review-annotations.ts"), + path.join(ROOT, "webview-ui/diff-viewer/review-annotation-speech.tsx"), path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), - path.join(ROOT, "webview-ui/agent-manager/CurrentTabsMenu.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarToggleButton.tsx"), + path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/SortableTerminalTab.tsx"), @@ -163,6 +165,16 @@ describe("Agent Manager Provider Messages", () => { expect(body).toContain("agentManager.sessionAdded") }) + it("warms MCP before creating every new worktree session", () => { + const body = getMethodBody("createSessionInWorktree") + const warmup = body.indexOf("startSession(") + const create = body.indexOf("client.session.create(") + + expect(warmup).toBeGreaterThanOrEqual(0) + expect(create).toBeGreaterThanOrEqual(0) + expect(warmup).toBeLessThan(create) + }) + it("state-mutating messages wait for state initialization", () => { const body = getMethodBody("shouldWaitForState") const messages = [ @@ -200,6 +212,27 @@ describe("Agent Manager Provider Messages", () => { expect(body).toContain("await this.terminalRouter.dispose()") expect(body).not.toContain("void this.terminalRouter.dispose()") }) + + it("clears remote session registrations when the panel closes", () => { + const body = getMethodBody("attachPanel") + expect(body).toContain('this.connectionService.unregisterFocused("agent-manager")') + expect(body).toContain('this.connectionService.registerOpen("agent-manager", [])') + expect(body).toContain("this.activeSessionId = undefined") + }) + + it("reports all open Agent Manager sessions for remote control", () => { + const body = fs.readFileSync(TSX_FILE, "utf-8") + expect(body).toContain("reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending)") + }) +}) + +describe("Agent Manager Model Picker", () => { + it("discloses data collection for free models in compare picker", () => { + const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), "utf-8") + + expect(source).toContain("model.tag.dataCollected") + expect(source).toContain("model.isFree") + }) }) // --------------------------------------------------------------------------- @@ -634,6 +667,10 @@ const VSCODE_ALLOWED: Record = { "run/task.ts": { note: "vscode adapter for Agent Manager run scripts", }, + // Reads terminal.integrated.* and editor.font* config for xterm font settings + "terminal-font.ts": { + note: "vscode config reader for integrated terminal font settings", + }, } /** @@ -761,6 +798,8 @@ describe("Agent Manager — provider chain parity with sidebar", () => { // which the agent manager already includes in its provider chain. "LanguageProvider", "DataProvider", + // Work-style onboarding is injected only into the sidebar empty state. + "WorkStyleProvider", ] it("agent manager includes all context providers from sidebar App.tsx", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts index 5a12d4aaa39..381ac518a38 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from "bun:test" -import { mergeWorktreeDiffs } from "../../webview-ui/agent-manager/diff-state" +import { mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state" import { EXTREME_DIFF_CHANGED_LINES, allOpenFiles, expandableOpenFiles, initialOpenFiles, + isDiffExpandable, + sanitizeOpenFiles, + shouldVirtualizeDiff, toggleOpenFiles, -} from "../../webview-ui/agent-manager/diff-open-policy" +} from "../../webview-ui/diff-viewer/diff-open-policy" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(overrides: Partial): WorktreeFileDiff { @@ -26,16 +29,41 @@ function diff(overrides: Partial): WorktreeFileDiff { } describe("agent manager diff state", () => { - it("preserves loaded detail when summary metadata is unchanged", () => { - const prev = [diff({ summarized: false, before: "old\n", after: "new\n" })] + it("preserves loaded detail and patch when summary metadata is unchanged", () => { + const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })] const next = [diff({ summarized: true })] const result = mergeWorktreeDiffs(prev, next) - expect(result.diffs).toEqual([diff({ summarized: false, before: "old\n", after: "new\n" })]) + expect(result.diffs).toEqual([ + diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" }), + ]) expect(result.diffs[0]).toBe(prev[0]) expect(result.stale.size).toBe(0) }) + it("preserves loaded image data when summary metadata is unchanged", () => { + const image = { + before: { mime: "image/png", bytes: 3, data: "b2xk" }, + after: { mime: "image/png", bytes: 3, data: "bmV3" }, + } + const prev = [diff({ file: "asset.png", kind: "image", summarized: false, image })] + const next = [diff({ file: "asset.png", kind: "image", summarized: true })] + const result = mergeWorktreeDiffs(prev, next) + + expect(result.diffs[0]).toBe(prev[0]) + expect(result.diffs[0]?.image).toBe(image) + expect(result.stale.size).toBe(0) + }) + + it("replaces detailed content when patch anchors change", () => { + const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })] + const next = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -100 +100 @@\n-old\n+new\n" })] + const result = mergeWorktreeDiffs(prev, next) + + expect(result.diffs[0]).toBe(next[0]) + expect(result.diffs[0]?.patch).toContain("@@ -100 +100 @@") + }) + it("preserves cached content and marks stale when summary metadata changes", () => { const prev = [diff({ summarized: false, before: "old\n", after: "new\n", additions: 1 })] const next = [diff({ summarized: true, additions: 2 })] @@ -54,46 +82,90 @@ describe("agent manager diff state", () => { expect(result.stale).toEqual(new Set(["src/app.ts"])) }) - it("opens reviewable diffs initially", () => { + it("opens every diff initially", () => { expect( initialOpenFiles([ diff({ file: "src/app.ts", generatedLike: false, additions: 3 }), diff({ file: "node_modules/pkg/index.js", generatedLike: true, additions: 3 }), + diff({ file: "audio/notification.wav", summarized: false, additions: 0 }), + diff({ file: "assets/banner.png", kind: "image", summarized: true, additions: 0 }), diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), ]), - ).toEqual(["src/app.ts"]) + ).toEqual(["src/app.ts", "node_modules/pkg/index.js", "src/huge.ts"]) const many = Array.from({ length: 26 }, (_, i) => diff({ file: `src/${i}.ts` })) expect(initialOpenFiles(many)).toHaveLength(26) }) - it("expands only reviewable files from the bulk action", () => { + it("keeps generated and large files in the expanded review", () => { expect( expandableOpenFiles([ diff({ file: "src/app.ts", generatedLike: false, additions: 3 }), diff({ file: "src/generated.ts", generatedLike: true, additions: 3 }), + diff({ file: "assets/archive.zip", summarized: false, additions: 0 }), diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), ]), - ).toEqual(["src/app.ts"]) + ).toEqual(["src/app.ts", "src/generated.ts", "src/huge.ts"]) }) - it("toggles reviewable files based on whether every reviewable file is open", () => { + it("toggles all files based on whether every file is open", () => { const diffs = [ diff({ file: "src/app.ts" }), diff({ file: "src/panel.ts" }), diff({ file: "src/generated.ts", generatedLike: true }), + diff({ file: "audio/alert.mp3", summarized: false, additions: 0 }), diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), ] expect(allOpenFiles(diffs, [])).toBe(false) expect(allOpenFiles(diffs, ["stale.ts"])).toBe(false) expect(allOpenFiles(diffs, ["src/app.ts"])).toBe(false) - expect(allOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toBe(true) - expect(allOpenFiles(diffs, ["stale.ts", "src/app.ts", "src/panel.ts", "src/generated.ts"])).toBe(true) + expect(allOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toBe(false) + expect(allOpenFiles(diffs, ["stale.ts", "src/app.ts", "src/panel.ts", "src/generated.ts"])).toBe(false) + expect( + allOpenFiles( + diffs, + diffs.map((item) => item.file), + ), + ).toBe(true) - expect(toggleOpenFiles(diffs, [])).toEqual(["src/app.ts", "src/panel.ts"]) - expect(toggleOpenFiles(diffs, ["stale.ts"])).toEqual(["src/app.ts", "src/panel.ts"]) - expect(toggleOpenFiles(diffs, ["src/app.ts"])).toEqual(["src/app.ts", "src/panel.ts"]) - expect(toggleOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toEqual([]) + const files = expandableOpenFiles(diffs) + expect(toggleOpenFiles(diffs, [])).toEqual(files) + expect(toggleOpenFiles(diffs, ["stale.ts"])).toEqual(files) + expect(toggleOpenFiles(diffs, ["src/app.ts"])).toEqual(files) + expect(toggleOpenFiles(diffs, files)).toEqual([]) + }) + + it("opens images while preventing other non-text diffs from entering open state", () => { + const audio = diff({ file: "audio/alert.wav", summarized: false, additions: 0 }) + const image = diff({ file: "assets/banner.png", kind: "image", summarized: true, additions: 0 }) + const text = diff({ file: "src/app.ts" }) + + expect(isDiffExpandable(audio)).toBe(false) + expect(isDiffExpandable(image)).toBe(true) + expect(isDiffExpandable(text)).toBe(true) + expect(sanitizeOpenFiles([audio, image, text], [audio.file, image.file, text.file])).toEqual([ + image.file, + text.file, + ]) + }) +}) + +describe("diff line virtualization", () => { + it("renders normal hunk patches directly inside virtual file rows", () => { + expect( + shouldVirtualizeDiff(diff({ file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 10, deletions: 5 })), + ).toBe(false) + }) + + it("virtualizes full-content and extreme individual files", () => { + expect( + shouldVirtualizeDiff(diff({ file: "src/source.ts", before: "a\n".repeat(4000), after: "b\n", additions: 1 })), + ).toBe(true) + expect( + shouldVirtualizeDiff( + diff({ file: "src/big.ts", patch: "large", additions: EXTREME_DIFF_CHANGED_LINES + 1, deletions: 0 }), + ), + ).toBe(true) }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts index 38a34b3f6ed..ab0965fa7e9 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts @@ -18,6 +18,7 @@ import { dict as appBs } from "../../webview-ui/src/i18n/bs" import { dict as appTr } from "../../webview-ui/src/i18n/tr" import { dict as appNl } from "../../webview-ui/src/i18n/nl" import { dict as appUk } from "../../webview-ui/src/i18n/uk" +import { dict as appIt } from "../../webview-ui/src/i18n/it" import { dict as amEn } from "../../webview-ui/agent-manager/i18n/en" import { dict as amZh } from "../../webview-ui/agent-manager/i18n/zh" import { dict as amZht } from "../../webview-ui/agent-manager/i18n/zht" @@ -37,6 +38,7 @@ import { dict as amBs } from "../../webview-ui/agent-manager/i18n/bs" import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr" import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl" import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk" +import { dict as amIt } from "../../webview-ui/agent-manager/i18n/it" const PREFIX = "agentManager." @@ -60,6 +62,7 @@ const locales = { tr: amTr, nl: amNl, uk: amUk, + it: amIt, } const appLocales = { @@ -82,6 +85,7 @@ const appLocales = { tr: appTr, nl: appNl, uk: appUk, + it: appIt, } function placeholders(text: string): string[] { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-mcp-warmup.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-mcp-warmup.test.ts new file mode 100644 index 00000000000..4e718980b18 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-mcp-warmup.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { startSession } from "../../src/agent-manager/mcp-warmup" + +function tick(): Promise { + return new Promise((resolve) => queueMicrotask(resolve)) +} + +describe("Agent Manager MCP warmup", () => { + it("starts MCP status for the worktree directory before session creation", async () => { + const calls: unknown[][] = [] + const client = { + mcp: { + status: (input: unknown, opts: unknown) => { + calls.push(["warm", input, opts]) + return Promise.resolve({ data: {} }) + }, + }, + } + + const result = await startSession( + client as never, + "/repo/.kilo/worktrees/feature", + async () => { + calls.push(["session"]) + return "created" + }, + () => {}, + ) + + expect(result).toBe("created") + expect(calls[0]).toEqual(["warm", { directory: "/repo/.kilo/worktrees/feature" }, { throwOnError: true }]) + expect(calls[1]).toEqual(["session"]) + }) + + it("does not wait for MCP warmup before creating the session", async () => { + const calls: string[] = [] + const warmup = new Promise(() => {}) + const client = { + mcp: { + status: () => { + calls.push("warm") + return warmup + }, + }, + } + + const result = await startSession( + client as never, + "/repo/.kilo/worktrees/feature", + async () => { + calls.push("session") + return "created" + }, + () => {}, + ) + + expect(result).toBe("created") + expect(calls).toEqual(["warm", "session"]) + }) + + it("logs and contains MCP warmup failures", async () => { + const logs: unknown[][] = [] + const client = { + mcp: { + status: () => { + throw new Error("connection failed") + }, + }, + } + + const result = await startSession( + client as never, + "/repo/.kilo/worktrees/feature", + async () => "created", + (...args) => logs.push(args), + ) + await tick() + + expect(result).toBe("created") + expect(logs[0]).toEqual(["[MCPWarmup] Starting for /repo/.kilo/worktrees/feature"]) + expect(logs[1]?.[0]).toBe("[MCPWarmup] Failed for /repo/.kilo/worktrees/feature:") + expect(logs[1]?.[1]).toBeInstanceOf(Error) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-telemetry.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-telemetry.test.ts new file mode 100644 index 00000000000..6e0adf55132 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-telemetry.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test" +import type { TelemetryRequest } from "../../webview-ui/src/types/messages/webview-messages" +import { capture, tracker } from "../../webview-ui/agent-manager/telemetry" +import { TelemetryEventName } from "../../src/services/telemetry/types" + +describe("Agent Manager telemetry", () => { + it("uses one stable event with low-cardinality button metadata", () => { + const messages: TelemetryRequest[] = [] + + capture({ postMessage: (message) => messages.push(message) }, "fullscreen_review", "tab_toolbar", { + action: "open", + fileCount: 3, + }) + + expect(messages).toEqual([ + { + type: "telemetry", + event: TelemetryEventName.AGENT_MANAGER_BUTTON_CLICKED, + properties: { + action: "open", + fileCount: 3, + source: "agent-manager", + button: "fullscreen_review", + surface: "tab_toolbar", + }, + }, + ]) + }) + + it("does not allow callers to override event dimensions", () => { + const messages: TelemetryRequest[] = [] + + capture({ postMessage: (message) => messages.push(message) }, "apply_to_local", "apply_dialog", { + source: "other", + button: "other", + surface: "other", + }) + + expect(messages[0]?.properties).toMatchObject({ + source: "agent-manager", + button: "apply_to_local", + surface: "apply_dialog", + }) + }) + + it("resolves current properties before running wrapped actions", () => { + const messages: TelemetryRequest[] = [] + const order: string[] = [] + const metrics = tracker({ + postMessage: (message) => { + messages.push(message) + order.push("telemetry") + }, + }) + const state = { action: "run" } + const click = metrics.click( + "run_script", + "tab_toolbar", + () => order.push("action"), + () => state, + ) + state.action = "stop" + + click() + + expect(messages[0]?.properties?.action).toBe("stop") + expect(order).toEqual(["telemetry", "action"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts new file mode 100644 index 00000000000..9bf257a8c3b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { createRoot } from "solid-js" +import { affectsTerminalFont, resolveTerminalFont } from "../../src/agent-manager/terminal-font" +import { TerminalRouter } from "../../src/agent-manager/terminal-routing" +import type { AgentManagerOutMessage, TerminalFont } from "../../src/agent-manager/types" +import { createTerminalMessageHandler, createTerminalState } from "../../webview-ui/agent-manager/terminal/state" +import { LOCAL } from "../../webview-ui/agent-manager/navigate" +import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages" + +const font: TerminalFont = { + fontFamily: "MesloLGS NF", + fontSize: 18, +} + +describe("Agent Manager terminal font", () => { + it("resolves terminal settings without inheriting the editor size", () => { + expect(resolveTerminalFont(undefined, undefined, undefined)).toEqual({ + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontSize: process.platform === "darwin" ? 12 : 14, + }) + expect(resolveTerminalFont("MesloLGS NF", 16, "Menlo")).toEqual({ + fontFamily: "MesloLGS NF", + fontSize: 16, + }) + expect(resolveTerminalFont(undefined, 16, "Menlo")).toEqual({ + fontFamily: "Menlo", + fontSize: 16, + }) + }) + + it("watches only settings that affect the terminal family or size", () => { + const event = (key: string) => + ({ + affectsConfiguration: (target: string) => target === key, + }) as Parameters[0] + + expect(affectsTerminalFont(event("terminal.integrated.fontFamily"))).toBe(true) + expect(affectsTerminalFont(event("terminal.integrated.fontSize"))).toBe(true) + expect(affectsTerminalFont(event("editor.fontFamily"))).toBe(true) + expect(affectsTerminalFont(event("editor.fontSize"))).toBe(false) + expect(affectsTerminalFont(event("terminal.integrated.letterSpacing"))).toBe(false) + }) + + it("includes the current font when creating a terminal", async () => { + const client = { + pty: { + create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const message = new Promise((resolve) => { + const router = new TerminalRouter({ + getClient: () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: resolve, + getTerminalFont: () => font, + }) + + expect(router.handle({ type: "agentManager.terminal.create", worktreeId: null })).toBe(true) + }) + + const created = await message + expect(created.type).toBe("agentManager.terminal.created") + if (created.type !== "agentManager.terminal.created") return + expect(created.font).toEqual(font) + expect(created.worktreeId).toBeNull() + expect(created.wsUrl).toContain("/pty/pty-1/connect") + }) + + it("keeps the created font in terminal state", () => { + createRoot((dispose) => { + const state = createTerminalState(() => LOCAL) + const activated: string[] = [] + const handler = createTerminalMessageHandler({ + state, + activate: (id) => activated.push(id), + saveTabMemory: () => undefined, + setSelection: () => undefined, + showError: () => undefined, + }) + const message = { + type: "agentManager.terminal.created", + worktreeId: null, + terminalId: "terminal-1", + title: "Terminal 1", + wsUrl: "ws://127.0.0.1/pty/pty-1/connect", + font, + } satisfies ExtensionMessage + + expect(handler(message)).toBe(true) + expect(state.forSelection(LOCAL)[0]?.font).toEqual(font) + expect(activated).toEqual(["terminal-1"]) + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts index 8742ab2776a..7eff66077c5 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -87,6 +87,7 @@ describe("agent manager tool start", () => { sessionID: "s-local", directory: "/repo", parts: [{ type: "text", text: "Do work" }], + snapshotInitialization: "wait", }), { throwOnError: true }, ) @@ -105,6 +106,97 @@ describe("agent manager tool start", () => { expect(c.notifyReady).toHaveBeenCalled() }) + it("deduplicates repeated delivery of the same exact request", async () => { + const requests = new Set() + const c = deps({ + claimRequest: mock((id: string) => { + if (requests.has(id)) return false + requests.add(id) + return true + }), + }) + const req: ToolRequest = { + requestID: "am-duplicate", + mode: "worktree", + tasks: [ + { + branchName: "echo-hello-world", + name: "Echo hello world", + prompt: 'Use the bash tool to run: echo "hello world". Report back the output.', + }, + ], + } + + await startFromTool(c, req) + await startFromTool(c, req) + + expect(c.createWorktree).toHaveBeenCalledTimes(1) + expect(c.createSessionInWorktree).toHaveBeenCalledTimes(1) + }) + + it("starts each task from separate tool calls even when their branch seeds match", async () => { + const requests = new Set() + const c = deps({ + claimRequest: mock((id: string) => { + if (requests.has(id)) return false + requests.add(id) + return true + }), + }) + const req: ToolRequest = { + requestID: "am-first", + mode: "worktree", + tasks: [ + { + branchName: "echo-hello-world", + name: "Echo hello world", + prompt: 'Use the bash tool to run: echo "hello world". Report back the output.', + }, + ], + } + + await startFromTool(c, req) + await startFromTool(c, { ...req, requestID: "am-second" }) + + expect(c.createWorktree).toHaveBeenCalledTimes(2) + expect(c.createSessionInWorktree).toHaveBeenCalledTimes(2) + expect(c.createWorktree).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ branchName: "echo-hello-world", name: "echo-hello-world" }), + ) + }) + + it("deduplicates concurrent delivery of the same request", async () => { + const requests = new Set() + const pending = Promise.withResolvers() + const resume = Promise.withResolvers() + const c = deps({ + claimRequest: mock((id: string) => { + if (requests.has(id)) return false + requests.add(id) + return true + }), + createWorktree: mock(async () => { + pending.resolve() + await resume.promise + return { worktree: { id: "wt-1" }, result: result("/repo/.kilo/worktrees/wt-1") } + }), + }) + const req: ToolRequest = { + requestID: "am-concurrent", + mode: "worktree", + tasks: [{ prompt: "Fix", branchName: "fix/concurrent" }], + } + + const first = startFromTool(c, req) + await pending.promise + await startFromTool(c, req) + resume.resolve() + await first + + expect(c.createWorktree).toHaveBeenCalledTimes(1) + }) + it("only applies version suffixes when versions is true", async () => { const normal = deps() await startFromTool(normal, { diff --git a/packages/kilo-vscode/tests/unit/attention.test.ts b/packages/kilo-vscode/tests/unit/attention.test.ts new file mode 100644 index 00000000000..2e3acb935d9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/attention.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "bun:test" +import type { TuiAttentionSoundName } from "@kilocode/plugin/tui" +import { AttentionService } from "../../src/services/attention/service" +import type { KiloConnectionService } from "../../src/services/cli-backend/connection-service" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { CustomSoundIDs, resolveSoundID } from "../../src/services/attention/sound" + +function setup(opts: { approve?: () => boolean | Promise } = {}) { + const sounds: TuiAttentionSoundName[] = [] + const events: Array<(event: SSEPayload) => void> = [] + const states: Array<(state: "connecting" | "connected" | "disconnected" | "error") => void> = [] + const connection = { + onEvent: (handler: (event: SSEPayload) => void) => { + events.push(handler) + return () => undefined + }, + onStateChange: (handler: (state: "connecting" | "connected" | "disconnected" | "error") => void) => { + states.push(handler) + return () => undefined + }, + } as unknown as KiloConnectionService + const service = new AttentionService(connection, opts) + ;(service as unknown as { notify: (sound: TuiAttentionSoundName) => void }).notify = (sound) => sounds.push(sound) + return { + sounds, + event: (event: SSEPayload) => events[0]?.(event), + state: (state: "connecting" | "connected" | "disconnected" | "error") => states[0]?.(state), + service, + } +} + +function event(value: unknown) { + return value as SSEPayload +} + +describe("AttentionService", () => { + it("plays the upstream completion sound once after a completed turn closes", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } })) + + expect(test.sounds).toEqual(["done"]) + test.service.dispose() + }) + + it("plays completion sounds only for parent agents", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "child", status: { type: "retry" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "child", status: { type: "idle" } } })) + test.event( + event({ + type: "session.turn.close", + properties: { sessionID: "child", parentID: "parent", reason: "completed" }, + }), + ) + test.event(event({ type: "session.status", properties: { sessionID: "parent", status: { type: "busy" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "parent", reason: "completed" } })) + + expect(test.sounds).toEqual(["done"]) + test.service.dispose() + }) + + it("deduplicates question and permission requests", () => { + const test = setup() + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "question.replied", properties: { requestID: "q1", sessionID: "s1" } })) + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + + expect(test.sounds).toEqual(["question", "question", "permission"]) + test.service.dispose() + }) + + it("stays silent for auto-approved permission requests", () => { + const test = setup({ approve: () => true }) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + test.event(event({ type: "permission.replied", properties: { requestID: "p1", sessionID: "s1" } })) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) + + it("plays attention when auto-approval fails and the request remains pending", async () => { + const test = setup({ approve: async () => false }) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + await Bun.sleep(0) + + expect(test.sounds).toEqual(["permission"]) + test.service.dispose() + }) + + it("stays silent when a permission resolves before auto-approval failure settles", async () => { + const approval = Promise.withResolvers() + const test = setup({ approve: () => approval.promise }) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + test.event(event({ type: "permission.replied", properties: { requestID: "p1", sessionID: "s1" } })) + approval.resolve(false) + await Bun.sleep(0) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) + + it("plays the error sound and suppresses the following completion", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.error", properties: { sessionID: "s1", error: { name: "ApiError" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + + expect(test.sounds).toEqual(["error"]) + test.service.dispose() + }) + + it("stays silent when a turn is manually interrupted after becoming idle", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "interrupted" } })) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) + + it("does not treat an aborted session error as requiring attention", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event( + event({ + type: "session.error", + properties: { sessionID: "s1", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, + }), + ) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "interrupted" } })) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) + + it("clears transitions when the backend disconnects", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.state("disconnected") + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } })) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) + + it("clears transitions when a session is deleted", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.deleted", properties: { sessionID: "s1", info: { id: "s1" } } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } })) + test.event(event({ type: "session.status", properties: { sessionID: "s2", status: { type: "busy" } } })) + test.event(event({ type: "sync", name: "session.deleted.1", data: { sessionID: "s2" } })) + test.event(event({ type: "session.turn.close", properties: { sessionID: "s2", reason: "completed" } })) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) +}) + +describe("attention defaults", () => { + it("keeps attention sounds opt-in", async () => { + const manifest = (await Bun.file(new URL("../../package.json", import.meta.url)).json()) as { + contributes: { configuration: { properties: Record } } + } + const properties = manifest.contributes.configuration.properties + + expect(properties["kilo-code.new.attention.enabled"]?.default).toBe(false) + expect(properties["kilo-code.new.attention.sound"]?.default).toBe("default") + expect(properties["kilo-code.new.attention.sound"]?.enum).toEqual(["default", "system", ...CustomSoundIDs]) + expect(properties["kilo-code.new.sounds.agentEnabled"]).toBeUndefined() + expect(properties["kilo-code.new.sounds.permissionsEnabled"]).toBeUndefined() + expect(properties["kilo-code.new.sounds.errorsEnabled"]).toBeUndefined() + }) + + it("resolves global sound choices safely", () => { + expect(resolveSoundID("default")).toBe("default") + expect(resolveSoundID("system")).toBe("system") + expect(resolveSoundID("alert-04")).toBe("alert-04") + expect(resolveSoundID("unknown")).toBe("default") + }) + + it("packages every selectable bundled sound", async () => { + const exists = await Promise.all( + CustomSoundIDs.map((name) => Bun.file(new URL(`../../audio-wav/${name}.wav`, import.meta.url)).exists()), + ) + expect(exists.every(Boolean)).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/auto-approve.test.ts b/packages/kilo-vscode/tests/unit/auto-approve.test.ts index f890b906bb1..2f66b71c719 100644 --- a/packages/kilo-vscode/tests/unit/auto-approve.test.ts +++ b/packages/kilo-vscode/tests/unit/auto-approve.test.ts @@ -79,44 +79,38 @@ function context() { return { subscriptions: [] as Array<{ dispose(): void }> } as vscode.ExtensionContext } -function connection(client: KiloClient | null) { - const listeners: Array<(event: Event) => void> = [] +function connection(client: KiloClient | null, dirs = new Map()) { const svc = { getClient: () => { if (!client) throw new Error("not connected") return client }, - onEvent: (listener: (event: Event) => void) => { - listeners.push(listener) - return () => { - const index = listeners.indexOf(listener) - if (index >= 0) listeners.splice(index, 1) - } - }, + getPermissionDirectory: (id: string) => dirs.get(id), } as unknown as KiloConnectionService - return { - svc, - emit(event: Event) { - for (const listener of listeners) listener(event) - }, - } + return { svc } } function client(opts: { list?: (dir: string) => Promise<{ data: Permission[] }> - reply?: (args: { requestID: string; directory: string; reply: "once" }) => Promise + reply?: ( + args: { requestID: string; directory: string; reply: "once" }, + options?: { throwOnError?: boolean }, + ) => Promise }) { return { permission: { list: async (args: { directory: string }) => opts.list?.(args.directory) ?? { data: [] }, - reply: async (args: { requestID: string; directory: string; reply: "once" }) => opts.reply?.(args), + reply: async ( + args: { requestID: string; directory: string; reply: "once" }, + options?: { throwOnError?: boolean }, + ) => opts.reply?.(args, options), }, } as unknown as KiloClient } function asked(id: string, sessionID = "ses_1") { - return { type: "permission.asked", properties: { id, sessionID } } as Event + return { type: "permission.asked", properties: { id, sessionID } } as Extract } describe("registerToggleAutoApprove", () => { @@ -134,7 +128,7 @@ describe("registerToggleAutoApprove", () => { ctrl.onChange((active) => changes.push(active)) expect(ctrl.active()).toBe(true) - conn.emit(asked("perm_1")) + expect(await ctrl.approve(asked("perm_1"))).toBe(true) expect(replies).toEqual([{ requestID: "perm_1", directory: "/repo/ses_1", reply: "once" }]) env.active = false @@ -142,7 +136,7 @@ describe("registerToggleAutoApprove", () => { expect(ctrl.active()).toBe(false) expect(changes).toEqual([false]) - conn.emit(asked("perm_2")) + expect(await ctrl.approve(asked("perm_2"))).toBe(false) expect(replies).toHaveLength(1) await ctrl.toggle() @@ -152,6 +146,67 @@ describe("registerToggleAutoApprove", () => { expect(env.messages).toContain("Auto-approve enabled") }) + it("uses the SSE directory for worktree permissions before session mappings are available", async () => { + config(true) + const replies: unknown[] = [] + const conn = connection(client({ reply: async (args) => replies.push(args) })) + const ctrl = registerToggleAutoApprove( + context(), + conn.svc, + () => "/workspace", + () => ["/workspace"], + ) + + await ctrl.approve(asked("perm_worktree", "ses_worktree"), "/workspace/.kilo/worktrees/feature") + await ctrl.approve(asked("perm_child", "ses_child"), "/workspace/.kilo/worktrees/feature") + + expect(replies).toEqual([ + { requestID: "perm_worktree", directory: "/workspace/.kilo/worktrees/feature", reply: "once" }, + { requestID: "perm_child", directory: "/workspace/.kilo/worktrees/feature", reply: "once" }, + ]) + }) + + it("uses the shared permission directory before falling back to session mappings", async () => { + config(true) + const replies: unknown[] = [] + const conn = connection( + client({ reply: async (args) => replies.push(args) }), + new Map([["perm_shared", "/workspace/.kilo/worktrees/shared"]]), + ) + const ctrl = registerToggleAutoApprove( + context(), + conn.svc, + () => "/workspace", + () => ["/workspace"], + ) + + await ctrl.approve(asked("perm_shared", "ses_child")) + + expect(replies).toEqual([ + { requestID: "perm_shared", directory: "/workspace/.kilo/worktrees/shared", reply: "once" }, + ]) + }) + + it("returns unhandled when an automatic reply fails", async () => { + config(true) + const conn = connection( + client({ + reply: async (_args, options) => { + expect(options).toEqual({ throwOnError: true }) + throw new Error("offline") + }, + }), + ) + const ctrl = registerToggleAutoApprove( + context(), + conn.svc, + () => "/workspace", + () => ["/workspace"], + ) + + expect(await ctrl.approve(asked("perm_1"))).toBe(false) + }) + it("cancels pending permission drains when disabled during an enable generation", async () => { config(false) const gate = defer<{ data: Permission[] }>() @@ -198,6 +253,7 @@ describe("createAutoApproveBridge", () => { const state = { active: false } const ctrl: AutoApproveController = { active: () => state.active, + approve: async () => false, toggle: async () => { state.active = !state.active for (const listener of listeners) listener(state.active) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts index 46c986b972f..fab5cbbcc67 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts @@ -81,7 +81,6 @@ describe("ErrorBackoff", () => { }) it("is not fatal initially", () => { - expect(backoff.isFatal()).toBe(false) expect(backoff.getFatalStatus()).toBeNull() }) @@ -89,21 +88,18 @@ describe("ErrorBackoff", () => { it("blocks after a 402 error", () => { backoff.failure(new Error("SSE failed: 402 Payment Required")) expect(backoff.blocked()).toBe(true) - expect(backoff.isFatal()).toBe(true) expect(backoff.getFatalStatus()).toBe(402) }) it("blocks after a 401 error", () => { backoff.failure(new Error("SSE failed: 401 Unauthorized")) expect(backoff.blocked()).toBe(true) - expect(backoff.isFatal()).toBe(true) expect(backoff.getFatalStatus()).toBe(401) }) it("blocks after a 403 error", () => { backoff.failure(new Error("SSE failed: 403 Forbidden")) expect(backoff.blocked()).toBe(true) - expect(backoff.isFatal()).toBe(true) expect(backoff.getFatalStatus()).toBe(403) }) @@ -168,7 +164,6 @@ describe("ErrorBackoff", () => { backoff.reset() expect(backoff.blocked()).toBe(false) - expect(backoff.isFatal()).toBe(false) expect(backoff.getFatalStatus()).toBeNull() }) @@ -178,7 +173,6 @@ describe("ErrorBackoff", () => { backoff.success() expect(backoff.blocked()).toBe(false) - expect(backoff.isFatal()).toBe(false) }) }) @@ -232,7 +226,6 @@ describe("ErrorBackoff", () => { it("fatal error overrides retriable backoff", () => { backoff.failure(new Error("SSE failed: 500 Internal Server Error")) backoff.failure(new Error("SSE failed: 402 Payment Required")) - expect(backoff.isFatal()).toBe(true) expect(backoff.blocked()).toBe(true) }) @@ -244,7 +237,6 @@ describe("ErrorBackoff", () => { backoff.success() expect(backoff.blocked()).toBe(false) - expect(backoff.isFatal()).toBe(false) }) }) }) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-migrate-default.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-migrate-default.test.ts new file mode 100644 index 00000000000..f031401dd24 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-migrate-default.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, afterEach, beforeEach } from "bun:test" +import * as vscode from "vscode" +import { migrateDefaultAutocompleteSettings } from "../../src/services/autocomplete/migrate-default" +import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../src/shared/autocomplete-models" + +type Scoped = { globalValue?: unknown; workspaceValue?: unknown } +type State = Map + +type Stub = { + getConfiguration: (section?: string) => { + get: (key: string, fallback?: unknown) => unknown + inspect: (key: string) => Scoped | undefined + update: (key: string, value: unknown, target: unknown) => Promise + } +} + +const original = vscode.workspace.getConfiguration + +function makeContext(initial: Record = {}) { + const flag = new Map(Object.entries(initial)) + return { + flag, + context: { + globalState: { + get: (key: string) => flag.get(key) as T | undefined, + update: async (key: string, value: unknown) => { + flag.set(key, value) + }, + }, + } as any, + } +} + +function stubConfig(state: State) { + function entry(key: string): Scoped { + const e = state.get(key) + if (e) return e + const fresh: Scoped = {} + state.set(key, fresh) + return fresh + } + ;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => { + if (section !== "kilo-code.new.autocomplete") { + return { + get: () => undefined, + inspect: () => undefined, + update: async () => {}, + } + } + return { + get: (key: string, fallback?: unknown) => { + const e = state.get(key) + return e?.workspaceValue ?? e?.globalValue ?? fallback + }, + inspect: (key: string) => state.get(key) ?? {}, + update: async (key: string, value: unknown, target: unknown) => { + // vscode.ConfigurationTarget.Global = 1, Workspace = 2 + const scope: keyof Scoped = target === 2 ? "workspaceValue" : "globalValue" + const e = entry(key) + if (value === undefined) delete e[scope] + else e[scope] = value + }, + } + } +} + +function setGlobal(state: State, key: string, value: unknown) { + const e = state.get(key) ?? {} + e.globalValue = value + state.set(key, e) +} + +function setWorkspace(state: State, key: string, value: unknown) { + const e = state.get(key) ?? {} + e.workspaceValue = value + state.set(key, e) +} + +afterEach(() => { + ;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"] +}) + +describe("migrateDefaultAutocompleteSettings", () => { + let state: State + + beforeEach(() => { + state = new Map() + stubConfig(state) + }) + + it("clears provider/model when both equal the current default at global scope", async () => { + setGlobal(state, "provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID) + setGlobal(state, "model", DEFAULT_AUTOCOMPLETE_MODEL.modelID) + const { context, flag } = makeContext() + + await migrateDefaultAutocompleteSettings(context) + + expect(state.get("provider")?.globalValue).toBeUndefined() + expect(state.get("model")?.globalValue).toBeUndefined() + expect(flag.get("kilo.autocomplete.defaultClearMigrationV1")).toBe(true) + }) + + it("leaves an explicitly chosen non-default model untouched", async () => { + setGlobal(state, "provider", "inception") + setGlobal(state, "model", "mercury-edit-2") + const { context, flag } = makeContext() + + await migrateDefaultAutocompleteSettings(context) + + expect(state.get("provider")?.globalValue).toBe("inception") + expect(state.get("model")?.globalValue).toBe("mercury-edit-2") + expect(flag.get("kilo.autocomplete.defaultClearMigrationV1")).toBe(true) + }) + + it("leaves a partial match untouched", async () => { + setGlobal(state, "provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID) + setGlobal(state, "model", "inception/mercury-edit-2") + const { context } = makeContext() + + await migrateDefaultAutocompleteSettings(context) + + expect(state.get("provider")?.globalValue).toBe(DEFAULT_AUTOCOMPLETE_MODEL.providerID) + expect(state.get("model")?.globalValue).toBe("inception/mercury-edit-2") + }) + + it("ignores workspace-scoped pins so they aren't mistaken for global defaults", async () => { + setWorkspace(state, "provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID) + setWorkspace(state, "model", DEFAULT_AUTOCOMPLETE_MODEL.modelID) + const { context } = makeContext() + + await migrateDefaultAutocompleteSettings(context) + + // Workspace value is intact — we only clear at global scope. + expect(state.get("provider")?.workspaceValue).toBe(DEFAULT_AUTOCOMPLETE_MODEL.providerID) + expect(state.get("model")?.workspaceValue).toBe(DEFAULT_AUTOCOMPLETE_MODEL.modelID) + }) + + it("only runs once per machine", async () => { + setGlobal(state, "provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID) + setGlobal(state, "model", DEFAULT_AUTOCOMPLETE_MODEL.modelID) + const { context } = makeContext({ "kilo.autocomplete.defaultClearMigrationV1": true }) + + await migrateDefaultAutocompleteSettings(context) + + // Setting was preserved — second run is a no-op. + expect(state.get("provider")?.globalValue).toBe(DEFAULT_AUTOCOMPLETE_MODEL.providerID) + expect(state.get("model")?.globalValue).toBe(DEFAULT_AUTOCOMPLETE_MODEL.modelID) + }) + + it("sets the flag even when nothing needed clearing", async () => { + const { context, flag } = makeContext() + + await migrateDefaultAutocompleteSettings(context) + + expect(flag.get("kilo.autocomplete.defaultClearMigrationV1")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts index 1996041e917..d2bc8799404 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts @@ -1,19 +1,15 @@ import { describe, expect, it } from "vitest" -import { - AUTOCOMPLETE_PROVIDER_ID, - AUTOCOMPLETE_PROVIDER_NAME, - AUTOCOMPLETE_SELECTOR_MODELS, -} from "../../webview-ui/src/components/settings/autocomplete-model-selector" +import { AUTOCOMPLETE_SELECTOR_MODELS } from "../../webview-ui/src/components/settings/autocomplete-model-selector" import { AUTOCOMPLETE_MODELS } from "../../src/shared/autocomplete-models" describe("autocomplete model selector", () => { - it("shows only Kilo Gateway autocomplete models", () => { + it("shows autocomplete models grouped by their configured provider", () => { expect(AUTOCOMPLETE_SELECTOR_MODELS).toEqual( AUTOCOMPLETE_MODELS.map((m) => ({ - id: m.id, + id: m.modelID, name: m.label, - providerID: AUTOCOMPLETE_PROVIDER_ID, - providerName: AUTOCOMPLETE_PROVIDER_NAME, + providerID: m.providerID, + providerName: m.provider, })), ) }) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts index e7dc5657839..799daa11857 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts @@ -7,8 +7,8 @@ describe("autocomplete model enum ↔ AUTOCOMPLETE_MODELS sync", () => { const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) const prop = pkg.contributes.configuration.properties["kilo-code.new.autocomplete.model"] - it("package.json enum matches AUTOCOMPLETE_MODELS ids", () => { - const ids = AUTOCOMPLETE_MODELS.map((m) => m.id) + it("package.json enum matches AUTOCOMPLETE_MODELS model IDs", () => { + const ids = AUTOCOMPLETE_MODELS.map((m) => m.modelID) expect(prop.enum).toEqual(ids) }) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-settings-message.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-settings-message.test.ts new file mode 100644 index 00000000000..3d24e8c9246 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-settings-message.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, afterEach, beforeEach } from "bun:test" +import * as vscode from "vscode" +import { buildAutocompleteSettingsMessage, validAutocompleteSetting } from "../../src/services/autocomplete/settings" + +type Stub = { + getConfiguration: (section?: string) => { + get: (key: string, fallback?: T) => T | undefined + update?: (key: string, value: unknown) => Promise + } +} + +const original = vscode.workspace.getConfiguration + +function stubConfig(state: Map) { + ;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => { + if (section !== "kilo-code.new.autocomplete") { + return { get: (_key: string, fallback?: T) => fallback } + } + return { + get: (key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback), + } + } +} + +afterEach(() => { + ;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"] +}) + +describe("buildAutocompleteSettingsMessage", () => { + let state: Map + + beforeEach(() => { + state = new Map() + stubConfig(state) + }) + + it("returns null for both keys when nothing is set so the webview renders 'Not set'", () => { + const msg = buildAutocompleteSettingsMessage() + + expect(msg.settings.provider).toBeNull() + expect(msg.settings.model).toBeNull() + }) + + it("passes an explicit BYOK selection through verbatim", () => { + state.set("provider", "inception") + state.set("model", "mercury-edit-2") + + const msg = buildAutocompleteSettingsMessage() + + expect(msg.settings.provider).toBe("inception") + expect(msg.settings.model).toBe("mercury-edit-2") + }) + + it("does not coerce a bare model setting to a default — let the webview see what was stored", () => { + state.set("model", "mercury-edit-2") + + const msg = buildAutocompleteSettingsMessage() + + expect(msg.settings.provider).toBeNull() + expect(msg.settings.model).toBe("mercury-edit-2") + }) +}) + +describe("validAutocompleteSetting", () => { + it("accepts null/undefined for provider and model so the user can clear back to the default", () => { + expect(validAutocompleteSetting("provider", null)).toBe(true) + expect(validAutocompleteSetting("provider", undefined)).toBe(true) + expect(validAutocompleteSetting("model", null)).toBe(true) + expect(validAutocompleteSetting("model", undefined)).toBe(true) + }) + + it("accepts known providers and models", () => { + expect(validAutocompleteSetting("provider", "inception")).toBe(true) + expect(validAutocompleteSetting("model", "mercury-edit-2")).toBe(true) + }) + + it("rejects unknown providers and models", () => { + expect(validAutocompleteSetting("provider", "openrouter")).toBe(false) + expect(validAutocompleteSetting("model", "gpt-5")).toBe(false) + }) + + it("rejects non-boolean toggle updates", () => { + expect(validAutocompleteSetting("enableAutoTrigger", "true")).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts index 415362ab7e5..216c574d1d7 100644 --- a/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts @@ -3,6 +3,7 @@ import { finalizeChatSuggestion, buildChatPrefix, } from "../../src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils" +import { getChatAutocompleteModel } from "../../src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" describe("finalizeChatSuggestion", () => { it("returns empty string for empty input", () => { @@ -98,3 +99,14 @@ describe("buildChatPrefix", () => { expect(result).toContain("hi") }) }) + +describe("getChatAutocompleteModel", () => { + it("uses the matching FIM model for Next Edit settings", () => { + expect(getChatAutocompleteModel("kilo", "inception/mercury-next-edit").id).toBe("kilo/inception/mercury-edit-2") + expect(getChatAutocompleteModel("inception", "mercury-next-edit").id).toBe("inception/mercury-edit-2") + }) + + it("keeps FIM settings unchanged", () => { + expect(getChatAutocompleteModel("kilo", "mistralai/codestral-2508").id).toBe("kilo/mistralai/codestral-2508") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts new file mode 100644 index 00000000000..accf86ee0a3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test" +import { + handleImportAndSend, + handleRequestCloudSessionData, + type CloudSessionContext, +} from "../../src/kilo-provider/handlers/cloud-session" + +function stalled(options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }) + }) +} + +function context(sent: unknown[]) { + return { + client: { + kilo: { + cloud: { + session: { + get: (_params: { id: string }, options?: { signal?: AbortSignal }) => stalled(options), + import: (_params: { sessionId: string; directory: string }, options?: { signal?: AbortSignal }) => + stalled(options), + }, + }, + }, + }, + currentSession: null, + trackedSessionIds: new Set(), + connectionService: { recordMessageSessionId: () => undefined }, + postMessage: (message: unknown) => sent.push(message), + getWorkspaceDirectory: () => "/repo", + gatherEditorContext: async () => ({}), + } as unknown as CloudSessionContext +} + +describe("cloud session preview handler", () => { + it("reports a failure when the CLI preview request stalls", async () => { + const timeout = AbortSignal.timeout + AbortSignal.timeout = () => { + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + + try { + const sent: unknown[] = [] + const outcome = await Promise.race([ + handleRequestCloudSessionData(context(sent), "cloud-session").then(() => "resolved" as const), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("resolved") + expect(sent).toEqual([ + { + type: "cloudSessionImportFailed", + cloudSessionId: "cloud-session", + error: "The operation timed out", + }, + ]) + } finally { + AbortSignal.timeout = timeout + } + }) + + it("reports a failure when the CLI import request stalls", async () => { + const timeout = AbortSignal.timeout + AbortSignal.timeout = () => { + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + + try { + const sent: unknown[] = [] + const outcome = await Promise.race([ + handleImportAndSend(context(sent), "cloud-session", "Continue").then(() => "resolved" as const), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("resolved") + expect(sent).toEqual([ + { + type: "cloudSessionImportFailed", + cloudSessionId: "cloud-session", + error: "The operation timed out", + }, + ]) + } finally { + AbortSignal.timeout = timeout + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts index 866f57adf74..eca588a5d1b 100644 --- a/packages/kilo-vscode/tests/unit/config-scope.test.ts +++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts @@ -33,17 +33,15 @@ describe("splitConfigByScope", () => { expect(split.project).toEqual({}) }) - it("writes speech-to-text experimental settings to global config", () => { + it("writes the speech-to-text model setting to global config", () => { const split = splitConfigByScope({ experimental: { - speech_to_text: true, speech_to_text_model: "openai/gpt-4o-mini-transcribe", }, }) expect(split.global).toEqual({ experimental: { - speech_to_text: true, speech_to_text_model: "openai/gpt-4o-mini-transcribe", }, }) diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index f5842ca032b..5ca90cb714b 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "bun:test" -import { deepMerge, stripNulls, ConfigState } from "../../webview-ui/src/utils/config-utils" +import { + configUnsetPaths, + ConfigState, + deepMerge, + mergeScopedConfig, + pruneConfigSet, + stripNulls, +} from "../../webview-ui/src/utils/config-utils" import type { Config } from "../../webview-ui/src/types/messages" // --------------------------------------------------------------------------- @@ -42,6 +49,38 @@ describe("deepMerge", () => { }) }) +describe("scoped config normalization", () => { + it("preserves indexing null overrides while stripping unrelated nulls", () => { + const target = { username: "alice", indexing: { model: "global", dimension: 1024 } } as Config + const source = { username: null, indexing: { model: null, dimension: null } } as unknown as Partial + + expect(mergeScopedConfig(target, source)).toEqual({ indexing: { model: null, dimension: null } }) + }) + + it("builds clean set and unset payloads while preserving indexing null overrides", () => { + const patch = { + formatter: {}, + username: null, + indexing: { + model: null, + dimension: null, + searchMinScore: undefined, + qdrant: { apiKey: undefined }, + }, + } + + expect(pruneConfigSet(patch)).toEqual({ + formatter: {}, + indexing: { model: null, dimension: null }, + }) + expect(configUnsetPaths(patch)).toEqual([ + ["username"], + ["indexing", "searchMinScore"], + ["indexing", "qdrant", "apiKey"], + ]) + }) +}) + describe("stripNulls", () => { it("removes null values", () => { const cfg = { snapshot: true, username: null } as unknown as Config @@ -330,6 +369,29 @@ describe("ConfigState", () => { }) }) + describe("clearing an agent variant override", () => { + it("keeps null in the draft so the backend receives a delete sentinel", () => { + const s = new ConfigState() + s.handleConfigLoaded({ + agent: { + explore: { + model: "kilo/anthropic/claude-sonnet-4-6", + variant: "high", + }, + }, + }) + + s.updateConfig({ agent: { explore: { variant: null } } }) + + expect(s.config.agent?.explore?.variant).toBeUndefined() + expect(s.dirty).toBe(true) + expect(s.draft.agent?.explore?.variant).toBeNull() + expect(JSON.parse(JSON.stringify(s.draft))).toEqual({ + agent: { explore: { variant: null } }, + }) + }) + }) + describe("agent permission patches", () => { it("merges nested per-agent permission patches into existing rules", () => { const s = new ConfigState() diff --git a/packages/kilo-vscode/tests/unit/connection-service-question.test.ts b/packages/kilo-vscode/tests/unit/connection-service-question.test.ts new file mode 100644 index 00000000000..a7c1df64666 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/connection-service-question.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { KiloConnectionService } from "../../src/services/cli-backend/connection-service" + +describe("KiloConnectionService question routing", () => { + test("ignores stale NotFoundError rejects while draining questions", async () => { + const service = new KiloConnectionService({} as any) + const client = { + permission: { + list: async () => ({ data: [] }), + }, + question: { + list: async () => ({ data: [{ id: "que_test" }] }), + reject: async () => ({ error: { _tag: "NotFound" } }), + }, + suggestion: { + list: async () => ({ data: [] }), + }, + network: { + list: async () => ({ data: [] }), + }, + } + + ;(service as any).client = client + ;(service as any).directoryProviders.add(() => ["/tmp/workspace"]) + + await expect(service.drainPendingPrompts()).resolves.toBeUndefined() + }) + + test("records and clears request origins from SSE events", () => { + const service = new KiloConnectionService({} as any) + const handler = service as unknown as { + handleQuestionEvent(event: unknown, directory?: string): void + } + + handler.handleQuestionEvent( + { type: "question.asked", properties: { id: "que_test", sessionID: "ses_test", questions: [] } }, + "/tmp/worktree", + ) + expect(service.getQuestionDirectory("que_test")).toBe("/tmp/worktree") + expect(service.getQuestionRevision()).toBe(1) + + handler.handleQuestionEvent({ + type: "question.replied", + properties: { requestID: "que_test", sessionID: "ses_test", answers: [] }, + }) + expect(service.getQuestionDirectory("que_test")).toBeUndefined() + expect(service.getQuestionRevision()).toBe(2) + + service.recordQuestionDirectory("que_rejected", "/tmp/worktree") + handler.handleQuestionEvent({ + type: "question.rejected", + properties: { requestID: "que_rejected", sessionID: "ses_test" }, + }) + expect(service.getQuestionDirectory("que_rejected")).toBeUndefined() + expect(service.getQuestionRevision()).toBe(3) + }) + + test("prunes stale origins only for successfully scanned directories", () => { + const service = new KiloConnectionService({} as any) + service.recordQuestionDirectory("que_active", "/tmp/scanned") + service.recordQuestionDirectory("que_stale", "/tmp/scanned") + service.recordQuestionDirectory("que_unknown", "/tmp/failed") + + service.pruneQuestionDirectories(new Set(["que_active"]), new Set(["/tmp/scanned"])) + + expect(service.getQuestionDirectory("que_active")).toBe("/tmp/scanned") + expect(service.getQuestionDirectory("que_stale")).toBeUndefined() + expect(service.getQuestionDirectory("que_unknown")).toBe("/tmp/failed") + expect(service.getQuestionRevision()).toBe(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index c916af225a9..46d74e8a231 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -1,185 +1,166 @@ -import { describe, it, expect } from "bun:test" +import { describe, expect, it } from "bun:test" +import type { GlobalEvent } from "@kilocode/sdk/v2/client" import { resolveEventSessionId } from "../../src/services/cli-backend/connection-utils" -import type { Event } from "@kilocode/sdk/v2/client" const noLookup = (_: string) => undefined -/** Helper to create a partial Event for testing — only the fields accessed by resolveEventSessionId matter. */ -function event(partial: Record): Event { - return partial as unknown as Event +type Payload = GlobalEvent["payload"] + +const message = { + id: "m1", + sessionID: "s5", + role: "user", + time: { created: 0 }, + agent: "build", + model: { providerID: "kilo", modelID: "test" }, +} as const + +const part = { + id: "p1", + sessionID: "s6", + messageID: "m1", + type: "text", + text: "", +} as const + +function sync(event: Extract): Payload { + return event } describe("resolveEventSessionId", () => { - it("returns session id from session.created", () => { - const e = event({ - type: "session.created", - properties: { - info: { id: "s1", title: "", directory: "", time: { created: 0, updated: 0 } }, + it("returns the session ID from session.created.1", () => { + const event = sync({ + type: "sync", + name: "session.created.1", + id: "e1", + seq: 0, + aggregateID: "sessionID", + data: { + sessionID: "s1", + info: { + id: "s1", + slug: "session", + projectID: "project", + directory: "/workspace", + title: "Session", + version: "1", + time: { created: 0, updated: 0 }, + }, }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s1") - }) - it("returns session id from session.updated", () => { - const e = event({ - type: "session.updated", - properties: { - info: { id: "s2", title: "", directory: "", time: { created: 0, updated: 0 } }, - }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s2") + expect(resolveEventSessionId(event, noLookup)).toBe("s1") }) - it("returns sessionID from session.status", () => { - const e = event({ - type: "session.status", - properties: { sessionID: "s3", status: { type: "idle" } }, + it("returns the session ID from session.updated.1", () => { + const event = sync({ + type: "sync", + name: "session.updated.1", + id: "e2", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "s2", info: { title: "Updated" } }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s3") - }) - it("returns sessionID from todo.updated", () => { - const e = event({ - type: "todo.updated", - properties: { sessionID: "s4", todos: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s4") + expect(resolveEventSessionId(event, noLookup)).toBe("s2") }) - it("returns sessionID from message.updated and calls onMessageUpdated", () => { - const e = event({ - type: "message.updated", - properties: { - info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, - }, + it("records message.updated.1 mappings", () => { + const event = sync({ + type: "sync", + name: "message.updated.1", + id: "e3", + seq: 2, + aggregateID: "sessionID", + data: { sessionID: "s5", info: message }, }) - const recorded: [string, string][] = [] - const result = resolveEventSessionId(e, noLookup, (mid, sid) => recorded.push([mid, sid])) - expect(result).toBe("s5") - expect(recorded).toEqual([["m1", "s5"]]) - }) + const recorded: Array<[string, string]> = [] - it("message.updated does not require onMessageUpdated callback", () => { - const e = event({ - type: "message.updated", - properties: { - info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, - }, - }) - expect(() => resolveEventSessionId(e, noLookup)).not.toThrow() + expect(resolveEventSessionId(event, noLookup, (mid, sid) => recorded.push([mid, sid]))).toBe("s5") + expect(recorded).toEqual([["m1", "s5"]]) }) - it("returns sessionID directly from message.part.updated when part has sessionID", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "", sessionID: "s6", messageID: "m1" }, - }, + it("does not require a message mapping callback", () => { + const event = sync({ + type: "sync", + name: "message.updated.1", + id: "e4", + seq: 3, + aggregateID: "sessionID", + data: { sessionID: "s5", info: message }, }) - expect(resolveEventSessionId(e, noLookup)).toBe("s6") - }) - it("falls back to lookup when message.part.updated has no sessionID but has messageID", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "", messageID: "m2" }, - }, - }) - const lookup = (id: string) => (id === "m2" ? "s7" : undefined) - expect(resolveEventSessionId(e, lookup)).toBe("s7") + expect(() => resolveEventSessionId(event, noLookup)).not.toThrow() }) - it("returns undefined for message.part.updated with no sessionID and messageID not in map", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "", messageID: "unknown" }, - }, + it("returns the envelope session ID from message.part.updated.1", () => { + const event = sync({ + type: "sync", + name: "message.part.updated.1", + id: "e5", + seq: 4, + aggregateID: "sessionID", + data: { sessionID: "s6", part, time: 0 }, }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() - }) - it("returns undefined for message.part.updated with no messageID and no sessionID", () => { - const e = event({ - type: "message.part.updated", - properties: { - part: { type: "text", id: "p1", text: "" }, - }, - }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() + expect(resolveEventSessionId(event, noLookup)).toBe("s6") }) - it("returns sessionID from permission.asked", () => { - const e = event({ - type: "permission.asked", - properties: { - id: "p1", - sessionID: "s8", - permission: "read_file", - patterns: [], - metadata: {}, - always: [], - }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s8") - }) + it("routes transient session events", () => { + const event = { + id: "e6", + type: "session.status", + properties: { sessionID: "s3", status: { type: "idle" } }, + } satisfies Payload - it("returns sessionID from question.asked", () => { - const e = event({ - type: "question.asked", - properties: { id: "q1", sessionID: "s9", questions: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s9") + expect(resolveEventSessionId(event, noLookup)).toBe("s3") }) - it("returns sessionID from question.replied", () => { - const e = event({ - type: "question.replied", - properties: { sessionID: "s10", requestID: "r1", answers: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s10") - }) + it("routes transient message deltas", () => { + const event = { + id: "e7", + type: "message.part.delta", + properties: { sessionID: "s4", messageID: "m2", partID: "p2", field: "text", delta: "x" }, + } satisfies Payload - it("returns sessionID from question.rejected", () => { - const e = event({ - type: "question.rejected", - properties: { sessionID: "s11", requestID: "r2" }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s11") + expect(resolveEventSessionId(event, noLookup)).toBe("s4") }) - it("returns sessionID from suggestion.shown", () => { - const e = event({ - type: "suggestion.shown", - properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s12") - }) + it("routes session.network events", () => { + const event = { + id: "e8", + type: "session.network.restored", + properties: { sessionID: "s7" }, + } satisfies Payload - it("returns sessionID from suggestion.accepted", () => { - const e = event({ - type: "suggestion.accepted", - properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s13") + expect(resolveEventSessionId(event, noLookup)).toBe("s7") }) - it("returns sessionID from suggestion.dismissed", () => { - const e = event({ + it("routes permission, question, and suggestion events", () => { + const permission = { + id: "e9", + type: "permission.replied", + properties: { sessionID: "s8", requestID: "p1", reply: "once" }, + } satisfies Payload + const question = { + id: "e10", + type: "question.rejected", + properties: { sessionID: "s9", requestID: "q1" }, + } satisfies Payload + const suggestion = { + id: "e11", type: "suggestion.dismissed", - properties: { sessionID: "s14", requestID: "sug_2" }, - }) - expect(resolveEventSessionId(e, noLookup)).toBe("s14") - }) + properties: { sessionID: "s10", requestID: "sg1" }, + } satisfies Payload - it("returns undefined for unknown event types (global events)", () => { - const e = event({ type: "server.connected", properties: {} }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() + expect(resolveEventSessionId(permission, noLookup)).toBe("s8") + expect(resolveEventSessionId(question, noLookup)).toBe("s9") + expect(resolveEventSessionId(suggestion, noLookup)).toBe("s10") }) - it("returns undefined for another unknown event type", () => { - const e = event({ type: "server.heartbeat", properties: {} }) - expect(resolveEventSessionId(e, noLookup)).toBeUndefined() + it("returns undefined for global events", () => { + const event = { id: "e12", type: "server.connected", properties: {} } satisfies Payload + + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() }) }) diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index c2dab4a182a..8cf641d90a2 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -1,17 +1,49 @@ -import { describe, expect, it } from "bun:test" +import { afterEach, describe, expect, it, mock } from "bun:test" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import simpleGit from "simple-git" import { abortSession, - captureState, + continueInWorktree, forkSession, registerSession, type ContinueContext, - type StepResult, } from "../../src/agent-manager/continue-in-worktree" -import type { CreateWorktreeResult } from "../../src/agent-manager/WorktreeManager" +import { WorktreeManager, type CreateWorktreeResult } from "../../src/agent-manager/WorktreeManager" +import { forkText } from "../../src/agent-manager/fork-handoff" import type { Session } from "@kilocode/sdk/v2/client" const noop = () => {} const log = noop as (...args: unknown[]) => void +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(dirs.splice(0, dirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true }))) +}) + +async function repo(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "continue-worktree-")) + dirs.push(dir) + const git = simpleGit(dir) + await git.init(["--initial-branch=main"]) + await git.addConfig("user.email", "test@test.com") + await git.addConfig("user.name", "Test") + await fs.writeFile(path.join(dir, "state.txt"), "base\n") + await git.add("state.txt") + await git.commit("initial") + return dir +} + +function client(fork = mock(async () => ({ data: session("forked") }))) { + return { + session: { + abort: mock(async () => ({})), + fork, + promptAsync: mock(async () => ({})), + }, + } as never +} function session(id: string): Session { return { @@ -35,6 +67,8 @@ function ctx(overrides: Partial = {}): ContinueContext { }, createWorktreeOnDisk: async () => null, runSetupScript: async () => {}, + cleanupWorktree: async () => {}, + notifyError: noop, getStateManager: () => undefined, registerWorktreeSession: noop, registerSession: noop, @@ -100,17 +134,27 @@ describe("continue-in-worktree steps", () => { if (!res.ok) expect(res.error).toContain("fork failed") }) - it("returns forked session on success", async () => { + it("records handoff instructions for the forked worktree session", async () => { const forked = session("forked-1") + const promptAsync = mock(async () => ({})) const c = ctx({ getClient: () => ({ - session: { fork: () => Promise.resolve({ data: forked }) }, + session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, }) as never, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(true) if (res.ok) expect(res.value.id).toBe("forked-1") + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "forked-1", + directory: "/tmp/wt", + noReply: true, + parts: [{ type: "text", text: forkText({ directory: "/tmp/wt" }), synthetic: true }], + }, + { throwOnError: true }, + ) }) }) @@ -143,3 +187,47 @@ describe("continue-in-worktree steps", () => { }) }) }) + +describe("continueInWorktree", () => { + it("rolls back the created worktree when Git transfer fails", async () => { + const root = await repo() + const git = simpleGit(root) + await fs.writeFile(path.join(root, "state.txt"), "local dirty\n") + + const manager = new WorktreeManager(root, noop) + const setup = mock(async () => {}) + const cleanup = mock(async () => { + if (created) await manager.removeWorktree(created.path, created.branch) + }) + const notify = mock((_error: string, _result: CreateWorktreeResult, _worktreeId: string) => {}) + const progress: Array<{ status: string; error?: string }> = [] + let created: CreateWorktreeResult | undefined + const c = ctx({ + root, + getClient: () => client(), + createWorktreeOnDisk: async (opts) => { + const value = await manager.createWorktree(opts) + created = value + const target = simpleGit(value.path) + await fs.writeFile(path.join(value.path, "state.txt"), "conflict\n") + await target.add("state.txt") + await target.commit("conflict") + return { worktree: { id: "wt-1" }, result: value } + }, + runSetupScript: setup, + cleanupWorktree: cleanup, + notifyError: notify, + }) + + await continueInWorktree(c, "source", (status, _detail, error) => progress.push({ status, error })) + + expect(progress.at(-1)?.status).toBe("error") + expect(progress.at(-1)?.error).toContain("Unstaged patch failed") + expect(setup).toHaveBeenCalledTimes(1) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(notify).toHaveBeenCalledWith(expect.stringContaining("Unstaged patch failed"), created!, "wt-1") + expect(created).toBeDefined() + await expect(fs.stat(created!.path)).rejects.toThrow() + expect((await git.branchLocal()).all).not.toContain(created!.branch) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index a045e2e1f76..0ba8b3f4e94 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -9,6 +9,7 @@ function base(): FormState { return { providerID: "my-provider", name: "My Provider", + npm: "@ai-sdk/openai-compatible", baseURL: "https://example.com/v1", apiKey: "", models: [{ id: "model-1", name: "Model One", reasoning: false, variants: [] }], @@ -28,6 +29,13 @@ function args(form: FormState) { } describe("validateCustomProvider – variant name validation", () => { + it("persists the selected provider package", () => { + const form = base() + form.npm = "@ai-sdk/openai" + + expect(validateCustomProvider(args(form)).result?.config.npm).toBe("@ai-sdk/openai") + }) + it("allows reconnecting a disabled provider id", () => { const form = base() const out = validateCustomProvider({ @@ -56,6 +64,8 @@ describe("validateCustomProvider – variant name validation", () => { name: "fast", enableThinking: undefined, thinking: undefined, + splitReasoning: undefined, + outputEffort: undefined, reasoningEffort: undefined, chatTemplateArgs: undefined, }, @@ -73,6 +83,8 @@ describe("validateCustomProvider – variant name validation", () => { name: "", enableThinking: undefined, thinking: undefined, + splitReasoning: undefined, + outputEffort: undefined, reasoningEffort: undefined, chatTemplateArgs: undefined, }, @@ -90,6 +102,8 @@ describe("validateCustomProvider – variant name validation", () => { name: " ", enableThinking: undefined, thinking: undefined, + splitReasoning: undefined, + outputEffort: undefined, reasoningEffort: undefined, chatTemplateArgs: undefined, }, @@ -107,6 +121,8 @@ describe("validateCustomProvider – variant name validation", () => { name: "fast", enableThinking: undefined, thinking: undefined, + splitReasoning: undefined, + outputEffort: undefined, reasoningEffort: undefined, chatTemplateArgs: undefined, }, @@ -114,6 +130,8 @@ describe("validateCustomProvider – variant name validation", () => { name: "fast", enableThinking: undefined, thinking: undefined, + splitReasoning: undefined, + outputEffort: undefined, reasoningEffort: undefined, chatTemplateArgs: undefined, }, @@ -131,6 +149,8 @@ describe("validateCustomProvider – variant name validation", () => { name: "", enableThinking: undefined, thinking: undefined, + splitReasoning: undefined, + outputEffort: undefined, reasoningEffort: undefined, chatTemplateArgs: undefined, }, @@ -148,11 +168,27 @@ describe("validateCustomProvider – variant name validation", () => { const form = base() form.models[0].reasoning = true form.models[0].variants = [ - { name: "eco", enableThinking: true, thinking: undefined, reasoningEffort: "low", chatTemplateArgs: undefined }, + { + name: "eco", + enableThinking: true, + thinking: "adaptive", + splitReasoning: false, + outputEffort: "max", + reasoningEffort: "low", + chatTemplateArgs: undefined, + }, ] const out = validateCustomProvider(args(form)) expect(out.result).toBeDefined() const saved = out.result!.config.models["model-1"] as Record - expect(saved.variants).toEqual({ eco: { enable_thinking: true, reasoningEffort: "low" } }) + expect(saved.variants).toEqual({ + eco: { + enable_thinking: true, + thinking: { type: "adaptive" }, + reasoning_split: false, + effort: "max", + reasoningEffort: "low", + }, + }) }) }) diff --git a/packages/kilo-vscode/tests/unit/custom-provider.test.ts b/packages/kilo-vscode/tests/unit/custom-provider.test.ts index 1a3ce2c242c..b9153376952 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider.test.ts @@ -8,6 +8,16 @@ import { validateProviderID, withCustomProviderDeletions, } from "../../src/shared/custom-provider" +import { isCustomProviderPackage } from "../../src/shared/provider-model" + +describe("isCustomProviderPackage", () => { + it("recognizes supported custom provider packages", () => { + expect(isCustomProviderPackage("@ai-sdk/openai-compatible")).toBe(true) + expect(isCustomProviderPackage("@ai-sdk/openai")).toBe(true) + expect(isCustomProviderPackage("@ai-sdk/anthropic")).toBe(true) + expect(isCustomProviderPackage("malicious-package")).toBe(false) + }) +}) describe("validateProviderID", () => { it("accepts valid provider ids", () => { @@ -64,9 +74,9 @@ describe("resolveCustomProviderKey", () => { }) describe("sanitizeCustomProviderConfig", () => { - it("normalizes config and forces the approved package", () => { + it("normalizes config and preserves an approved package", () => { const result = sanitizeCustomProviderConfig({ - npm: "malicious-package", + npm: "@ai-sdk/anthropic", name: " My Provider ", env: [" MY_PROVIDER_KEY "], options: { @@ -83,7 +93,7 @@ describe("sanitizeCustomProviderConfig", () => { expect(result).toEqual({ value: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "My Provider", env: ["MY_PROVIDER_KEY"], options: { @@ -100,7 +110,18 @@ describe("sanitizeCustomProviderConfig", () => { }) }) - it("accepts models with chat_template_args variant", () => { + it("rejects unapproved packages", () => { + const result = sanitizeCustomProviderConfig({ + npm: "malicious-package", + name: "Bad Provider", + options: { baseURL: "https://example.com/v1" }, + models: { "model-1": { name: "Model One" } }, + }) + + expect("error" in result ? result.error : "").toContain("Invalid enum value") + }) + + it("accepts supported thinking variant options", () => { const result = sanitizeCustomProviderConfig({ name: "Thinking Provider", options: { baseURL: "https://example.com/v1" }, @@ -108,7 +129,12 @@ describe("sanitizeCustomProviderConfig", () => { "model-1": { name: "Model One", variants: { - thinking: { chat_template_args: { enable_thinking: true } }, + thinking: { + thinking: { type: "adaptive" }, + reasoning_split: true, + effort: "max", + chat_template_args: { enable_thinking: true }, + }, }, }, }, @@ -123,7 +149,12 @@ describe("sanitizeCustomProviderConfig", () => { "model-1": { name: "Model One", variants: { - thinking: { chat_template_args: { enable_thinking: true } }, + thinking: { + thinking: { type: "adaptive" }, + reasoning_split: true, + effort: "max", + chat_template_args: { enable_thinking: true }, + }, }, }, }, @@ -166,11 +197,12 @@ describe("withCustomProviderDeletions", () => { expect(models.gone).toBeNull() }) - it("emits null for variants removed from a surviving model", () => { + it("emits null for reasoning and variants removed from a surviving model", () => { const existing = { models: { keep: { name: "Keep", + reasoning: true, variants: { high: { reasoningEffort: "high" }, low: { reasoningEffort: "low" } }, }, }, @@ -182,9 +214,39 @@ describe("withCustomProviderDeletions", () => { }, } as typeof baseNext const result = withCustomProviderDeletions(existing, next) + const model = (result.models as Record }>) + .keep + expect(model.reasoning).toBeNull() + expect(model.variants?.high).toEqual({ reasoningEffort: "high" }) + expect(model.variants?.low).toBeNull() + }) + + it("emits null when reasoning is disabled on a surviving model", () => { + const existing = { models: { keep: { name: "Keep", reasoning: true } } } + const result = withCustomProviderDeletions(existing, baseNext) + expect(result.models.keep).toEqual({ name: "Keep", reasoning: null }) + }) + + it("emits null for options removed from a surviving variant", () => { + const existing = { + models: { + keep: { + name: "Keep", + variants: { + thinking: { thinking: { type: "adaptive" }, reasoning_split: true, reasoningEffort: "high" }, + }, + }, + }, + } + const next = { + ...baseNext, + models: { + keep: { name: "Keep", variants: { thinking: { reasoningEffort: "high" } } }, + }, + } as typeof baseNext + const result = withCustomProviderDeletions(existing, next) const model = (result.models as Record }>).keep - expect(model.variants.high).toEqual({ reasoningEffort: "high" }) - expect(model.variants.low).toBeNull() + expect(model.variants.thinking).toEqual({ reasoningEffort: "high", thinking: null, reasoning_split: null }) }) it("does not touch variants on a model that is being deleted", () => { diff --git a/packages/kilo-vscode/tests/unit/diff-image.test.ts b/packages/kilo-vscode/tests/unit/diff-image.test.ts new file mode 100644 index 00000000000..6bb7e4df969 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-image.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { imageMime, loadImage, MAX_IMAGE_BYTES, readImageFile } from "../../src/diff/shared/image" +import { parseRawOids } from "../../src/diff/sources/git-status" + +describe("diff images", () => { + it("recognizes every image format handled by the VS Code image preview", () => { + const files = [ + "photo.jpg", + "photo.jpe", + "photo.jpeg", + "image.png", + "image.bmp", + "animation.gif", + "favicon.ico", + "image.webp", + "image.avif", + "vector.svg", + ] + + for (const file of files) expect(imageMime(file)).toStartWith("image/") + expect(imageMime("ASSETS/LOGO.PNG")).toBe("image/png") + expect(imageMime("archive.zip")).toBeUndefined() + expect(imageMime("photo.tiff")).toBeUndefined() + }) + + it("parses image blob identities from a single raw diff", () => { + const before = "1".repeat(40) + const after = "2".repeat(40) + const refs = parseRawOids(`:100644 100644 ${before} ${after} M\tassets/banner.png\n`) + + expect(refs.get("assets/banner.png")).toEqual({ before, after }) + }) + + it("encodes image sides without converting bytes to text", async () => { + const before = Buffer.from([0x00, 0xff, 0x10, 0x80]) + const after = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + const image = await loadImage( + "asset.png", + { bytes: before.byteLength, read: async () => before }, + { bytes: after.byteLength, read: async () => after }, + ) + + expect(image?.before).toEqual({ mime: "image/png", bytes: 4, data: before.toString("base64") }) + expect(image?.after).toEqual({ mime: "image/png", bytes: 4, data: after.toString("base64") }) + }) + + it("bounds mutable image reads to the payload cap", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "diff-image-test-")) + const file = path.join(dir, "large.png") + try { + await fs.writeFile(file, Buffer.alloc(MAX_IMAGE_BYTES + 100)) + expect((await readImageFile(file))?.byteLength).toBe(MAX_IMAGE_BYTES + 1) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + it("does not read image sides over the webview payload cap", async () => { + let reads = 0 + const image = await loadImage("asset.webp", { + bytes: MAX_IMAGE_BYTES + 1, + read: async () => { + reads++ + return Buffer.from("unused") + }, + }) + + expect(reads).toBe(0) + expect(image?.before).toEqual({ + mime: "image/webp", + bytes: MAX_IMAGE_BYTES + 1, + error: "too-large", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts index 3aa9be076d5..ce870f0abbe 100644 --- a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts @@ -57,18 +57,26 @@ describe("createSessionDiffSource.fetch", () => { deletions: 0, status: "modified", }, + { + file: "large.txt", + patch: "", + additions: 500, + deletions: 200, + status: "modified", + }, ] const { fetch } = recording(raw) const source = createSessionDiffSource("s2", fetch, "/repo") const result = await source.fetch() - expect(result.diffs).toHaveLength(2) + expect(result.diffs).toHaveLength(3) const foo = result.diffs[0]! expect(foo.file).toBe("foo.ts") expect(foo.before).toBe("keep\nold\n") expect(foo.after).toBe("keep\nnew\n") + expect(foo.patch).toBe(modifiedPatch) expect(foo.additions).toBe(1) expect(foo.deletions).toBe(1) expect(foo.status).toBe("modified") @@ -77,9 +85,59 @@ describe("createSessionDiffSource.fetch", () => { expect(foo.summarized).toBe(false) const big = result.diffs[1]! - expect(big.summarized).toBe(true) + expect(big.summarized).toBe(false) expect(big.before).toBe("") expect(big.after).toBe("") + expect(result.diffs[2]?.summarized).toBe(true) + }) + + it("rebuilds text-backed SVG snapshot sides without sending them to Pierre", async () => { + const before = '' + const after = '' + const patch = [ + "diff --git a/assets/banner.svg b/assets/banner.svg", + "--- a/assets/banner.svg", + "+++ b/assets/banner.svg", + "@@ -1 +1 @@", + `-${before}`, + `+${after}`, + "", + ].join("\n") + const { fetch } = recording([{ file: "assets/banner.svg", patch, additions: 1, deletions: 1, status: "modified" }]) + const result = await createSessionDiffSource("s-image", fetch, "/repo").fetch() + + expect(result.diffs[0]).toMatchObject({ + file: "assets/banner.svg", + before: "", + after: "", + patch: "", + kind: "image", + summarized: false, + image: { + before: { mime: "image/svg+xml", data: Buffer.from(`${before}\n`).toString("base64") }, + after: { mime: "image/svg+xml", data: Buffer.from(`${after}\n`).toString("base64") }, + }, + }) + }) + + it("reuses converted image details while the snapshot is unchanged", async () => { + const { fetch } = recording([ + { file: "assets/banner.svg", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + ]) + const source = createSessionDiffSource("s-image-cache", fetch, "/repo") + const first = await source.fetch() + const second = await source.fetch() + + expect(second.diffs).toBe(first.diffs) + }) + + it("marks binary snapshot images unavailable when their sides were not retained", async () => { + const { fetch } = recording([ + { file: "assets/banner.png", patch: "", additions: 0, deletions: 0, status: "modified" }, + ]) + const result = await createSessionDiffSource("s-image", fetch, "/repo").fetch() + + expect(result.diffs[0]?.image).toEqual({}) }) it("propagates errors from the underlying fetch", async () => { diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts index ce6614c519b..f854b92e219 100644 --- a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts @@ -1,5 +1,5 @@ /** - * Architecture test: FullScreenDiffView CSS co-location. + * Architecture test: FullScreenDiffView CSS imports. * * `FullScreenDiffView` and its children (`FileTree`, etc.) rely on classes * defined in BOTH `agent-manager.css` and `agent-manager-review.css`. The @@ -22,10 +22,10 @@ import fs from "node:fs" import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") -const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx") -const REQUIRED = ["./agent-manager.css", "./agent-manager-review.css"] as const +const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx") +const REQUIRED = ["../agent-manager/agent-manager.css", "../agent-manager/agent-manager-review.css"] as const -describe("FullScreenDiffView — CSS co-location", () => { +describe("FullScreenDiffView — CSS imports", () => { it("imports every stylesheet required to render correctly", () => { const src = fs.readFileSync(FULL_SCREEN_DIFF_VIEW, "utf-8") const missing = REQUIRED.filter((css) => !src.includes(`import "${css}"`)) diff --git a/packages/kilo-vscode/tests/unit/errorUtils.test.ts b/packages/kilo-vscode/tests/unit/errorUtils.test.ts index aef7ae2dcb5..6f660bcb72e 100644 --- a/packages/kilo-vscode/tests/unit/errorUtils.test.ts +++ b/packages/kilo-vscode/tests/unit/errorUtils.test.ts @@ -28,6 +28,26 @@ describe("unwrapError", () => { const json = JSON.stringify({ message: "connection refused" }) expect(unwrapError(`Error: ${json}`)).toBe("connection refused") }) + + it("formats empty provider rate-limit errors", () => { + const body = { + type: "error", + sequence_number: 2, + error: { type: "tokens", code: "rate_limit_exceeded", message: "", param: null }, + } + const input = JSON.stringify({ message: JSON.stringify(body) }) + + expect(unwrapError(input)).toBe("Provider rate limit exceeded. Please try again shortly.") + }) + + it("preserves provider details when a rate-limit message is present", () => { + const input = JSON.stringify({ + type: "error", + error: { type: "tokens", code: "rate_limit_exceeded", message: "Try again in 30 seconds." }, + }) + + expect(unwrapError(input)).toBe("tokens: Try again in 30 seconds.") + }) }) describe("parseAssistantError", () => { diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 174989a2611..4ff0d758ec8 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -16,6 +16,7 @@ const PKG_JSON_FILE = path.join(ROOT, "package.json") const SRC_DIR = path.join(ROOT, "src") const EXTENSION_FILE = path.join(ROOT, "src/extension.ts") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts") function sliceBlock(source: string, start: number): string { const open = source.indexOf("{", start) @@ -103,6 +104,28 @@ describe("Extension — package.json command sync", () => { `Commands without "kilo-code.new." prefix — use the namespaced form:\n` + bad.map((b) => ` - ${b}`).join("\n"), ).toEqual([]) }) + + it("scopes Agent Manager search to the panel and leaves the integrated terminal alone", () => { + const binding = pkg.contributes?.keybindings?.find( + (item: { command: string }) => item.command === "kilo-code.new.agentManager.search", + ) + expect(binding).toMatchObject({ + key: "ctrl+f", + mac: "cmd+f", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !terminalFocus", + }) + }) + + it("scopes the open PR shortcut to Agent Manager", () => { + const binding = pkg.contributes?.keybindings?.find( + (item: { command: string }) => item.command === "kilo-code.new.agentManager.openPR", + ) + expect(binding).toMatchObject({ + key: "ctrl+shift+r", + mac: "cmd+shift+r", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + }) + }) }) // --------------------------------------------------------------------------- @@ -187,6 +210,35 @@ describe("Extension — KiloProvider handler wiring", () => { // it silently no-op'd, leaving the UI stuck. // --------------------------------------------------------------------------- +describe("Extension — Agent Manager remote wiring", () => { + const ext = fs.readFileSync(EXTENSION_FILE, "utf-8") + const host = fs.readFileSync(VSCODE_HOST_FILE, "utf-8") + + it("passes the shared remote service to Agent Manager", () => { + expect(ext).toContain("new VscodeHost(context.extensionUri, connectionService, context, remoteService)") + }) + + it("wires the remote service before attaching the Agent Manager webview", () => { + const remote = host.indexOf("provider.setRemoteService(this.remoteService)") + const attach = host.indexOf("provider.attachToWebview") + expect(remote).toBeGreaterThan(-1) + expect(attach).toBeGreaterThan(-1) + expect(remote).toBeLessThan(attach) + }) +}) + +describe("KiloProvider — remote focus lifecycle", () => { + const provider = fs.readFileSync(KILO_PROVIDER_FILE, "utf-8") + + it("registers newly created sessions and uses the synchronous session ID", () => { + const create = sliceBlock(provider, provider.indexOf("private async handleCreateSession")) + const resolve = sliceBlock(provider, provider.indexOf("private async resolveSession")) + expect(create).toContain("this.focusSession(session.id)") + expect(resolve).toContain("this.focusSession(session.id)") + expect(provider).toContain("this.focusSession(webviewView.visible ? this.contextSessionID : undefined)") + }) +}) + describe("KiloProvider — continueInWorktree error fallback", () => { const helper = fs.readFileSync(path.join(ROOT, "src/kilo-provider/continue-worktree.ts"), "utf-8") diff --git a/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts b/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts index e590f92b364..ba4fd3c46e9 100644 --- a/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts @@ -40,7 +40,6 @@ describe("FileIgnoreController", () => { expect(controller.validateAccess("secret/keys.txt")).toBe(false) expect(controller.validateAccess(path.join(workspace, "a.snap"))).toBe(false) expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true) - expect(controller.getInstructions()).toContain(".kilocodeignore") }) it("does NOT block .env files unless explicitly listed", async () => { @@ -81,7 +80,6 @@ describe("FileIgnoreController", () => { expect(controller.validateAccess(path.join(workspace, "node_modules", "foo.js"))).toBe(false) expect(controller.validateAccess(path.join(workspace, "build", "output.js"))).toBe(false) expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true) - expect(controller.getInstructions()).toContain(".gitignore") }) it("blocks .env files via hardcoded sensitive patterns", async () => { @@ -167,12 +165,5 @@ describe("FileIgnoreController", () => { expect(controller.validateAccess("/some/file.ts")).toBe(false) expect(controller.validateAccess("relative/file.ts")).toBe(false) }) - - it("filterPaths returns empty array", async () => { - const controller = new FileIgnoreController("") - await controller.initialize() - - expect(controller.filterPaths(["/some/file.ts", "other.ts"])).toEqual([]) - }) }) }) diff --git a/packages/kilo-vscode/tests/unit/file-tree.test.ts b/packages/kilo-vscode/tests/unit/file-tree.test.ts index a54908dab20..2f1c066f6d8 100644 --- a/packages/kilo-vscode/tests/unit/file-tree.test.ts +++ b/packages/kilo-vscode/tests/unit/file-tree.test.ts @@ -5,7 +5,7 @@ import { flattenChain, treeOrder, type FileTreeNode, -} from "../../webview-ui/agent-manager/file-tree-utils" +} from "../../webview-ui/diff-viewer/file-tree-utils" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(file: string, status?: "added" | "deleted" | "modified"): WorktreeFileDiff { diff --git a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts index 5a96ef7eb28..2bd36106149 100644 --- a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts @@ -18,6 +18,7 @@ const TARGETS = [ path.join(ROOT, "webview-ui/src"), path.join(ROOT, "webview-ui/agent-manager"), path.join(ROOT, "webview-ui/kiloclaw"), + path.join(ROOT, "webview-ui/marketplace"), path.join(ROOT, "webview-ui/diff-viewer"), path.join(ROOT, "webview-ui/diff-virtual"), path.join(REPO, "packages/kilo-ui/src/components"), @@ -28,6 +29,7 @@ const WATCHED_PROVIDERS = [ path.join(ROOT, "src/diff/DiffViewerProvider.ts"), path.join(ROOT, "src/DiffVirtualProvider.ts"), path.join(ROOT, "src/kiloclaw/KiloClawProvider.ts"), + path.join(ROOT, "src/MarketplacePanelProvider.ts"), ] const ALLOWED_DIRS = new Set(["stories"]) @@ -100,6 +102,22 @@ describe("webview font-size architecture", () => { ).toEqual([]) }) + it("uses scalable line heights in polished tool previews", () => { + const files = [ + path.join(REPO, "packages/kilo-ui/src/components/basic-tool.css"), + path.join(REPO, "packages/kilo-ui/src/components/message-part.css"), + ] + const violations = files.flatMap((file) => { + const src = stripComments(fs.readFileSync(file, "utf-8")) + return Array.from( + src.matchAll(/line-height\s*:\s*\d+(?:\.\d+)?px\b/g), + (match) => `${rel(file)}:${line(src, match.index ?? 0)}`, + ) + }) + + expect(violations).toEqual([]) + }) + it("injects and live-broadcasts the webview font-size setting to all webview providers", () => { const util = fs.readFileSync(path.join(ROOT, "src/utils.ts"), "utf-8") expect(util, "buildWebviewHtml must seed webview font tokens before app code runs").toContain("getWebviewFontSize") diff --git a/packages/kilo-vscode/tests/unit/fork-handoff.test.ts b/packages/kilo-vscode/tests/unit/fork-handoff.test.ts new file mode 100644 index 00000000000..9fb775cd220 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/fork-handoff.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, mock } from "bun:test" +import { forkText, recordForkHandoff } from "../../src/agent-manager/fork-handoff" + +describe("fork handoff", () => { + it("describes retained context without assuming a new task", () => { + const text = forkText({ directory: "/repo/.kilo/worktrees/feature" }) + + expect(text).toContain("This session was forked from an existing session in the current repository or worktree.") + expect(text).toContain("Use this as the current working directory: /repo/.kilo/worktrees/feature") + expect(text).toContain("this location supersedes any earlier repository or worktree location") + expect(text).toContain("The prior conversation context was retained intentionally.") + expect(text).toContain("continue the same task, explore an alternative approach, or provide new instructions") + expect(text).toContain("Follow the user's next instruction as the direction for this fork") + }) + + it("records a hidden no-reply handoff in the forked session", async () => { + const promptAsync = mock(async () => ({})) + const client = { session: { promptAsync } } + + await recordForkHandoff({ + client: client as never, + sessionId: "session-fork", + directory: "/repo/.kilo/worktrees/feature", + }) + + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "session-fork", + directory: "/repo/.kilo/worktrees/feature", + noReply: true, + parts: [ + { + type: "text", + text: forkText({ directory: "/repo/.kilo/worktrees/feature" }), + synthetic: true, + }, + ], + }, + { throwOnError: true }, + ) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/fork-session.test.ts b/packages/kilo-vscode/tests/unit/fork-session.test.ts new file mode 100644 index 00000000000..b5526c2688c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/fork-session.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, mock } from "bun:test" +import type { Session } from "@kilocode/sdk/v2/client" +import { forkText } from "../../src/agent-manager/fork-handoff" +import { forkSession, type ForkContext } from "../../src/agent-manager/fork-session" + +const noop = () => {} + +function session(id: string): Session { + return { id, title: id, createdAt: "", updatedAt: "" } as Session +} + +function ctx(client: unknown, overrides: Partial = {}): ForkContext { + return { + getClient: () => client as never, + state: undefined, + directory: "/repo", + postError: noop, + registerWorktreeSession: noop, + pushState: noop, + notifyForked: noop, + registerSession: noop, + log: noop, + ...overrides, + } +} + +describe("agent manager fork session", () => { + it("records the hidden handoff in the current repository", async () => { + const fork = mock(async () => ({ data: session("forked") })) + const promptAsync = mock(async () => ({})) + const client = { session: { fork, promptAsync } } + + await forkSession(ctx(client), "source", undefined, "message") + + expect(fork).toHaveBeenCalledWith( + { sessionID: "source", directory: "/repo", messageID: "message" }, + { throwOnError: true }, + ) + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "forked", + directory: "/repo", + noReply: true, + parts: [{ type: "text", text: forkText({ directory: "/repo" }), synthetic: true }], + }, + { throwOnError: true }, + ) + }) + + it("uses the selected worktree directory for the handoff", async () => { + const fork = mock(async () => ({ data: session("forked") })) + const promptAsync = mock(async () => ({})) + const client = { session: { fork, promptAsync } } + const state = { + getWorktree: () => ({ path: "/repo/.kilo/worktrees/feature" }), + addSession: mock(() => undefined), + } + + await forkSession(ctx(client, { state: state as never }), "source", "worktree") + + expect(fork).toHaveBeenCalledWith( + { sessionID: "source", directory: "/repo/.kilo/worktrees/feature" }, + { throwOnError: true }, + ) + expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ directory: "/repo/.kilo/worktrees/feature" }), { + throwOnError: true, + }) + }) + + it("still exposes the fork when recording the handoff fails", async () => { + const notify = mock(() => undefined) + const log = mock(() => undefined) + const client = { + session: { + fork: mock(async () => ({ data: session("forked") })), + promptAsync: mock(async () => { + throw new Error("handoff failed") + }), + }, + } + + await forkSession(ctx(client, { notifyForked: notify, log }), "source") + + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ id: "forked" }), "source", undefined) + expect(log).toHaveBeenCalledWith("forkSession: failed to record fork handoff:", "handoff failed") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/format-keybinding.test.ts b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts index 564b8a9ef3a..8320b94cd08 100644 --- a/packages/kilo-vscode/tests/unit/format-keybinding.test.ts +++ b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { formatKeybinding } from "../../src/agent-manager/format-keybinding" +import { buildKeybindingMap, formatKeybinding } from "../../src/agent-manager/format-keybinding" describe("formatKeybinding", () => { describe("mac", () => { @@ -70,3 +70,11 @@ describe("formatKeybinding", () => { }) }) }) + +describe("buildKeybindingMap", () => { + it("maps the configurable Agent Manager search shortcut", () => { + const bindings = [{ command: "kilo-code.new.agentManager.search", key: "ctrl+f", mac: "cmd+f" }] + expect(buildKeybindingMap(bindings, true).search).toBe("⌘F") + expect(buildKeybindingMap(bindings, false).search).toBe("Ctrl+F") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts index 7c59e3d4d0f..28c1d7f21b3 100644 --- a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts @@ -40,6 +40,7 @@ import { dict as appBs } from "../../webview-ui/src/i18n/bs" import { dict as appTr } from "../../webview-ui/src/i18n/tr" import { dict as appNl } from "../../webview-ui/src/i18n/nl" import { dict as appUk } from "../../webview-ui/src/i18n/uk" +import { dict as appIt } from "../../webview-ui/src/i18n/it" // Layer 2: upstream UI (@opencode-ai/ui re-exported via @kilocode/kilo-ui) import { dict as uiEn } from "../../../ui/src/i18n/en" @@ -61,6 +62,7 @@ import { dict as uiBs } from "../../../ui/src/i18n/bs" import { dict as uiTr } from "../../../ui/src/i18n/tr" import { dict as uiNl } from "../../../ui/src/i18n/nl" import { dict as uiUk } from "../../../ui/src/i18n/uk" +import { dict as uiIt } from "../../../ui/src/i18n/it" // Layer 3: kilo-i18n overrides import { dict as kiloEn } from "../../../kilo-i18n/src/en" @@ -82,6 +84,7 @@ import { dict as kiloBs } from "../../../kilo-i18n/src/bs" import { dict as kiloTr } from "../../../kilo-i18n/src/tr" import { dict as kiloNl } from "../../../kilo-i18n/src/nl" import { dict as kiloUk } from "../../../kilo-i18n/src/uk" +import { dict as kiloIt } from "../../../kilo-i18n/src/it" // Layer 4: agent manager (locale alignment already tested in agent-manager-i18n-split.test.ts) import { dict as amEn } from "../../webview-ui/agent-manager/i18n/en" @@ -110,6 +113,7 @@ import { dict as cliBs } from "../../src/services/cli-backend/i18n/bs" import { dict as cliTr } from "../../src/services/cli-backend/i18n/tr" import { dict as cliNl } from "../../src/services/cli-backend/i18n/nl" import { dict as cliUk } from "../../src/services/cli-backend/i18n/uk" +import { dict as cliIt } from "../../src/services/cli-backend/i18n/it" import { dict as acEn } from "../../src/services/autocomplete/i18n/en" @@ -137,6 +141,7 @@ const appLocales: Record> = { tr: appTr, nl: appNl, uk: appUk, + it: appIt, } const kiloLocales: Record> = { @@ -159,6 +164,7 @@ const kiloLocales: Record> = { tr: kiloTr, nl: kiloNl, uk: kiloUk, + it: kiloIt, } const uiLocales: Record> = { @@ -181,6 +187,7 @@ const uiLocales: Record> = { tr: uiTr, nl: uiNl, uk: uiUk, + it: uiIt, } const cliLocales: Record> = { @@ -203,6 +210,7 @@ const cliLocales: Record> = { tr: cliTr, nl: cliNl, uk: cliUk, + it: cliIt, } // Merge webview dictionaries in the same priority order as language.tsx diff --git a/packages/kilo-vscode/tests/unit/ime-enter-key.test.ts b/packages/kilo-vscode/tests/unit/ime-enter-key.test.ts new file mode 100644 index 00000000000..219f84ed011 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/ime-enter-key.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "bun:test" +import { isEnterKeyCommitNotIme } from "../../webview-ui/src/utils/ime-enter" + +describe("isEnterKeyCommitNotIme", () => { + it("is true for a normal Enter keydown", () => { + expect(isEnterKeyCommitNotIme({ key: "Enter", isComposing: false, keyCode: 13 })).toBe(true) + }) + + it("is false while isComposing is true", () => { + expect(isEnterKeyCommitNotIme({ key: "Enter", isComposing: true, keyCode: 13 })).toBe(false) + }) + + it("is false when keyCode is 229 (IME-processed key on Windows)", () => { + expect(isEnterKeyCommitNotIme({ key: "Enter", isComposing: false, keyCode: 229 })).toBe(false) + }) + + it("is false for non-Enter keys", () => { + expect(isEnterKeyCommitNotIme({ key: "a", isComposing: false, keyCode: 65 })).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/indexing-settings-message.test.ts b/packages/kilo-vscode/tests/unit/indexing-settings-message.test.ts new file mode 100644 index 00000000000..b80f479de07 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/indexing-settings-message.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { buildIndexingSettingsMessage, validIndexingSetting } from "../../src/kilo-provider/indexing-settings" + +type Stub = { + getConfiguration: (section?: string) => { + get: (key: string, fallback?: T) => T | undefined + } +} + +const original = vscode.workspace.getConfiguration + +function stubConfig(state: Map) { + ;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => { + if (section !== "kilo-code.new.indexing") { + return { get: (_key: string, fallback?: T) => fallback } + } + return { + get: (key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback), + } + } +} + +afterEach(() => { + ;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"] +}) + +describe("buildIndexingSettingsMessage", () => { + let state: Map + + beforeEach(() => { + state = new Map() + stubConfig(state) + }) + + it("shows the indexing button by default", () => { + expect(buildIndexingSettingsMessage().settings.showButtonWhenDisabled).toBe(true) + }) + + it("returns the persisted button preference", () => { + state.set("showButtonWhenDisabled", false) + + expect(buildIndexingSettingsMessage().settings.showButtonWhenDisabled).toBe(false) + }) +}) + +describe("validIndexingSetting", () => { + it("accepts only boolean button visibility updates", () => { + expect(validIndexingSetting("showButtonWhenDisabled", true)).toBe(true) + expect(validIndexingSetting("showButtonWhenDisabled", false)).toBe(true) + expect(validIndexingSetting("showButtonWhenDisabled", "false")).toBe(false) + expect(validIndexingSetting("unknown", true)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/indexing-tab-state.test.ts b/packages/kilo-vscode/tests/unit/indexing-tab-state.test.ts new file mode 100644 index 00000000000..35f0193cd33 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/indexing-tab-state.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test" +import { + indexingConfig, + indexingDescription, + indexingEnabled, + indexingEnabledInherited, + indexingInheritance, + indexingSource, + indexingUpdate, +} from "../../webview-ui/src/components/settings/indexing-tab-state" + +describe("indexing tab scope state", () => { + it("uses the global value when project enablement is inherited", () => { + expect(indexingEnabled("project", { enabled: true }, {})).toBe(true) + expect(indexingEnabled("project", { enabled: false }, {})).toBe(false) + expect(indexingEnabledInherited("project", { enabled: true }, {})).toBe(true) + expect(indexingEnabledInherited("project", { enabled: false }, {})).toBe(true) + }) + + it("uses explicit project overrides", () => { + expect(indexingEnabled("project", { enabled: true }, { enabled: false })).toBe(false) + expect(indexingEnabled("project", { enabled: false }, { enabled: true })).toBe(true) + expect(indexingEnabledInherited("project", { enabled: true }, { enabled: false })).toBe(false) + }) + + it("ignores project values in global scope", () => { + const global = { enabled: false, provider: "openai" as const, openai: { apiKey: "global" } } + const project = { enabled: true, provider: "ollama" as const, ollama: { baseUrl: "http://project" } } + + expect(indexingEnabled("global", global, project)).toBe(false) + expect(indexingEnabledInherited("global", global, {})).toBe(false) + expect(indexingConfig("global", global, project)).toEqual(global) + }) + + it("keeps inherited values out of project updates", () => { + expect( + indexingUpdate( + "project", + { enabled: true, provider: "openai", openai: { apiKey: "global" } }, + { qdrant: { url: "http://project" } }, + { enabled: false }, + ), + ).toEqual({ enabled: false, qdrant: { url: "http://project" } }) + }) + + it("preserves explicit null overrides and recursively inherits undefined leaves", () => { + expect( + indexingConfig( + "project", + { + model: "global-model", + dimension: 1024, + qdrant: { url: "http://global", apiKey: "global-secret" }, + }, + { + model: null, + dimension: null, + qdrant: { url: "http://project", apiKey: undefined }, + }, + ), + ).toEqual({ + model: null, + dimension: null, + qdrant: { url: "http://project", apiKey: "global-secret" }, + }) + }) + + it("classifies inherited and partially inherited fields", () => { + const global = { + provider: "openai-compatible" as const, + model: "global-model", + dimension: 1024, + "openai-compatible": { baseUrl: "https://global.test", apiKey: "secret" }, + } + const project = { + model: null, + "openai-compatible": { baseUrl: "https://project.test" }, + } + + expect(indexingInheritance("project", global, project, [["provider"]])).toBe("inherited") + expect(indexingInheritance("project", global, project, [["model"]])).toBe("none") + expect(indexingInheritance("project", global, project, [["dimension"]])).toBe("inherited") + expect( + indexingInheritance("project", global, project, [ + ["openai-compatible", "baseUrl"], + ["openai-compatible", "apiKey"], + ]), + ).toBe("partial") + expect(indexingInheritance("global", global, project, [["provider"]])).toBe("none") + expect(indexingInheritance("project", {}, {}, [["vectorStore"]])).toBe("none") + expect(indexingSource("project", global, project, [["provider"]])).toBe("global") + expect(indexingSource("project", global, project, [["model"]])).toBe("local") + expect( + indexingSource("project", global, project, [ + ["openai-compatible", "baseUrl"], + ["openai-compatible", "apiKey"], + ]), + ).toBe("mixed") + expect(indexingSource("project", {}, {}, [["vectorStore"]])).toBe("default") + expect(indexingSource("global", global, project, [["provider"]])).toBe("none") + expect(indexingDescription("Configure this value.", "inherited")).toBe( + "Configure this value. Inherited from global config.", + ) + }) + + it("merges inherited values with project overrides", () => { + expect( + indexingConfig( + "project", + { + enabled: true, + provider: "openai", + model: "global-model", + vectorStore: "qdrant", + openai: { apiKey: "global" }, + qdrant: { url: "http://global", apiKey: "global-secret" }, + }, + { provider: "ollama", qdrant: { url: "http://project" } }, + ), + ).toEqual({ + enabled: true, + provider: "ollama", + model: "global-model", + vectorStore: "qdrant", + openai: { apiKey: "global" }, + qdrant: { url: "http://project", apiKey: "global-secret" }, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts index cd91df1ecff..ad6551e802d 100644 --- a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import { applyIndexingStatusMessage, formatIndexingLabel, + indexingButtonVisible, indexingTone, } from "../../webview-ui/src/context/indexing-utils" import { mapSSEEventToWebviewMessage } from "../../src/kilo-provider-utils" @@ -19,6 +20,41 @@ function makeStatus(overrides: Partial = {}): IndexingStatus { } } +describe("indexing button visibility", () => { + it("hides the button when the indexing feature is unavailable", () => { + expect(indexingButtonVisible(false, true, { indexing: { enabled: true } }, { indexing: { enabled: true } })).toBe( + false, + ) + }) + + it("shows the button while indexing is off by default", () => { + expect(indexingButtonVisible(true, true, {}, {})).toBe(true) + }) + + it("hides the button when indexing is off and the preference is disabled", () => { + expect(indexingButtonVisible(true, false, {}, {})).toBe(false) + }) + + it("shows the button when project indexing is enabled", () => { + expect(indexingButtonVisible(true, false, { indexing: { enabled: true } }, {})).toBe(true) + expect(indexingButtonVisible(true, false, { indexing: { enabled: true } }, { indexing: { enabled: false } })).toBe( + true, + ) + }) + + it("stays hidden when global and project indexing are off", () => { + expect(indexingButtonVisible(true, false, { indexing: { enabled: false } }, { indexing: { enabled: false } })).toBe( + false, + ) + }) + + it("shows the button whenever global indexing is enabled", () => { + expect(indexingButtonVisible(true, false, { indexing: { enabled: false } }, { indexing: { enabled: true } })).toBe( + true, + ) + }) +}) + describe("indexing formatting", () => { it("formats in-progress status like the TUI", () => { const status = makeStatus({ state: "In Progress", percent: 42, processedFiles: 21, totalFiles: 50 }) @@ -85,37 +121,19 @@ describe("indexing SSE mapping", () => { }) describe("indexing feature detection", () => { - it("requires experimental.semantic_indexing when indexing plugin is present", () => { - expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(false) - expect(configFeatures({ plugin: ["kilo-indexing"], experimental: {} }).indexing).toBe(false) - expect(configFeatures({ plugin: ["kilo-indexing"], experimental: { semantic_indexing: false } }).indexing).toBe( - false, - ) + it("enables indexing settings when the indexing plugin is present", () => { + expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) }) - it("detects supported indexing plugin specifiers when experimental.semantic_indexing is true", () => { - expect(configFeatures({ plugin: ["kilo-indexing"], experimental: { semantic_indexing: true } }).indexing).toBe(true) - expect( - configFeatures({ plugin: ["kilo-indexing@1.2.3"], experimental: { semantic_indexing: true } }).indexing, - ).toBe(true) - expect( - configFeatures({ plugin: ["@kilocode/kilo-indexing"], experimental: { semantic_indexing: true } }).indexing, - ).toBe(true) - expect( - configFeatures({ plugin: ["@kilocode/kilo-indexing@1.2.3"], experimental: { semantic_indexing: true } }).indexing, - ).toBe(true) - expect( - configFeatures({ - plugin: ["file:///tmp/.opencode/plugin/kilo-indexing.js"], - experimental: { semantic_indexing: true }, - }).indexing, - ).toBe(true) - expect( - configFeatures({ - plugin: ["file:///tmp/node_modules/@kilocode/kilo-indexing/index.js"], - experimental: { semantic_indexing: true }, - }).indexing, - ).toBe(true) + it("detects supported indexing plugin specifiers", () => { + expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["kilo-indexing@1.2.3"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["@kilocode/kilo-indexing"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["@kilocode/kilo-indexing@1.2.3"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["file:///tmp/.opencode/plugin/kilo-indexing.js"] }).indexing).toBe(true) + expect(configFeatures({ plugin: ["file:///tmp/node_modules/@kilocode/kilo-indexing/index.js"] }).indexing).toBe( + true, + ) }) it("ignores unrelated plugin lists", () => { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 77ec50adf60..975cf56d6b6 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -69,6 +69,7 @@ function connection() { getServerInfo: () => ({ port: 12345 }), getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), getConnectionState: () => "connected" as const, + getConnectionError: () => null, resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined), recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index e3854e53015..31ed93fd4f0 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -10,7 +10,12 @@ type Internals = { cachedIndexingStatusMessage: unknown handleEvent: (event: unknown, directory?: string) => void reloadAfterAuthChange: () => Promise - handleUpdateConfig: (partial: Partial) => Promise + handleUpdateConfig: ( + partial: Partial, + project?: Partial, + globalUnset?: string[][], + projectUnset?: string[][], + ) => Promise fetchAndSendConfig: () => Promise fetchAndSendProviders: () => Promise fetchAndSendAgents: () => Promise @@ -22,6 +27,7 @@ type Internals = { function createConnection() { let drains = 0 + const patches: unknown[] = [] const client = { global: { config: { @@ -32,11 +38,17 @@ function createConnection() { config: { get: async () => ({ data: {} }), update: async () => ({ data: {} }), + overlay: async () => ({ data: { project: {} } }), + overlayUpdate: async (patch: unknown) => { + patches.push(patch) + return { data: {} } + }, }, } return { drains: () => drains, + patches: () => patches, service: { drainPendingPrompts: async () => { drains += 1 @@ -97,6 +109,48 @@ describe("KiloProvider indexing refresh", () => { expect(indexing).toBe(0) }) + it("refreshes providers when prompt-training model visibility changes", async () => { + const conn = createConnection() + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + let calls = 0 + internal.connectionState = "connected" + internal.fetchAndSendProviders = async () => { + calls += 1 + } + + await internal.handleUpdateConfig({ hide_prompt_training_models: true }) + + expect(calls).toBe(1) + }) + + it("passes scoped unset paths to the config overlay endpoint", async () => { + const conn = createConnection() + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + internal.connectionState = "connected" + + await internal.handleUpdateConfig( + { indexing: { qdrant: { apiKey: undefined } } }, + { indexing: { searchMinScore: undefined } }, + [["indexing", "qdrant", "apiKey"]], + [["indexing", "searchMinScore"]], + ) + + expect(conn.patches()).toEqual([ + expect.objectContaining({ + scope: "global", + set: { indexing: { qdrant: { apiKey: undefined } } }, + unset: [["indexing", "qdrant", "apiKey"]], + }), + expect.objectContaining({ + scope: "project", + set: { indexing: { searchMinScore: undefined } }, + unset: [["indexing", "searchMinScore"]], + }), + ]) + }) + it("fetchAndSendIndexingStatus uses current session directory header", async () => { const worktree = "/repo/.kilo/.kilocode/worktrees/feature" const calls: { input: RequestInfo | URL; init?: RequestInit }[] = [] diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 7bde1f4ad91..a11283907e2 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, spyOn } from "bun:test" +import type { PartUpdate } from "../../src/shared/stream-messages" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) -const { KiloProvider } = await import("../../src/KiloProvider") +const { KiloProvider, unwrapSyncEvent } = await import("../../src/KiloProvider") type State = "connecting" | "connected" | "disconnected" | "error" @@ -33,6 +34,21 @@ function mkMessage(id: string, role: "user" | "assistant", time = 0) { } } +function mkSession(revert?: { messageID: string }) { + return { + id: "s1", + slug: "session", + version: "1", + projectID: "project", + directory: "/repo", + title: "Session", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + revert, + } +} + function mkResult(items: unknown[]) { return { data: items, response: { headers: new Headers() } } } @@ -41,14 +57,22 @@ function createClient(options?: { messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }> messagesData?: unknown[] deleteDeferred?: Deferred + revertDeferred?: Deferred<{ data?: unknown; error?: unknown }> sessionData?: unknown sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }> + abortFailures?: string[] }) { const calls: { before?: string; limit?: number }[] = [] const stopped: { sessionID: string; directory?: string }[] = [] + const aborted: { sessionID: string; directory?: string }[] = [] + const prompted: Array> = [] + const reverted: Array> = [] return { calls, stopped, + aborted, + prompted, + reverted, session: { list: async () => ({ data: [] }), get: async (params: { sessionID: string; directory?: string }) => { @@ -56,6 +80,20 @@ function createClient(options?: { return { data: options?.sessionData ?? null } }, status: async () => ({ data: {} }), + revert: async (params: Record) => { + reverted.push(params) + if (options?.revertDeferred) return options.revertDeferred.promise + return { data: mkSession({ messageID: String(params.messageID) }) } + }, + promptAsync: async (params: Record) => { + prompted.push(params) + return { data: undefined } + }, + abort: async (params: { sessionID: string; directory?: string }) => { + aborted.push(params) + if (params.directory && options?.abortFailures?.includes(params.directory)) throw new Error("abort failed") + return { data: true } + }, messages: async (params: { before?: string; limit?: number }) => { calls.push({ before: params.before, limit: params.limit }) if (options?.messagesDeferred) return options.messagesDeferred.promise @@ -98,6 +136,7 @@ function createConnection(client: ReturnType) { registerDirectoryProvider: () => () => undefined, getServerInfo: () => ({ port: 12345 }), getConnectionState: () => "connected" as const, + getConnectionError: () => null, resolveEventSessionId: () => undefined, recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, @@ -110,11 +149,21 @@ function createConnection(client: ReturnType) { type ProviderInternals = { connectionState: State webview: { postMessage: (message: unknown) => Promise } | null - currentSession: { id: string; directory?: string } | null + currentSession: { id: string; directory?: string; revert?: { messageID: string } } | null contextSessionID: string | undefined sessionDirectories: Map trackedSessionIds: Set + checkpoints: Map> + revisions: Map + streams: { push: (msg: PartUpdate) => void } + checkpoint: (sid: string, run: () => Promise) => void + gatherEditorContext: () => Promise> + refreshSessionDetails: (sid: string, dir: string) => void stopCurrentSessionProcesses: (next?: string) => void + handleEvent: (event: unknown, directory?: string) => void + handleAbort: (sid?: string) => Promise + handleRevertSession: (sid: string, messageID: string) => Promise + handleSendMessage: (text: string, messageID?: string, sessionID?: string) => Promise handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -133,6 +182,304 @@ function makeProvider(client: ReturnType) { return { provider, internal, sent } } +describe("KiloProvider.handleAbort", () => { + it("aborts the original owner after a running session moves to a worktree", async () => { + const client = createClient() + const { provider, internal, sent } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + expect(sent.at(-1)).toMatchObject({ type: "sessionStatus", sessionID: "s1", status: "idle" }) + }) + + it("preserves the original owner when the status event lacks a directory", async () => { + const client = createClient() + const { provider, internal } = makeProvider(client) + internal.handleEvent({ + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + }) + + it("attempts every owner and stays busy when one abort fails", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}) + const client = createClient({ abortFailures: ["/repo"] }) + const { provider, internal, sent } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + expect(sent.at(-1)).toMatchObject({ type: "sessionStatus", sessionID: "s1", status: "busy" }) + expect(error).toHaveBeenCalledTimes(1) + error.mockRestore() + }) +}) + +describe("KiloProvider revert ordering", () => { + it("unwraps the nested sync payload emitted by the live SSE endpoint", () => { + const event = unwrapSyncEvent({ + type: "sync", + syncEvent: { + type: "session.updated.1", + id: "evt_clear", + seq: 0, + aggregateID: "sessionID", + data: { sessionID: "s1", info: { revert: null } }, + }, + }) + + expect(event).toEqual({ + source: "sync", + id: "evt_clear", + seq: 0, + type: "session.updated", + properties: { sessionID: "s1", info: { revert: null } }, + }) + }) + + it("waits for an in-flight revert before submitting the replacement prompt", async () => { + const revert = defer<{ data?: unknown; error?: unknown }>() + const client = createClient({ revertDeferred: revert }) + const { internal } = makeProvider(client) + internal.currentSession = mkSession() + internal.gatherEditorContext = async () => ({}) + + internal.checkpoint("s1", () => internal.handleRevertSession("s1", "m1")) + const send = internal.handleSendMessage("replacement", "m2", "s1") + await Promise.resolve() + await Promise.resolve() + + expect(client.reverted).toHaveLength(1) + expect(client.prompted).toHaveLength(0) + + revert.resolve({ data: mkSession({ messageID: "m1" }) }) + await send + + expect(client.prompted).toHaveLength(1) + expect(client.prompted[0]?.sessionID).toBe("s1") + }) + + it("waits for a revert queued while the replacement prompt gathers context", async () => { + const context = defer>() + const revert = defer<{ data?: unknown; error?: unknown }>() + const client = createClient({ revertDeferred: revert }) + const { internal } = makeProvider(client) + internal.currentSession = mkSession() + internal.gatherEditorContext = () => context.promise + + const send = internal.handleSendMessage("replacement", "m2", "s1") + await Promise.resolve() + internal.checkpoint("s1", () => internal.handleRevertSession("s1", "m1")) + context.resolve({}) + await Promise.resolve() + await Promise.resolve() + + expect(client.prompted).toHaveLength(0) + + revert.resolve({ data: mkSession({ messageID: "m1" }) }) + await send + + expect(client.prompted).toHaveLength(1) + }) + + it("does not submit the replacement prompt when the revert fails", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}) + const revert = defer<{ data?: unknown; error?: unknown }>() + const client = createClient({ revertDeferred: revert }) + const { internal, sent } = makeProvider(client) + internal.currentSession = mkSession() + internal.gatherEditorContext = async () => ({}) + + internal.checkpoint("s1", () => internal.handleRevertSession("s1", "m1")) + const send = internal.handleSendMessage("replacement", "m2", "s1") + await Promise.resolve() + revert.resolve({ error: new Error("revert failed") }) + await send + + expect(client.prompted).toHaveLength(0) + expect(sent).toContainEqual(expect.objectContaining({ type: "sendMessageFailed", messageID: "m2" })) + error.mockRestore() + }) + + it("does not restore a stale revert boundary after a newer clear update", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + internal.currentSession = mkSession({ messageID: "m1" }) + internal.trackedSessionIds.add("s1") + + internal.handleEvent({ + source: "sync", + id: "evt_000000000002", + seq: 0, + type: "session.updated", + properties: { sessionID: "s1", info: { revert: null } }, + }) + const count = sent.length + + internal.handleEvent({ + source: "sync", + id: "evt_000000000001", + seq: 0, + type: "session.updated", + properties: { sessionID: "s1", info: { revert: { messageID: "m1" } } }, + }) + internal.handleEvent({ + id: "evt_000000000001", + type: "session.updated", + properties: { sessionID: "s1", info: mkSession({ messageID: "m1" }) }, + }) + + expect(internal.currentSession?.revert).toBeUndefined() + expect(internal.revisions.get("s1")).toEqual({ id: "evt_000000000002", seq: 0 }) + expect(sent).toHaveLength(count) + expect(sent.at(-1)).toMatchObject({ type: "sessionUpdated", session: { id: "s1", revert: null } }) + }) + + it("uses sequence ordering for workspace-replayed session updates", () => { + const client = createClient() + const { internal } = makeProvider(client) + internal.currentSession = mkSession({ messageID: "m1" }) + internal.trackedSessionIds.add("s1") + + internal.handleEvent({ + source: "sync", + id: "evt_ffffffffffff", + seq: 1, + type: "session.updated", + properties: { sessionID: "s1", info: { revert: { messageID: "m1" } } }, + }) + internal.handleEvent({ + source: "sync", + id: "evt_000000000001", + seq: 2, + type: "session.updated", + properties: { sessionID: "s1", info: { revert: null } }, + }) + + expect(internal.currentSession?.revert).toBeUndefined() + expect(internal.revisions.get("s1")).toEqual({ id: "evt_000000000001", seq: 2 }) + }) + + it("publishes authoritative session state after a missed clear event", async () => { + const client = createClient({ sessionData: mkSession() }) + const { internal, sent } = makeProvider(client) + internal.currentSession = mkSession({ messageID: "m1" }) + internal.contextSessionID = "s1" + + internal.refreshSessionDetails("s1", "/repo") + await Promise.resolve() + await Promise.resolve() + + expect(internal.currentSession?.revert).toBeUndefined() + expect(sent.at(-1)).toMatchObject({ type: "sessionUpdated", session: { id: "s1", revert: null } }) + }) + + it("retries a focused session refresh after a concurrent session update", async () => { + const first = defer<{ data: unknown }>() + const second = defer<{ data: unknown }>() + let calls = 0 + const client = createClient({ + sessionGet: async () => { + calls += 1 + return calls === 1 ? first.promise : second.promise + }, + }) + const { internal } = makeProvider(client) + internal.currentSession = mkSession({ messageID: "m1" }) + internal.contextSessionID = "s1" + internal.trackedSessionIds.add("s1") + + internal.refreshSessionDetails("s1", "/repo") + internal.handleEvent({ + source: "sync", + id: "evt_000000000001", + seq: 0, + type: "session.updated", + properties: { sessionID: "s1", info: { title: "updated" } }, + }) + first.resolve({ data: mkSession() }) + await Bun.sleep(0) + expect(calls).toBe(2) + + second.resolve({ data: { ...mkSession(), title: "updated" } }) + await Bun.sleep(0) + + expect(internal.currentSession?.id).toBe("s1") + expect(internal.currentSession?.revert).toBeUndefined() + }) + + it("ignores an older session refresh that resolves last", async () => { + const first = defer<{ data: unknown }>() + const second = defer<{ data: unknown }>() + let calls = 0 + const client = createClient({ + sessionGet: async () => { + calls += 1 + return calls === 1 ? first.promise : second.promise + }, + }) + const { internal, sent } = makeProvider(client) + internal.currentSession = mkSession({ messageID: "m1" }) + internal.contextSessionID = "s1" + + internal.refreshSessionDetails("s1", "/repo") + internal.refreshSessionDetails("s1", "/repo") + second.resolve({ data: mkSession() }) + await Bun.sleep(0) + first.resolve({ data: mkSession({ messageID: "m1" }) }) + await Bun.sleep(0) + + expect(internal.currentSession?.revert).toBeUndefined() + expect(sent.filter((msg) => (msg as { type?: string }).type === "sessionUpdated")).toHaveLength(1) + }) + + it("ignores a session refresh superseded by a revert response", async () => { + const session = defer<{ data: unknown }>() + const client = createClient({ sessionGet: async () => session.promise }) + const { internal } = makeProvider(client) + internal.currentSession = mkSession() + internal.contextSessionID = "s1" + + internal.refreshSessionDetails("s1", "/repo") + await internal.handleRevertSession("s1", "m1") + session.resolve({ data: mkSession() }) + await Bun.sleep(0) + + expect(internal.currentSession?.revert).toEqual({ messageID: "m1" }) + }) +}) + describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { it("stops background processes for the previous session when switching sessions", async () => { const client = createClient({ @@ -301,30 +648,132 @@ describe("KiloProvider.handleDeleteSession / background processes", () => { }) }) -describe("KiloProvider.loadMessages / sub-agent viewer full history", () => { - it("loads all messages without the MESSAGE_PAGE_LIMIT cap (sub-agent viewer needs full turn history)", async () => { - // Regression: SubAgentViewerProvider used to call client.session.messages - // with no limit, loading every turn. After switching to provider.loadMessages - // it inherited the 80-message page cap and sub-agents with more than 80 - // turns would open truncated with no visible indicator. loadMessages() is - // the sub-agent viewer's single entry point — it must request the full - // transcript. - const big = Array.from({ length: 200 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i)) - const client = createClient({ messagesData: big }) +describe("KiloProvider.handleLoadMessages / slim payload", () => { + it("strips transcript-only metadata before posting messages to the webview", async () => { + const user = mkMessage("m1", "user", 1) + const assistant = mkMessage("m2", "assistant", 2) + const client = createClient({ + messagesData: [ + { + ...user, + info: { + ...user.info, + summary: { diffs: [{ file: "a.ts", patch: "full patch", additions: 2, deletions: 1 }] }, + }, + }, + { + ...assistant, + parts: [ + { + type: "reasoning", + id: "r1", + text: "Considering options", + metadata: { openai: { reasoningEncryptedContent: "encrypted", itemId: "item-1" } }, + }, + ], + }, + ], + }) const { provider, sent } = makeProvider(client) await provider.loadMessages("s1") const loaded = sent.find( (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", - ) as { messages: unknown[] } | undefined - expect(loaded).toBeDefined() - expect(loaded!.messages).toHaveLength(200) + ) as + | { + messages: Array<{ + summary?: { diffs?: Array> } + parts: Array<{ metadata?: { openai?: Record } }> + }> + } + | undefined + expect(loaded?.messages[0]?.summary?.diffs?.[0]).toEqual({ file: "a.ts", additions: 2, deletions: 1 }) + expect(loaded?.messages[1]?.parts[0]?.metadata?.openai).toEqual({ itemId: "item-1" }) + }) + + it("strips summary patches from live message updates", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + + internal.handleEvent({ + type: "message.updated", + properties: { + info: { + id: "m1", + sessionID: "s1", + role: "user", + time: { created: 1 }, + summary: { diffs: [{ file: "a.ts", patch: "full patch", additions: 2, deletions: 1 }] }, + }, + }, + }) + + const created = sent.find( + (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messageCreated", + ) as { message?: { summary?: { diffs?: Array> } } } | undefined + expect(created?.message?.summary?.diffs?.[0]).toEqual({ file: "a.ts", additions: 2, deletions: 1 }) + }) +}) + +describe("KiloProvider.loadMessages / sub-agent viewer", () => { + it("uses the same paginated initial load as normal sessions", async () => { + const page = Array.from({ length: 80 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i)) + const client = createClient({ messagesData: page }) + const { provider, sent } = makeProvider(client) + + await provider.loadMessages("s1") + + const loaded = sent.find( + (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", + ) as { messages: unknown[]; hasMore: boolean } | undefined + expect(loaded?.messages).toHaveLength(80) + expect(loaded?.hasMore).toBe(true) + expect(client.calls).toEqual([{ before: undefined, limit: 80 }]) + }) + + it("delivers reasoning updates received during the initial snapshot after messagesLoaded", async () => { + const pending = defer<{ data: unknown[]; response: { headers: Headers } }>() + const client = createClient({ messagesDeferred: pending }) + const { provider, internal, sent } = makeProvider(client) + const load = provider.loadMessages("s1") + + internal.streams.push({ + type: "partUpdated", + sessionID: "s1", + messageID: "m2", + part: { + id: "r1", + sessionID: "s1", + messageID: "m2", + type: "reasoning", + text: "Complete reasoning", + }, + }) + pending.resolve( + mkResult([ + mkMessage("m1", "user", 1), + { + ...mkMessage("m2", "assistant", 2), + parts: [ + { + id: "r1", + sessionID: "s1", + messageID: "m2", + type: "reasoning", + text: "", + }, + ], + }, + ]), + ) + await load - // Server contract: limit: 0 (or undefined) returns everything. - expect(client.calls).toHaveLength(1) - const limit = client.calls[0]?.limit - expect(limit === undefined || limit === 0).toBe(true) + const types = sent.map((msg) => (typeof msg === "object" && msg ? (msg as { type?: string }).type : undefined)) + const snapshot = types.indexOf("messagesLoaded") + const update = types.findIndex((type) => type === "partUpdated" || type === "partsUpdated") + expect(snapshot).toBeGreaterThanOrEqual(0) + expect(update).toBeGreaterThan(snapshot) }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-rename.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-rename.test.ts new file mode 100644 index 00000000000..a5d684bf71a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-rename.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test" +import { renameSession } from "../../src/kilo-provider/rename-session" +import { SESSION_TITLE_LIMIT } from "../../src/shared/session-title" + +type Params = { sessionID: string; directory?: string; title?: string } + +function client() { + const calls: Params[] = [] + return { + calls, + value: { + session: { + update: async (params: Params) => { + calls.push(params) + return { + data: { + id: params.sessionID, + title: params.title, + time: { created: 1, updated: 2 }, + }, + } + }, + }, + }, + } +} + +describe("renameSession", () => { + it("normalizes and persists a valid title through the backend client", async () => { + const api = client() + + const updated = await renameSession({ + client: api.value as never, + sessionID: "ses_1", + title: " Rename active session ", + directory: "/repo", + }) + + expect(api.calls).toHaveLength(1) + expect(api.calls[0]).toEqual({ sessionID: "ses_1", directory: "/repo", title: "Rename active session" }) + expect(updated.title).toBe("Rename active session") + }) + + it("rejects unsafe titles before they reach the backend", async () => { + const api = client() + const input = [" ", "a".repeat(SESSION_TITLE_LIMIT + 1), "Title\nSecond line", "Title\u202espoof"] + + for (const title of input) { + await expect( + renameSession({ client: api.value as never, sessionID: "ses_1", title, directory: "/repo" }), + ).rejects.toThrow("Invalid session title") + } + + expect(api.calls).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index b6e24bd2e8b..c4e2219355e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -91,6 +91,7 @@ function createConnection(client: ReturnType) { getServerInfo: () => ({ port: 12345 }), getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), getConnectionState: () => "connected" as const, + getConnectionError: () => null, resolveEventSessionId: () => undefined, recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index d54327ad3ed..d3b710d7805 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "bun:test" import { sessionToWebview, + applySessionPatch, + sessionPatchToWebview, indexProvidersById, filterVisibleAgents, buildSettingPath, @@ -18,9 +20,10 @@ import type { Agent, Provider, Event, - EventMessagePartUpdated, - EventMessageUpdated, + SyncEventMessagePartUpdated, + SyncEventMessageUpdated, EventSessionStatus, + EventSessionTurnClose, EventPermissionAsked, EventPermissionReplied, EventTodoUpdated, @@ -30,8 +33,8 @@ import type { EventSuggestionShown, EventSuggestionAccepted, EventSuggestionDismissed, - EventSessionCreated, - EventSessionUpdated, + SyncEventSessionCreated, + SyncEventSessionUpdated, EventServerConnected, TextPart, AssistantMessage, @@ -155,6 +158,121 @@ describe("sessionToWebview", () => { expect(() => new Date(result.createdAt)).not.toThrow() expect(new Date(result.createdAt).getTime()).toBe(1700000000000) }) + + it("clears optional state omitted from a full session snapshot", () => { + const result = sessionToWebview(makeSession()) + expect(result.revert).toBeNull() + expect(result.summary).toBeNull() + }) +}) + +describe("applySessionPatch", () => { + it("applies values without dropping unrelated session fields", () => { + const session = makeSession({ + workspaceID: "workspace-1", + cost: 1, + tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, + }) + + const result = applySessionPatch(session, { title: "Updated", cost: 2, time: { updated: 1700002000000 } }) + + expect(result.title).toBe("Updated") + expect(result.cost).toBe(2) + expect(result.workspaceID).toBe("workspace-1") + expect(result.tokens).toEqual(session.tokens) + expect(result.time).toEqual({ created: 1700000000000, updated: 1700002000000 }) + }) + + it("clears optional fields when the patch contains null", () => { + const session = makeSession({ + workspaceID: "workspace-1", + summary: { additions: 1, deletions: 2, files: 3 }, + cost: 4, + tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, + share: { url: "https://example.com" }, + agent: "code", + model: { id: "model-1", providerID: "provider-1" }, + permission: [], + revert: { messageID: "msg-1" }, + time: { created: 1, updated: 2, compacting: 3, archived: 4 }, + }) + + const result = applySessionPatch(session, { + workspaceID: null, + summary: null, + cost: null, + tokens: null, + share: { url: null }, + agent: null, + model: null, + permission: null, + revert: null, + time: { compacting: null, archived: null }, + }) + + expect(result.workspaceID).toBeUndefined() + expect(result.summary).toBeUndefined() + expect(result.cost).toBeUndefined() + expect(result.tokens).toBeUndefined() + expect(result.share).toBeUndefined() + expect(result.agent).toBeUndefined() + expect(result.model).toBeUndefined() + expect(result.permission).toBeUndefined() + expect(result.revert).toBeUndefined() + expect(result.time).toEqual({ created: 1, updated: 2 }) + }) + + it("ignores null patches for required session fields", () => { + const session = makeSession() + const result = applySessionPatch(session, { + slug: null, + projectID: null, + directory: null, + title: null, + version: null, + time: { created: null, updated: null }, + }) + + expect(result.slug).toBe(session.slug) + expect(result.projectID).toBe(session.projectID) + expect(result.directory).toBe(session.directory) + expect(result.title).toBe(session.title) + expect(result.version).toBe(session.version) + expect(result.time).toEqual(session.time) + }) + + it("does not mutate the original session", () => { + const session = makeSession({ revert: { messageID: "msg-1" }, time: { created: 1, updated: 2, compacting: 3 } }) + + applySessionPatch(session, { revert: null, time: { updated: 4, compacting: null } }) + + expect(session.revert).toEqual({ messageID: "msg-1" }) + expect(session.time).toEqual({ created: 1, updated: 2, compacting: 3 }) + }) +}) + +describe("sessionPatchToWebview", () => { + it("converts relevant patch timestamps and preserves explicit clears", () => { + const result = sessionPatchToWebview("sess-1", { + parentID: null, + summary: null, + revert: null, + time: { created: 1700000000000, updated: 1700001000000 }, + }) + + expect(result).toEqual({ + id: "sess-1", + parentID: null, + summary: null, + revert: null, + createdAt: new Date(1700000000000).toISOString(), + updatedAt: new Date(1700001000000).toISOString(), + }) + }) + + it("omits fields not present in the patch", () => { + expect(sessionPatchToWebview("sess-1", { cost: 2 })).toEqual({ id: "sess-1" }) + }) }) describe("indexProvidersById", () => { @@ -242,11 +360,17 @@ describe("buildSettingPath", () => { }) describe("mapSSEEventToWebviewMessage", () => { - it("maps message.part.updated to partUpdated", () => { - const event: EventMessagePartUpdated = { - type: "message.part.updated", - properties: { + it("maps message.part.updated.1 to partUpdated", () => { + const event: SyncEventMessagePartUpdated = { + type: "sync", + name: "message.part.updated.1", + id: "evt-part", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID: "sess-1", part: makeTextPart({ text: "hello" }), + time: 1700000000000, }, } const msg = mapSSEEventToWebviewMessage(event, "sess-1") @@ -257,18 +381,33 @@ describe("mapSSEEventToWebviewMessage", () => { } }) - it("returns null for message.part.updated when sessionID is undefined", () => { - const event: EventMessagePartUpdated = { - type: "message.part.updated", - properties: { part: makeTextPart({ text: "" }) }, + it("maps message.part.updated.1 without a tracked sessionID", () => { + const event: SyncEventMessagePartUpdated = { + type: "sync", + name: "message.part.updated.1", + id: "evt-part", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID: "sess-1", + part: makeTextPart({ text: "" }), + time: 1700000000000, + }, } - expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + const msg = mapSSEEventToWebviewMessage(event, undefined) + expect(msg?.type).toBe("partUpdated") + if (msg?.type === "partUpdated") expect(msg.sessionID).toBe("sess-1") }) - it("maps message.updated to messageCreated with ISO date", () => { - const event: EventMessageUpdated = { - type: "message.updated", - properties: { + it("maps message.updated.1 to messageCreated with ISO date", () => { + const event: SyncEventMessageUpdated = { + type: "sync", + name: "message.updated.1", + id: "evt-message", + seq: 1, + aggregateID: "sessionID", + data: { + sessionID: "sess-1", info: makeAssistantMessage({ cost: 0.001 }), }, } @@ -309,6 +448,16 @@ describe("mapSSEEventToWebviewMessage", () => { } }) + it("maps session.turn.close to its terminal reason", () => { + const event: EventSessionTurnClose = { + id: "evt-turn", + type: "session.turn.close", + properties: { sessionID: "sess-1", reason: "interrupted" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" }) + }) + it("maps permission.asked to permissionRequest", () => { const event: EventPermissionAsked = { type: "permission.asked", @@ -485,10 +634,14 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg?.type).toBe("suggestionResolved") }) - it("maps session.created to sessionCreated with ISO dates", () => { - const event: EventSessionCreated = { - type: "session.created", - properties: { info: makeSession() }, + it("maps session.created.1 to sessionCreated with ISO dates", () => { + const event: SyncEventSessionCreated = { + type: "sync", + name: "session.created.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-1", info: makeSession() }, } const msg = mapSSEEventToWebviewMessage(event, "sess-1") expect(msg?.type).toBe("sessionCreated") @@ -497,13 +650,16 @@ describe("mapSSEEventToWebviewMessage", () => { } }) - it("maps session.updated to sessionUpdated with ISO dates", () => { - const event: EventSessionUpdated = { - type: "session.updated", - properties: { info: makeSession({ id: "sess-2" }) }, + it("ignores session.updated.1 after backend state reconciliation", () => { + const event: SyncEventSessionUpdated = { + type: "sync", + name: "session.updated.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-2", info: { id: "sess-2", time: { updated: 1700001000000 } } }, } - const msg = mapSSEEventToWebviewMessage(event, "sess-2") - expect(msg?.type).toBe("sessionUpdated") + expect(mapSSEEventToWebviewMessage(event, "sess-2")).toBeNull() }) it("returns null for server.connected (no webview message)", () => { @@ -518,37 +674,46 @@ describe("mapSSEEventToWebviewMessage", () => { }) describe("isEventFromForeignProject", () => { - const session = (projectID: string) => - ({ - id: "s1", - projectID, - title: "test", - directory: "/workspace", - time: { created: 0, updated: 0 }, - }) as unknown as Session + function created(projectID: string): SyncEventSessionCreated { + return { + type: "sync", + name: "session.created.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-1", info: makeSession({ projectID }) }, + } + } - it("drops session.created from a different project", () => { - const event: Event = { type: "session.created", properties: { info: session("project-B") } } - expect(isEventFromForeignProject(event, "project-A")).toBe(true) + function updated(projectID: string): SyncEventSessionUpdated { + return { + type: "sync", + name: "session.updated.1", + id: "evt-session", + seq: 1, + aggregateID: "sessionID", + data: { sessionID: "sess-1", info: { projectID } }, + } + } + + it("drops session.created.1 from a different project", () => { + expect(isEventFromForeignProject(created("project-B"), "project-A")).toBe(true) }) - it("drops session.updated from a different project", () => { - const event: Event = { type: "session.updated", properties: { info: session("project-B") } } - expect(isEventFromForeignProject(event, "project-A")).toBe(true) + it("drops session.updated.1 from a different project", () => { + expect(isEventFromForeignProject(updated("project-B"), "project-A")).toBe(true) }) - it("keeps session.created from the same project", () => { - const event: Event = { type: "session.created", properties: { info: session("project-A") } } - expect(isEventFromForeignProject(event, "project-A")).toBe(false) + it("keeps session.created.1 from the same project", () => { + expect(isEventFromForeignProject(created("project-A"), "project-A")).toBe(false) }) it("keeps all events when expectedProjectID is undefined", () => { - const event: Event = { type: "session.created", properties: { info: session("project-B") } } - expect(isEventFromForeignProject(event, undefined)).toBe(false) + expect(isEventFromForeignProject(created("project-B"), undefined)).toBe(false) }) it("keeps non-session events regardless of project", () => { - const event = { type: "server.heartbeat", properties: {} } as unknown as Event + const event: EventServerConnected = { type: "server.connected", properties: {} } expect(isEventFromForeignProject(event, "project-A")).toBe(false) }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 784aa019e40..41e4cbd25f2 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -19,9 +19,17 @@ import path from "node:path" const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../../..") const KILO_UI_DIR = path.join(MONOREPO_ROOT, "packages/kilo-ui") +const BASIC_TOOL_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/basic-tool.tsx") const DATA_CONTEXT_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/context/data.tsx") const MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/message-part.tsx") const KILO_MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-part.tsx") +const KILO_MESSAGE_PART_CSS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-part.css") +const SHELL_ROLLING_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/shell-rolling-results.tsx") +const ASSISTANT_MESSAGE_FILE = path.join( + MONOREPO_ROOT, + "packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx", +) +const CHAT_LAYOUT_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/styles/chat-layout.css") function check(code: string): { ok: boolean; output: string } { const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", code], { @@ -137,6 +145,19 @@ describe("DataProvider contract (runtime)", () => { }) }) +describe("Assistant Markdown streaming contract (source)", () => { + const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + const block = + src.match( + /PART_MAPPING\["text"\]\s*=\s*function TextPartDisplay[\s\S]*?(?=\/\/ Expanded mode|PART_MAPPING\["reasoning"\])/, + )?.[0] ?? "" + + it("passes active text streams through Markdown's streaming mode", () => { + expect(block).not.toBe("") + expect(block).toContain("streaming={streaming()}") + }) +}) + describe("Edit tool diff-first click contract (source)", () => { const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") @@ -171,7 +192,7 @@ describe("Write and apply_patch patch rendering contracts (source)", () => { }) }) -describe("Bash tool syntax highlighting and section labels (source)", () => { +describe("Bash tool static terminal preview (source)", () => { const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") const block = src.match(/ToolRegistry\.register\(\{\s*name:\s*"bash"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? "" @@ -180,25 +201,39 @@ describe("Bash tool syntax highlighting and section labels (source)", () => { expect(block).toContain("BashHighlightedOutput") }) - it("BashHighlightedOutput uses shellscript grammar for commands without $ prefix", () => { - // The command should be highlighted as shellscript, but the $ prompt must - // NOT be inside the highlighted code (it breaks Shiki's parse context) - expect(src).toMatch(/data-lang="shellscript">\$\{escapeHtml\(cmd\)\}/) - expect(src).not.toMatch(/data-lang="shellscript">\$\s/) + it("does not animate expanded bash details", () => { + expect(block).toMatch(/allowPendingToggle\s+trigger=/) + expect(block).not.toMatch(/allowPendingToggle\s+animated/) + }) + + it("BashHighlightedOutput syntax highlights the command next to the prompt", () => { + expect(src).toContain('data-slot="bash-terminal" data-kind="command"') + expect(src).toContain('data-slot="bash-prompt"') + expect(src).toContain('data-slot="bash-section-code" data-scrollable ref={cmdRef}') + expect(src).toContain('data-lang="shellscript"') + expect(src).toContain("escapeHtml(cmd)") + }) + + it("BashHighlightedOutput syntax highlights log output", () => { + expect(src).toContain('data-slot="bash-terminal" data-kind="output"') + expect(src).toContain('data-slot="bash-section-code" data-scrollable ref={outRef}') + expect(src).toContain('data-lang="log"') + expect(src).toContain("escapeHtml(out)") }) - it("BashHighlightedOutput uses log grammar for output", () => { - expect(src).toMatch(/data-lang="log"/) + it("BashHighlightedOutput highlights only while expanded", () => { + expect(src).toContain("if (!props.active) return") + expect(block).toContain("active={open()}") }) - it("BashHighlightedOutput renders section labels matching MCP tool pattern", () => { - // Must use the same data-slot as MCP tools for consistent styling - expect(src).toMatch(/data-slot="mcp-section-label".*shell\.command/) - expect(src).toMatch(/data-slot="mcp-section-label".*shell\.output/) + it("BashHighlightedOutput keeps command and output in separate terminal containers", () => { + const slots = src.match(/data-slot="bash-terminal"/g) ?? [] + expect(slots).toHaveLength(2) }) - it("BashHighlightedOutput has edge-to-edge divider between sections", () => { - expect(src).toContain('data-slot="bash-divider"') + it("BashHighlightedOutput does not render shell section labels or a divider", () => { + expect(src).not.toMatch(/data-slot="mcp-section-label".*shell\./) + expect(src).not.toContain('data-slot="bash-divider"') }) it("BashHighlightedOutput supports openContent for opening output in editor", () => { @@ -218,6 +253,23 @@ describe("Bash tool syntax highlighting and section labels (source)", () => { }) }) +describe("Expanded tool motion and typography (source)", () => { + it("animates completed rolling shell details", () => { + const src = fs.readFileSync(SHELL_ROLLING_FILE, "utf-8") + expect(src).toContain("useCollapsible({") + expect(src).toContain("content: () => contentRef") + expect(src).toContain("body: () => bodyRef") + }) + + it("uses the assistant markdown line-height ratio for reasoning output", () => { + const css = fs.readFileSync(KILO_MESSAGE_PART_CSS_FILE, "utf-8") + const block = css.match( + /html\[data-theme="kilo-vscode"\] \[data-component="reasoning-part"\][\s\S]*?(?=@keyframes reasoning-pulse)/, + )?.[0] + expect(block).toMatch(/\[data-component="markdown"\]\s*\{[^}]*line-height:\s*160%;/) + }) +}) + describe("HighlightedText @mention regex fallback and click handler (source)", () => { const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") @@ -241,13 +293,47 @@ describe("HighlightedText @mention regex fallback and click handler (source)", ( expect(src).toMatch(/segment\.text\.replace\(\/\^@\//) }) - it("escapeHtml is imported from shared util, not duplicated", () => { - expect(src).toMatch(/import.*escapeHtml.*from.*util\/escape-html/) - // Must NOT contain a local function definition + it("does not duplicate HTML escaping helpers", () => { expect(src).not.toMatch(/function escapeHtml/) }) }) +describe("AssistantMessage visible row contract (source)", () => { + const src = fs.readFileSync(ASSISTANT_MESSAGE_FILE, "utf-8") + + it("filters suppressed tools that have no visible renderer", () => { + expect(src).toContain('state.status === "completed" && !!ToolRegistry.render(tool)') + }) + + it("filters pending questions until their dock request exists", () => { + expect(src).toContain('part.state.status !== "pending" && part.state.status !== "running"') + expect(src).toContain('matchToolRequest(part, "question", session.questions())') + }) + + it("filters completed synthetic text and redaction-only reasoning", () => { + expect(src).toContain('part.type === "text" && part.synthetic && props.message.time.completed') + expect(src).toContain('.text?.replace("[REDACTED]", "").trim()') + }) + + it("uses the plan exit card only when plan metadata is renderable", () => { + expect(src).toContain("if (!planExitInfo(part)) return") + }) +}) + +describe("Assistant transcript spacing contract (source)", () => { + const css = fs.readFileSync(CHAT_LAYOUT_FILE, "utf-8") + + it("uses a 6px gap between virtualized assistant rows", () => { + expect(css).toMatch(/\.vscode-session-turn\[data-row="assistant"\]\s*\{\s*padding-bottom: 6px;/) + }) + + it("removes spacing from assistant rows without visible content", () => { + expect(css).toMatch( + /\.vscode-session-turn\[data-row="assistant"\]:has\(> \.vscode-session-turn-assistant:empty\)\s*\{\s*padding-bottom: 0;/, + ) + }) +}) + describe("BasicTool export contract (runtime)", () => { it("BasicTool and GenericTool are exported from basic-tool", () => { const result = check(` @@ -266,3 +352,34 @@ describe("BasicTool export contract (runtime)", () => { expect(result.ok, `BasicTool export check failed: ${result.output}`).toBe(true) }) }) + +describe("Collapsed deferred tool details contract (source)", () => { + const basic = fs.readFileSync(BASIC_TOOL_FILE, "utf-8") + const message = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + + it("uses an explicit details hint before touching deferred children", () => { + expect(basic).toContain("hasDetails?: boolean") + expect(basic).toContain("props.hasDetails ?? !!hasChildren()") + expect(basic).toMatch(/\{props\.children\}<\/Show>/) + }) + + it("opts edit-family transcript cards into collapsed lazy details", () => { + for (const name of ["edit", "write", "apply_patch"]) { + const block = + message.match( + new RegExp(`ToolRegistry\\.register\\(\\{\\s*name:\\s*"${name}"[\\s\\S]*?(?=ToolRegistry\\.register\\(|$)`), + )?.[0] ?? "" + expect(block).toContain("defer") + expect(block).toContain("hasDetails") + } + }) + + it("lazy-mounts completed bash output and retains it after first expansion", () => { + const block = + message.match(/ToolRegistry\.register\(\{\s*name:\s*"bash"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? "" + expect(block).toContain("const [mounted, setMounted] = createSignal(open())") + expect(block).toMatch(/if \(open\(\) \|\| pending\(\)\) setMounted\(true\)/) + expect(block).toContain("hasDetails") + expect(block).toMatch(/[\s\S]*? {}, + refreshSessions: () => {}, + migrationCache: cache, + migrationCheckInFlight: false, + disposeGlobal: async () => {}, + broadcastComplete: () => {}, + } as unknown as MigrationContext +} + +describe("migration cache", () => { + it("isolates entries by operation and source", () => { + const cache: MigrationCache = new Map() + const entry: MigrationCacheEntry = { operationId: "new", source: "legacy", data: legacy } + cache.set("new", entry) + + expect(getMigrationCache(cache, "legacy", "new")).toBe(entry) + expect(getMigrationCache(cache, "roo", "new")).toBeUndefined() + expect(getMigrationCache(cache, "legacy", "stale")).toBeUndefined() + }) + + it("retains an empty Roo discovery for its operation", () => { + const cache: MigrationCache = new Map() + const entry: MigrationCacheEntry = { operationId: "empty", source: "roo", data: null } + cache.set("empty", entry) + + expect(getMigrationCache(cache, "roo", "empty")).toBe(entry) + expect(getMigrationCache(cache, "roo", "empty")?.data).toBeNull() + }) + + it("drops entries from abandoned operations when a new request arrives", async () => { + const cache: MigrationCache = new Map() + cache.set("abandoned", { operationId: "abandoned", source: "roo", data: null }) + + await handleRequestMigrationData(makeContext(cache), "roo", "fresh") + + expect(cache.has("abandoned")).toBe(false) + expect(getMigrationCache(cache, "roo", "fresh")).toBeDefined() + }) + + it("evicts an operation's entry once the migration completes", async () => { + const cache: MigrationCache = new Map() + cache.set("op", { operationId: "op", source: "roo", data: null }) + + await handleStartMigration(makeContext(cache), "roo", "op", { + providers: [], + mcpServers: [], + customModes: [], + sessions: [], + defaultModel: false, + settings: { + autoApproval: { + commandRules: false, + readPermission: false, + writePermission: false, + executePermission: false, + mcpPermission: false, + taskPermission: false, + }, + language: false, + autocomplete: false, + }, + }) + + expect(cache.has("op")).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/session-batch.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/session-batch.test.ts new file mode 100644 index 00000000000..dcec9a6eda8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/session-batch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" +import { runSessionBatch } from "../../../src/legacy-migration/session-batch" + +const sessions = [ + { id: "one", title: "One", directory: "/repo", time: 2 }, + { id: "two", title: "Two", directory: "/repo", time: 1 }, +] + +describe("migration session batch", () => { + it("uses consistent progress, skipped, error, and summary semantics", async () => { + const progress: unknown[] = [] + const phases: unknown[] = [] + const results = await runSessionBatch({ + selections: [{ id: "one" }, { id: "two" }], + sessions, + resolve: (id) => ({ id, dir: "/tasks" }), + migrate: async (selection) => + selection.id === "one" ? { ok: true, skipped: true } : { ok: false, message: "broken" }, + onProgress: (...args) => progress.push(args), + onSessionProgress: (value) => phases.push(value), + delay: async () => undefined, + }) + + expect(results).toEqual([ + { item: "One", category: "session", status: "warning", message: "Already imported." }, + { item: "Two", category: "session", status: "error", message: "broken" }, + ]) + expect(progress).toEqual([ + ["one", "migrating"], + ["one", "warning", "Already imported."], + ["two", "migrating"], + ["two", "error", "broken"], + ]) + expect(phases.at(-1)).toMatchObject({ session: sessions[1], index: 2, total: 2, phase: "summary" }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/task-store.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/task-store.test.ts new file mode 100644 index 00000000000..6a681eed93f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/task-store.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { listSessions, resolveSession, scanTaskStore } from "../../../src/legacy-migration/task-store" + +type Fs = typeof vscode.workspace.fs +const fs = vscode.workspace.fs as Fs +const original = { readDirectory: fs.readDirectory, readFile: fs.readFile, stat: fs.stat } +const dir = "/storage/kilocode.kilo-code/tasks" +const api = (id: string) => `${dir}/${id}/api_conversation_history.json` + +describe("task store history scan", () => { + afterEach(() => { + fs.readDirectory = original.readDirectory + fs.readFile = original.readFile + fs.stat = original.stat + }) + + it("includes only history items whose conversation file exists, without scanning or parsing disk", async () => { + let listed = false + let read = false + fs.readDirectory = async () => { + listed = true + return [] + } + fs.readFile = async () => { + read = true + throw new Error("history scan must not read files") + } + fs.stat = async (uri) => { + if (uri.fsPath === api("keep")) return { type: vscode.FileType.File, ctime: 0, mtime: 0, size: 1 } + throw new Error(`missing ${uri.fsPath}`) + } + + const items = [ + { id: "keep", task: "Keep me", workspace: "/repo", ts: 1700000000000 }, + { id: "gone", task: "Deleted on disk", workspace: "/repo", ts: 1700000000001 }, + ] + const scan = await scanTaskStore(dir, items, { mode: "history" }) + + // "gone" is dropped (no file) and on-disk orphans are never considered (no enumeration). + expect(listSessions(scan.catalog)).toEqual([ + { id: "keep", title: "Keep me", directory: "/repo", time: 1700000000000 }, + ]) + expect(resolveSession(scan.catalog, "keep")).toMatchObject({ id: "keep", dir }) + expect(scan.diagnostics).toEqual([]) + expect(listed).toBe(false) + expect(read).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index cd9e35f9490..1e00c06470d 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -2,7 +2,14 @@ import { describe, it, expect } from "bun:test" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import { diffSummary, diffFile, generatedLike, resolveBase, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff" +import { + createLocalDiff, + diffSummary, + diffFile, + generatedLike, + resolveBase, + MAX_DETAIL_BYTES, +} from "../../src/agent-manager/local-diff" import { GitOps } from "../../src/agent-manager/GitOps" import { WorktreeDiffReverter } from "../../src/diff/shared/reverter" import { resolveLocalDiffTarget } from "../../src/diff/shared/target" @@ -182,6 +189,21 @@ describe("diffSummary", () => { }) }) + it("classifies untracked files from content rather than their extension", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03])) + await fs.writeFile(path.join(dir, "notes.bin"), "plain text\n") + + const result = await diffSummary(git(), dir, base) + expect(result.find((entry) => entry.file === "tone.wav")?.additions).toBe(0) + expect(result.find((entry) => entry.file === "notes.bin")?.additions).toBe(1) + + const detail = await diffFile(git(), dir, base, "tone.wav") + expect(detail?.summarized).toBe(false) + expect(detail?.patch).toBe("") + }) + }) + it("all entries are summarized with empty before/after/patch", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "untracked.txt"), "x\n") @@ -200,6 +222,43 @@ describe("diffSummary", () => { }) }) + it("uses git numstat metadata for tracked binary files", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03])) + runSync(dir, ["add", "tone.wav"]) + runSync(dir, ["commit", "-m", "add audio"]) + + const summary = await diffSummary(git(), dir, base) + expect(summary.find((entry) => entry.file === "tone.wav")?.additions).toBe(0) + + const detail = await diffFile(git(), dir, base, "tone.wav") + expect(detail?.summarized).toBe(false) + expect(detail?.patch).toBe("") + }) + }) + + it("loads binary-safe before and after data for image diffs", async () => { + await withRepo(async (dir, base) => { + const before = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]) + const after = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]) + await fs.writeFile(path.join(dir, "banner.png"), before) + runSync(dir, ["add", "banner.png"]) + runSync(dir, ["commit", "-m", "add banner"]) + runSync(dir, ["branch", "-f", base]) + await fs.writeFile(path.join(dir, "banner.png"), after) + + const local = createLocalDiff(git()) + const summary = (await local.summary(dir, base)).find((entry) => entry.file === "banner.png") + const detail = await local.file(dir, base, "banner.png") + + expect(summary?.kind).toBe("image") + expect(summary?.summarized).toBe(true) + expect(detail?.summarized).toBe(false) + expect(detail?.image?.before?.data).toBe(before.toString("base64")) + expect(detail?.image?.after?.data).toBe(after.toString("base64")) + }) + }) + it("marks generated-like files via generatedLike flag", async () => { await withRepo(async (dir, base) => { await fs.mkdir(path.join(dir, "dist"), { recursive: true }) @@ -229,6 +288,20 @@ describe("diffFile", () => { }) }) + it("rejects image detail paths outside the repository", async () => { + await withRepo(async (dir, base) => { + const name = `${path.basename(dir)}-secret.png` + const secret = path.join(path.dirname(dir), name) + await fs.writeFile(secret, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])) + try { + expect(await diffFile(git(), dir, base, `../${name}`)).toBeNull() + expect(await diffFile(git(), dir, base, secret)).toBeNull() + } finally { + await fs.rm(secret, { force: true }) + } + }) + }) + it("returns before/after/patch for a modified tracked file", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "seed.txt"), "seed\nmore\n") @@ -260,6 +333,56 @@ describe("diffFile", () => { }) }) + it("loads full detail from the latest summary snapshot", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + const summary = await local.summary(dir, base) + const entry = summary.find((item) => item.file === "seed.txt") + const result = await local.file(dir, base, "seed.txt") + + expect(entry?.summarized).toBe(true) + expect(result?.summarized).toBe(false) + expect(result?.additions).toBe(entry?.additions) + expect(result?.deletions).toBe(entry?.deletions) + expect(result?.stamp).toBe(entry?.stamp) + expect(result?.before).toBe("seed\n") + expect(result?.after).toBe("seed\ncached\n") + expect(result?.patch).toContain("+cached") + }) + }) + + it("does not materialize binary detail from a cached summary", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03])) + const local = createLocalDiff(git()) + + await local.summary(dir, base) + const result = await local.file(dir, base, "tone.wav") + + expect(result?.summarized).toBe(false) + expect(result?.patch).toBe("") + expect(result?.before).toBe("") + expect(result?.after).toBe("") + }) + }) + + it("keeps summary snapshots isolated by worktree", async () => { + await withRepo(async (first, firstBase) => { + await withRepo(async (second, secondBase) => { + await fs.writeFile(path.join(first, "seed.txt"), "seed\nfirst\n") + await fs.writeFile(path.join(second, "seed.txt"), "seed\nsecond\n") + const local = createLocalDiff(git()) + + await local.summary(first, firstBase) + await local.summary(second, secondBase) + + expect((await local.file(first, firstBase, "seed.txt"))?.after).toBe("seed\nfirst\n") + expect((await local.file(second, secondBase, "seed.txt"))?.after).toBe("seed\nsecond\n") + }) + }) + }) + it("falls back to summarized entry when the working-copy file exceeds the detail cap", async () => { await withRepo(async (dir, base) => { // Write a tracked file that's ~2.5x the cap on the working-copy side. diff --git a/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts b/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts index 3354e61943c..f69cbdd92b7 100644 --- a/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts +++ b/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { isAnnotationMutation } from "../../webview-ui/agent-manager/markdown-annotation-mutation" +import { isAnnotationMutation } from "../../webview-ui/diff-viewer/markdown-annotation-mutation" function mutation(target: Node): Pick { return { target } diff --git a/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts b/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts index a584092361c..8a582935abc 100644 --- a/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts +++ b/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts @@ -24,6 +24,7 @@ import { join } from "node:path" */ describe("Markdown rAF-coalesced parse — regression guard", () => { const path = join(__dirname, "..", "..", "..", "ui", "src", "components", "markdown.tsx") + const helper = join(__dirname, "..", "..", "..", "ui", "src", "kilocode", "markdown-stream-highlight.ts") const stripComments = (src: string): string => src @@ -32,6 +33,7 @@ describe("Markdown rAF-coalesced parse — regression guard", () => { .replace(/^\s*\/\/.*$/gm, "") const src = stripComments(readFileSync(path, "utf8")) + const body = stripComments(readFileSync(helper, "utf8")) it("render effect uses requestAnimationFrame to coalesce parses", () => { // Locate the createEffect that owns the morphdom call. @@ -50,4 +52,11 @@ describe("Markdown rAF-coalesced parse — regression guard", () => { // rapid updates can collapse into it. expect(src).toMatch(/\b(pendingFrame|pendingContent)\b/) }) + + it("delegates streamed Shiki refreshes to the Kilo-owned helper", () => { + expect(src).toContain("preserveStreamingHighlight(fromEl, toEl, local.streaming ?? false)") + expect(body).toContain("export function preserveStreamingHighlight") + expect(body).toMatch(/continues\(before, after\)[\s\S]*queue\(from, after, lang\)/) + expect(body).toMatch(/const done = \(\) => \{\s*job\.busy = false\s*if \(!pre\.isConnected\) return/) + }) }) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts new file mode 100644 index 00000000000..d0f5962110b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import { + removeMarketplaceItem, + removeMarketplaceItemFromAllScopes, + type MarketplaceActionContext, + type MarketplaceRemoveContext, +} from "../../src/services/marketplace/actions" +import type { McpMarketplaceItem } from "../../src/services/marketplace/types" + +const project = "/repo" +const storage = vscode.Uri.file("/storage") +const local = `${project}/.kilo/mcp.json` +const legacy = `${project}/.kilocode/mcp.json` +const global = `${storage.fsPath}/settings/mcp_settings.json` +const item: McpMarketplaceItem = { + id: "memory", + type: "mcp", + name: "Memory", + description: "", + url: "", + content: "", +} +const fs = vscode.workspace.fs as unknown as { + readFile: (uri: vscode.Uri) => Promise + writeFile: (uri: vscode.Uri, data: Uint8Array) => Promise +} +const original = { readFile: fs.readFile, writeFile: fs.writeFile } + +function setup() { + const files = new Map([ + [local, JSON.stringify({ mcpServers: { memory: {}, keep: {} } })], + [legacy, JSON.stringify({ mcpServers: { memory: {}, keep: {} } })], + [global, JSON.stringify({ mcpServers: { memory: {}, keep: {} } })], + ]) + fs.readFile = async (uri) => { + const body = files.get(uri.fsPath) + if (!body) throw new Error("missing file") + return Buffer.from(body) + } + fs.writeFile = async (uri, data) => { + files.set(uri.fsPath, Buffer.from(data).toString("utf8")) + } + return files +} + +function has(files: Map, file: string) { + return !!JSON.parse(files.get(file)!).mcpServers.memory +} + +function connection() { + return { + getClientAsync: mock(async () => ({ + global: { config: { update: mock(async () => {}) } }, + instance: { dispose: mock(async () => {}) }, + })), + } as unknown as MarketplaceActionContext["connection"] +} + +afterEach(() => { + fs.readFile = original.readFile + fs.writeFile = original.writeFile +}) + +describe("Marketplace legacy MCP cleanup", () => { + it("preserves global legacy config during project removal", async () => { + const files = setup() + const ctx = { + connection: connection(), + marketplace: { remove: mock(async () => ({ success: true, slug: item.id })) }, + storage, + } as unknown as MarketplaceActionContext + + await removeMarketplaceItem(ctx, item, "project", project, project) + + expect(has(files, local)).toBe(false) + expect(has(files, legacy)).toBe(false) + expect(has(files, global)).toBe(true) + }) + + it("preserves project legacy config during global removal", async () => { + const files = setup() + const ctx = { + connection: connection(), + marketplace: { remove: mock(async () => ({ success: true, slug: item.id })) }, + storage, + } as unknown as MarketplaceActionContext + + await removeMarketplaceItem(ctx, item, "global", project, project) + + expect(has(files, local)).toBe(true) + expect(has(files, legacy)).toBe(true) + expect(has(files, global)).toBe(false) + }) + + it("removes project and global legacy config during sidebar cleanup", async () => { + const files = setup() + const ctx = { + connection: connection(), + remove: mock(async () => ({ success: true, slug: item.id })), + storage, + } as MarketplaceRemoveContext + + await removeMarketplaceItemFromAllScopes(ctx, item, project, project) + + expect(has(files, local)).toBe(false) + expect(has(files, legacy)).toBe(false) + expect(has(files, global)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts index 535162cfb11..1991576cece 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts @@ -4,6 +4,7 @@ import * as os from "os" import * as path from "path" import { MarketplaceInstaller } from "../../src/services/marketplace/installer" import { MarketplacePaths } from "../../src/services/marketplace/paths" +import { exec } from "../../src/util/process" const tmpDir = path.join(os.tmpdir(), `kilo-test-${Date.now()}`) @@ -17,11 +18,36 @@ class TestPaths extends MarketplacePaths { } } -describe("MarketplaceInstaller MCP format normalization", () => { - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true }).catch(() => {}) - }) +function skill(content: string, id = "test-skill") { + return { + type: "skill" as const, + id, + name: "Test Skill", + description: "test", + category: "test", + githubUrl: "https://example.com", + content, + displayName: "Test Skill", + displayCategory: "Test", + } +} +async function archive(): Promise { + const root = path.join(tmpDir, "archive") + const source = path.join(root, "source") + const dir = path.join(source, "skill") + const tarball = path.join(root, "skill.tar.gz") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "SKILL.md"), "# Test Skill\n") + await exec("tar", ["-czf", tarball, "-C", source, "skill"]) + return fs.readFile(tarball) +} + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe("MarketplaceInstaller MCP format normalization", () => { it("converts local command+args+env format to CLI format", async () => { const installer = new MarketplaceInstaller(new TestPaths()) const item = { @@ -94,3 +120,126 @@ describe("MarketplaceInstaller MCP format normalization", () => { expect(mcp).toEqual({ type: "local", command: ["npx", "-y", "someserver"], environment: { KEY: "val" } }) }) }) + +describe("MarketplaceInstaller skills", () => { + it("rejects project installs without a workspace directory", async () => { + const installer = new MarketplaceInstaller(new TestPaths()) + const result = await installer.installSkill(skill("https://example.com/skill.tar.gz"), "project") + + expect(result).toEqual({ + success: false, + slug: "test-skill", + error: "No workspace directory for project-scope install", + }) + }) + + it("rejects project removals without a workspace directory", async () => { + const installer = new MarketplaceInstaller(new TestPaths()) + const result = await installer.removeSkill(skill("https://example.com/skill.tar.gz"), "project") + + expect(result).toEqual({ + success: false, + slug: "test-skill", + error: "No workspace directory for project-scope removal", + }) + }) + + it("rejects project MCP and agent removals without a workspace directory", async () => { + const installer = new MarketplaceInstaller(new TestPaths()) + const results = await Promise.all([ + installer.remove( + { + type: "mcp", + id: "test-mcp", + name: "Test MCP", + description: "test", + url: "https://example.com", + content: "{}", + }, + "project", + ), + installer.remove( + { + type: "agent", + id: "test-agent", + name: "Test Agent", + description: "test", + content: { mode: "all", description: "test", prompt: "test" }, + }, + "project", + ), + ]) + + expect(results).toEqual([ + { success: false, slug: "test-mcp", error: "No workspace directory for project-scope removal" }, + { success: false, slug: "test-agent", error: "No workspace directory for project-scope removal" }, + ]) + }) + + it("rejects skill ids that are unsafe on supported filesystems", async () => { + const paths = new TestPaths() + const dir = path.join(paths.skillsDir("project", tmpDir), "installed") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "SKILL.md"), "# Installed\n") + const installer = new MarketplaceInstaller(paths) + + for (const id of [".", "installed.", "CON", "nul.txt"]) { + const result = await installer.removeSkill(skill("https://example.com/skill.tar.gz", id), "project", tmpDir) + expect(result).toEqual({ success: false, slug: id, error: "Invalid skill id" }) + } + + expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf-8")).toBe("# Installed\n") + }) + + it("installs an extracted project skill without leaving staging directories", async () => { + const buffer = await archive() + const url = `data:application/gzip;base64,${buffer.toString("base64")}` + const paths = new TestPaths() + const installer = new MarketplaceInstaller(paths) + const result = await installer.installSkill(skill(url), "project", tmpDir) + + expect(result.success).toBe(true) + expect(await fs.readFile(path.join(paths.skillsDir("project", tmpDir), "test-skill", "SKILL.md"), "utf-8")).toBe( + "# Test Skill\n", + ) + expect( + (await fs.readdir(paths.skillsDir("project", tmpDir))).filter((name) => name.startsWith(".staging-")), + ).toEqual([]) + }) + + it("handles concurrent installs without sharing temporary paths", async () => { + const buffer = await archive() + const original = globalThis.fetch + const paths = new TestPaths() + const installer = new MarketplaceInstaller(paths) + const item = skill("https://example.com/skill.tar.gz") + const gate = Promise.withResolvers() + let count = 0 + globalThis.fetch = async () => { + count += 1 + if (count === 2) gate.resolve() + await gate.promise + return new Response(buffer) + } + + try { + const results = await Promise.all([ + installer.installSkill(item, "project", tmpDir), + installer.installSkill(item, "project", tmpDir), + ]) + expect(count).toBe(2) + expect(results.filter((result) => result.success)).toHaveLength(1) + expect(results.find((result) => !result.success)?.error).toBe( + "Skill already installed. Uninstall it before installing again.", + ) + expect(await fs.readFile(path.join(paths.skillsDir("project", tmpDir), "test-skill", "SKILL.md"), "utf-8")).toBe( + "# Test Skill\n", + ) + expect( + (await fs.readdir(paths.skillsDir("project", tmpDir))).filter((name) => name.startsWith(".staging-")), + ).toEqual([]) + } finally { + globalThis.fetch = original + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts b/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts new file mode 100644 index 00000000000..a2f32985292 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const root = path.resolve(import.meta.dir, "../..") +const kilo = fs.readFileSync(path.join(root, "src/KiloProvider.ts"), "utf-8") +const panel = fs.readFileSync(path.join(root, "src/MarketplacePanelProvider.ts"), "utf-8") +const remove = fs.readFileSync(path.join(root, "src/kilo-provider/remove-config-item.ts"), "utf-8") + +describe("standalone Marketplace architecture", () => { + it("keeps Marketplace webview cases out of KiloProvider", () => { + for (const type of [ + "fetchMarketplaceData", + "installMarketplaceItem", + "removeInstalledMarketplaceItem", + "dismissAgentMigrationBanner", + ]) { + expect(kilo).not.toContain(`case \"${type}\"`) + expect(panel).toContain(`case \"${type}\"`) + } + }) + + it("uses a dedicated Marketplace webview bundle", () => { + expect(panel).toContain('"dist", "marketplace.js"') + expect(panel).not.toContain('"dist", "webview.js"') + }) + + it("keeps sidebar removal behind a narrow adapter", () => { + expect(kilo).toContain("removeAgent(this.removeConfigItemCtx, name)") + expect(kilo).toContain("removeMcp(this.removeConfigItemCtx, name)") + expect(remove).toContain("createMarketplaceRemover") + expect(remove).not.toContain("new MarketplaceService()") + expect(remove).not.toContain("AgentMarketplaceItem") + expect(remove).not.toContain("McpMarketplaceItem") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/mode-model.test.ts b/packages/kilo-vscode/tests/unit/mode-model.test.ts new file mode 100644 index 00000000000..9710bde9698 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/mode-model.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" + +import { modelPatch } from "../../webview-ui/src/components/settings/mode-model" + +describe("modelPatch", () => { + it("clears model and variant together", () => { + expect(modelPatch("", "", [], "high")).toEqual({ model: null, variant: null }) + }) + + it("keeps current variant when next model supports it", () => { + expect(modelPatch("kilo", "anthropic/claude-sonnet-4-6", ["low", "high"], "high")).toEqual({ + model: "kilo/anthropic/claude-sonnet-4-6", + }) + }) + + it("clears stale variant when next model does not support it", () => { + expect(modelPatch("kilo", "anthropic/claude-sonnet-4-6", ["low", "medium"], "high")).toEqual({ + model: "kilo/anthropic/claude-sonnet-4-6", + variant: null, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts b/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts new file mode 100644 index 00000000000..529e8b55658 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const root = path.resolve(import.meta.dir, "../..") +const preview = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelPreview.tsx"), "utf8") +const selector = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelSelector.tsx"), "utf8") +const agent = fs.readFileSync(path.join(root, "webview-ui/agent-manager/MultiModelSelector.tsx"), "utf8") +const icons = fs.readFileSync(path.join(root, "../kilo-ui/src/components/icon.tsx"), "utf8") +const styles = fs.readFileSync(path.join(root, "webview-ui/src/styles/model-selector.css"), "utf8") + +describe("model preview data collection line", () => { + it("shows a visible data collection row above context details", () => { + const data = preview.indexOf('class="model-preview-data-line"') + const context = preview.indexOf("model.preview.label.context") + + expect(data).toBeGreaterThanOrEqual(0) + expect(context).toBeGreaterThan(data) + expect(preview).toContain('Icon name="book-open-check"') + expect(preview).toContain("isDataCollectedModel(model())") + expect(preview).toContain('language.t("model.tag.dataCollected")') + expect(styles).toContain(".model-preview-data-line") + }) + + it("renders prompt training independently from the model badges", () => { + expect(selector).toContain("isDataCollectedModel(model)") + expect(preview).toContain("isDataCollectedModel(model())") + expect(agent).toContain("isDataCollectedModel(model)") + }) + + it("renders BYOK availability independently from training metadata", () => { + expect(selector).toContain("hasByok(model)") + expect(preview).toContain("hasByok(model())") + expect(agent).toContain("hasByok(model)") + expect(selector).toContain(">BYOK") + expect(preview).toContain(">BYOK") + expect(agent).toContain(">BYOK") + }) + + it("shows BYOK instead of Free when both metadata fields are set", () => { + expect(selector).toContain("isFree(model) && !hasByok(model)") + expect(preview).toContain("model().isFree && !hasByok(model())") + expect(agent).toContain("model.isFree && !hasByok(model)") + }) + + it("uses neutral colors for the main model picker BYOK badge", () => { + expect(selector).toContain("model-selector-data-badge--byok") + expect(styles).toContain(".model-selector-data-badge--byok") + expect(styles).toContain("background: var(--vscode-badge-background) !important") + expect(styles).toContain("color: var(--vscode-badge-foreground) !important") + }) + + it("uses the book open check icon for all webview model data disclosures", () => { + expect(selector).toContain('Icon name="book-open-check"') + expect(selector).not.toContain('Icon name="warning"') + expect(agent).toContain('Icon name="book-open-check"') + expect(agent).not.toContain('Icon name="warning"') + expect(icons).toContain('"book-open-check"') + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index 0af708775af..8fbbd5e51c4 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -6,6 +6,10 @@ import { sanitizeName, KILO_GATEWAY_ID, PROVIDER_ORDER, + freeDataLabel, + isDataCollectedModel, + hasByok, + isFree, } from "../../webview-ui/src/components/shared/model-selector-utils" const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } @@ -38,9 +42,9 @@ describe("providerSortKey", () => { }) it("sorts providers correctly when used with sort", () => { - const ids = ["google", "anthropic", "kilo", "openai", "github-copilot"] + const ids = ["google", "anthropic", "kilo", "openai", "deepseek"] const sorted = ids.slice().sort((a, b) => providerSortKey(a) - providerSortKey(b)) - expect(sorted).toEqual(["kilo", "anthropic", "github-copilot", "openai", "google"]) + expect(sorted).toEqual(["kilo", "anthropic", "deepseek", "openai", "google"]) }) }) @@ -94,6 +98,36 @@ describe("sanitizeName", () => { }) }) +describe("freeDataLabel", () => { + it("uses the data collection label without repeating free", () => { + expect(freeDataLabel("Free", "Data may be used for training")).toBe("Data may be used for training") + }) +}) + +describe("isFree", () => { + it("uses only explicit free metadata", () => { + expect(isFree({ isFree: true })).toBe(true) + expect(isFree({ isFree: false })).toBe(false) + expect(isFree({})).toBe(false) + }) +}) + +describe("isDataCollectedModel", () => { + it("uses only explicit prompt training metadata", () => { + expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true) + expect(isDataCollectedModel({ mayTrainOnYourPrompts: false })).toBe(false) + expect(isDataCollectedModel({})).toBe(false) + }) +}) + +describe("hasByok", () => { + it("uses only explicit user BYOK metadata", () => { + expect(hasByok({ hasUserByokAvailable: true })).toBe(true) + expect(hasByok({ hasUserByokAvailable: false })).toBe(false) + expect(hasByok({})).toBe(false) + }) +}) + describe("buildTriggerLabel", () => { it("returns resolved model name for non-kilo provider unchanged", () => { expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o") diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index 3d0e5e9eb8d..e059a5f814a 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -6,6 +6,7 @@ import { restoreLocalSessions, reconcileLocalSessions, filterUnassignedSessions, + remoteSessions, LOCAL, } from "../../webview-ui/agent-manager/navigate" @@ -396,6 +397,30 @@ describe("restoreLocalSessions", () => { }) }) +describe("remoteSessions", () => { + const pending = (id: string) => id.startsWith("pending:") + + it("returns every real tab without collapsing sessions in the same worktree", () => { + const result = remoteSessions( + ["local-1", "pending:1", "shared"], + [ + { id: "shared", worktreeId: "wt-1" }, + { id: "worktree-1", worktreeId: "wt-1" }, + { id: "worktree-2", worktreeId: "wt-1" }, + { id: "worktree-3", worktreeId: "wt-2" }, + { id: "closed-local", worktreeId: null }, + ], + pending, + ) + + expect(result).toEqual(["local-1", "shared", "worktree-1", "worktree-2", "worktree-3"]) + }) + + it("returns an empty list without open sessions", () => { + expect(remoteSessions([], [], pending)).toEqual([]) + }) +}) + describe("reconcileLocalSessions", () => { const isPending = (id: string) => id.startsWith("pending-") diff --git a/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts b/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts new file mode 100644 index 00000000000..d80a6a7a06a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-editable-region.test.ts @@ -0,0 +1,37 @@ +import { MAX_EDITABLE_REGION_LINES } from "../../src/services/autocomplete/next-edit/constants" +import { computeEditableRegion } from "../../src/services/autocomplete/next-edit/editableRegion" + +describe("computeEditableRegion", () => { + it("returns the default [-5, +10] window around the cursor", () => { + const r = computeEditableRegion({ cursorLine: 20, totalLines: 100 }) + expect(r.startLine).toBe(15) + expect(r.endLine).toBe(30) + }) + + it("clips at file start", () => { + const r = computeEditableRegion({ cursorLine: 2, totalLines: 50 }) + expect(r.startLine).toBe(0) + expect(r.endLine).toBe(12) + }) + + it("clips at file end", () => { + const r = computeEditableRegion({ cursorLine: 49, totalLines: 50 }) + expect(r.endLine).toBe(49) + expect(r.startLine).toBe(44) + }) + + it("caps the region at MAX_EDITABLE_REGION_LINES", () => { + const r = computeEditableRegion({ + cursorLine: 100, + totalLines: 1000, + topMargin: 100, + bottomMargin: 100, + }) + expect(r.endLine - r.startLine + 1).toBeLessThanOrEqual(MAX_EDITABLE_REGION_LINES) + }) + + it("handles an empty document gracefully", () => { + const r = computeEditableRegion({ cursorLine: 0, totalLines: 0 }) + expect(r).toEqual({ startLine: 0, endLine: 0 }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts new file mode 100644 index 00000000000..23acde6d8ba --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-history-tracker.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { EditHistoryTracker } from "../../src/services/autocomplete/next-edit/editHistoryTracker" + +type Doc = vscode.TextDocument & { setText(text: string): void } + +function doc(path: string, initial: string): Doc { + const state = { text: initial } + return { + uri: { fsPath: path, scheme: "file" }, + getText: () => state.text, + setText: (text: string) => { + state.text = text + }, + } as unknown as Doc +} + +function docs(...items: Doc[]): void { + ;(vscode.workspace.textDocuments as unknown as Doc[]).splice(0, Infinity, ...items) +} + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +afterEach(() => docs()) + +describe("EditHistoryTracker", () => { + it("retains chronological edits across files for Mercury context", async () => { + const a = doc("/workspace/a.ts", "const a = 1\n") + const b = doc("/workspace/b.ts", "const b = 1\n") + docs(a, b) + const tracker = new EditHistoryTracker({ isFileAllowed: async () => true }) + + await settle() + a.setText("const a = 2\n") + await tracker.flush(a) + b.setText("const b = 2\n") + await tracker.flush(b) + + const diffs = await tracker.getRecentDiffs() + expect(diffs).toHaveLength(2) + expect(diffs[0]).toContain("a.ts") + expect(diffs[0]).toContain("+const a = 2") + expect(diffs[1]).toContain("b.ts") + expect(diffs[1]).toContain("+const b = 2") + + tracker.dispose() + }) + + it("does not retain edits when the access policy is missing at runtime", async () => { + const a = doc("/workspace/a.ts", "const a = 1\n") + docs(a) + const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise }) + + await settle() + a.setText("const a = 2\n") + await tracker.flush(a) + + expect(await tracker.getRecentDiffs()).toEqual([]) + tracker.dispose() + }) + + it("never returns edits from denied documents", async () => { + const denied = new Set(["/workspace/.env"]) + const safe = doc("/workspace/app.ts", "const safe = 1\n") + const secret = doc("/workspace/.env", "TOKEN=old\n") + docs(safe, secret) + const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) }) + + await settle() + secret.setText("TOKEN=secret\n") + await tracker.flush(secret) + safe.setText("const safe = 2\n") + await tracker.flush(safe) + + const diffs = await tracker.getRecentDiffs() + expect(diffs).toHaveLength(1) + expect(diffs[0]).toContain("app.ts") + expect(diffs[0]).not.toContain("TOKEN=secret") + + denied.add("/workspace/app.ts") + expect(await tracker.getRecentDiffs()).toEqual([]) + + tracker.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts new file mode 100644 index 00000000000..f4d44fd5c95 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-inline-completion-provider.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../src/services/cli-backend" +import { + NextEditInlineCompletionProvider, + type NextEditProviderDeps, +} from "../../src/services/autocomplete/next-edit/NextEditInlineCompletionProvider" +import type { NextEditSuggestionManager } from "../../src/services/autocomplete/next-edit/NextEditSuggestionManager" + +type Subject = { + toCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + suggestion: { + replacement: string + editableRegionStartLine: number + editableRegionEndLine: number + latencyMs: number + }, + ): vscode.InlineCompletionItem[] | undefined +} + +function doc(text: string): vscode.TextDocument { + const lines = text.split("\n") + return { + lineCount: lines.length, + lineAt: (line: number) => ({ + text: lines[line], + range: { end: new vscode.Position(line, lines[line].length) }, + }), + getText: () => text, + uri: { fsPath: "/workspace/test.ts", scheme: "file" }, + } as unknown as vscode.TextDocument +} + +describe("NextEditInlineCompletionProvider", () => { + it("does not send a document when the access policy is missing at runtime", async () => { + const connection = { getClientAsync: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: connection, + } as unknown as NextEditProviderDeps) + + const out = await provider.provideInlineCompletionItems( + doc("const value = 1"), + new vscode.Position(0, 0), + {} as vscode.InlineCompletionContext, + {} as vscode.CancellationToken, + ) + + expect(out).toBeUndefined() + expect(connection.getClientAsync).not.toHaveBeenCalled() + provider.dispose() + }) + + it("does not send a document when the access policy fails", async () => { + const connection = { getClientAsync: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: connection as unknown as KiloConnectionService, + isFileAllowed: async () => Promise.reject(new Error("unavailable")), + }) + + const out = await provider.provideInlineCompletionItems( + doc("const value = 1"), + new vscode.Position(0, 0), + {} as vscode.InlineCompletionContext, + {} as vscode.CancellationToken, + ) + + expect(out).toBeUndefined() + expect(connection.getClientAsync).not.toHaveBeenCalled() + provider.dispose() + }) + + it("stashes same-line rewrites before the cursor for decorated acceptance", () => { + const mgr = { clear: mock(), setPending: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + const text = "const oldName = make()" + const document = { + lineCount: 1, + lineAt: () => ({ text, range: { end: new vscode.Position(0, text.length) } }), + getText: () => text, + } as unknown as vscode.TextDocument + + const out = (provider as unknown as Subject).toCompletionItems(document, new vscode.Position(0, 13), { + replacement: "const newName = make()", + editableRegionStartLine: 0, + editableRegionEndLine: 0, + latencyMs: 1, + }) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "const newName = make()" }), + ) + provider.dispose() + }) + + it("stashes complete-line deletion intent for acceptance", () => { + const mgr = { clear: mock(), setPending: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + + const out = (provider as unknown as Subject).toCompletionItems( + doc("before\nremove\nafter"), + new vscode.Position(1, 0), + { + replacement: "before\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }, + ) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "", removesLines: true }), + ) + provider.dispose() + }) + + it("does not classify a blank-line rewrite as deletion", () => { + const mgr = { clear: mock(), setPending: mock() } + const provider = new NextEditInlineCompletionProvider({ + connectionService: {} as KiloConnectionService, + isFileAllowed: async () => true, + suggestionManager: mgr as unknown as NextEditSuggestionManager, + }) + + const out = (provider as unknown as Subject).toCompletionItems( + doc("before\nremove\nafter"), + new vscode.Position(0, 0), + { + replacement: "before\n\nafter", + editableRegionStartLine: 0, + editableRegionEndLine: 2, + latencyMs: 1, + }, + ) + + expect(out).toBeUndefined() + expect(mgr.setPending).toHaveBeenCalledWith( + expect.objectContaining({ kind: "replace", replacement: "", removesLines: false }), + ) + provider.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts new file mode 100644 index 00000000000..1f6fdb0c6aa --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test" +import { planInsertion, planReplacement } from "../../src/services/autocomplete/next-edit/pendingEdit" + +describe("planInsertion", () => { + it("appends after the final unterminated line at EOF", () => { + const edit = planInsertion( + { diffStartLine: 2, replacement: "third\n" }, + { lineCount: 2, end: (line) => [5, 6][line] }, + ) + + expect(edit).toEqual({ line: 1, character: 6, text: "\nthird" }) + }) + + it("keeps insertion-before-line semantics for a trailing empty line", () => { + const edit = planInsertion( + { diffStartLine: 1, replacement: "second\n" }, + { lineCount: 2, end: (line) => [5, 0][line] }, + ) + + expect(edit).toEqual({ line: 1, character: 0, text: "second\n" }) + }) +}) + +describe("planReplacement", () => { + it("removes a middle line through the following separator", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true }, + { lineCount: 3, end: (line) => [6, 6, 5][line] }, + ) + + expect(edit).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + text: "", + }) + }) + + it("removes a final line through the preceding separator", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true }, + { lineCount: 2, end: (line) => [6, 6][line] }, + ) + + expect(edit).toEqual({ + start: { line: 0, character: 6 }, + end: { line: 1, character: 6 }, + text: "", + }) + }) + + it("preserves a line intentionally rewritten as blank", () => { + const edit = planReplacement( + { diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: false }, + { lineCount: 3, end: (line) => [6, 6, 5][line] }, + ) + + expect(edit).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 6 }, + text: "", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts new file mode 100644 index 00000000000..d5bfa69f270 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-edit-recent-snippets.test.ts @@ -0,0 +1,55 @@ +import { + toAllowedMercuryRecentSnippets, + toMercuryRecentSnippets, +} from "../../src/services/autocomplete/next-edit/recentSnippetsAdapter" + +describe("toMercuryRecentSnippets", () => { + it("returns an empty array when no snippets are supplied", () => { + expect(toMercuryRecentSnippets([])).toEqual([]) + }) + + it("caps the number of snippets at 5", () => { + const snippets = Array.from({ length: 12 }, (_, i) => ({ + filepath: `file://${i}.ts`, + content: `const x${i} = ${i}`, + })) + const out = toMercuryRecentSnippets(snippets) + expect(out.length).toBe(5) + }) + + it("reverses input order (service returns newest→oldest, Mercury wants oldest→newest)", () => { + const out = toMercuryRecentSnippets([ + { filepath: "a.ts", content: "newest" }, + { filepath: "b.ts", content: "middle" }, + { filepath: "c.ts", content: "oldest" }, + ]) + expect(out.map((s) => s.content)).toEqual(["oldest", "middle", "newest"]) + }) + + it("trims content above 20 lines to a centered window", () => { + const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n") + const [snippet] = toMercuryRecentSnippets([{ filepath: "x.ts", content }]) + const lines = snippet.content.split("\n") + expect(lines.length).toBe(20) + // Center: lines should be drawn from somewhere in the middle of the input. + expect(lines[0]).toMatch(/^line[12]\d$/) + }) + + it("passes through filepath verbatim when not a parsable URI", () => { + const [out] = toMercuryRecentSnippets([{ filepath: "not a uri", content: "x" }]) + expect(out.filepath).toBe("not a uri") + }) + + it("excludes denied snippets before constructing next edit request context", () => { + const out = toAllowedMercuryRecentSnippets( + [ + { filepath: "secrets/.env", content: "TOKEN=do-not-send" }, + { filepath: "src/app.ts", content: "const safe = true" }, + ], + (path) => !path.endsWith(".env"), + ) + + expect(out).toEqual([{ filepath: "src/app.ts", content: "const safe = true" }]) + expect(JSON.stringify(out)).not.toContain("do-not-send") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/part-stash.test.ts b/packages/kilo-vscode/tests/unit/part-stash.test.ts index 58d25f6aff8..8e51214c19c 100644 --- a/packages/kilo-vscode/tests/unit/part-stash.test.ts +++ b/packages/kilo-vscode/tests/unit/part-stash.test.ts @@ -26,6 +26,15 @@ describe("PartStash", () => { expect(stash.size()).toBe(0) }) + it("removePart() keeps off-screen removals authoritative", () => { + const stash = new PartStash() + stash.put("m1", [text("p1", "m1", "drop"), text("p2", "m1", "keep")]) + stash.removePart("m1", "p1") + expect(stash.peek("m1")?.map((part) => part.id)).toEqual(["p2"]) + stash.removePart("m1", "p2") + expect(stash.peek("m1")).toEqual([]) + }) + it("take() consumes stashed parts atomically", () => { const stash = new PartStash() stash.put("m1", [text("p1", "m1", "a")]) diff --git a/packages/kilo-vscode/tests/unit/permission-description.test.ts b/packages/kilo-vscode/tests/unit/permission-description.test.ts index 34d6742f1fa..d67145ed20c 100644 --- a/packages/kilo-vscode/tests/unit/permission-description.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-description.test.ts @@ -1,9 +1,11 @@ import { describe, test, expect } from "bun:test" import { describePatterns, + describeRule, resolveLabel, TOOL_LABEL_KEYS, } from "../../webview-ui/src/components/chat/permission-dock-utils" +import { resolveTemplate } from "../../webview-ui/src/context/language-utils" // Mock t() that returns the English label for known keys, or the key itself const labels: Record = { @@ -24,8 +26,10 @@ const labels: Record = { "ui.permission.toolLabel.task": "Task", "ui.permission.toolLabel.skill": "Skill", "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?", + "ui.permission.doomLoop.rule": "Continue {{tool}} calls", } -const t = (key: string) => labels[key] ?? key +const t = (key: string, params?: Record) => resolveTemplate(labels[key] ?? key, params) describe("describePatterns", () => { test("returns null when patterns is empty", () => { @@ -95,6 +99,14 @@ describe("describePatterns", () => { expect(result).toEqual({ kind: "single", text: "Web Search query" }) }) + test("doom loop describes the repeated tool with its human-readable label", () => { + const result = describePatterns("doom_loop", ["read"], t) + expect(result).toEqual({ + kind: "single", + text: "Potential loop detected for the Read tool. Continue running?", + }) + }) + test("TOOL_LABEL_KEYS maps all expected tools", () => { const expected: Record = { read: "ui.permission.toolLabel.read", @@ -162,6 +174,17 @@ describe("describePatterns", () => { }) }) +describe("describeRule", () => { + test("describes doom loop rules as the action being allowed or denied", () => { + expect(describeRule("doom_loop", "read", t)).toBe("Continue Read calls") + }) + + test("preserves existing permission rule labels", () => { + expect(describeRule("read", "*", t)).toBe("Read") + expect(describeRule("read", "src/app.ts", t)).toBe("Read src/app.ts") + }) +}) + describe("resolveLabel", () => { test("returns translated label for known tool", () => { expect(resolveLabel("read", t)).toBe("Read") diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 68e5b4052e7..df7a44d25d6 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, spyOn } from "bun:test" import { fetchAndSendPendingPermissions, + handlePermissionResponse, recoverablePermissions, recoveryDirs, type RecoverablePermission, @@ -20,16 +21,32 @@ function pending(id: string, sessionID: string, permission = "bash"): Recoverabl } } -function permissionClient(permsPerDir: Record[]>, queries: string[]) { +function permissionClient( + permsPerDir: Record[]>, + queries: string[], + saves: unknown[] = [], + replies: unknown[] = [], + errors?: { list?: Record; save?: unknown; reply?: unknown }, +) { return { permission: { list: async (args?: { directory?: string }) => { const dir = args?.directory ?? "" queries.push(dir) + const error = errors?.list?.[dir] + if (error) return { data: undefined, error } return { data: permsPerDir[dir] ?? [] } }, - saveAlwaysRules: async () => ({ data: true }), - reply: async () => ({ data: true }), + saveAlwaysRules: async (args: unknown) => { + saves.push(args) + if (errors?.save) throw errors.save + return { data: true } + }, + reply: async (args: unknown) => { + replies.push(args) + if (errors?.reply) throw errors.reply + return { data: true } + }, }, } } @@ -37,8 +54,11 @@ function permissionClient(permsPerDir: Record function client( permsPerDir: Record[]>, queries: string[], + saves: unknown[] = [], + replies: unknown[] = [], + errors?: { list?: Record; save?: unknown; reply?: unknown }, ): PermissionContext["client"] { - return permissionClient(permsPerDir, queries) as unknown as PermissionContext["client"] + return permissionClient(permsPerDir, queries, saves, replies, errors) as unknown as PermissionContext["client"] } function ctx(opts: { @@ -46,11 +66,15 @@ function ctx(opts: { dirs?: Map permsPerDir?: Record[]> workspace?: string + errors?: { list?: Record; save?: unknown; reply?: unknown } + extra?: string[] }) { const messages: unknown[] = [] const queries: string[] = [] + const saves: unknown[] = [] + const replies: unknown[] = [] const perms = opts.permsPerDir ?? {} - const sdk = client(perms, queries) + const sdk = client(perms, queries, saves, replies, opts.errors) const permDirs = new Map() const fake: PermissionContext = { @@ -58,6 +82,7 @@ function ctx(opts: { currentSessionId: undefined, trackedSessionIds: new Set(opts.tracked), sessionDirectories: opts.dirs ?? new Map(), + extraDirectories: () => opts.extra ?? [], postMessage: (msg) => messages.push(msg), getWorkspaceDirectory: () => opts.workspace ?? "/workspace", recordPermissionDirectory: (id, dir) => permDirs.set(id, dir), @@ -65,14 +90,20 @@ function ctx(opts: { clearPermissionDirectory: (id) => { permDirs.delete(id) }, - prunePermissionDirectories: (active) => { - for (const key of permDirs.keys()) { - if (!active.has(key)) permDirs.delete(key) + prunePermissionDirectories: (active, dirs) => { + for (const [key, dir] of permDirs) { + if (active.has(key)) { + continue + } + if (dirs && !dirs.has(dir)) { + continue + } + permDirs.delete(key) } }, } - return { fake, messages, queries, permDirs } + return { fake, messages, queries, saves, replies, permDirs } } describe("recoveryDirs", () => { @@ -92,6 +123,94 @@ describe("recoveryDirs", () => { "/workspace/.kilo/worktrees/beta", ]) }) + + it("includes extra worktree directories", () => { + const dirs = new Map([["s1", "/workspace/.kilo/worktrees/alpha"]]) + expect(recoveryDirs("/workspace", dirs, ["/workspace/.kilo/worktrees/beta", "/workspace"])).toEqual([ + "/workspace", + "/workspace/.kilo/worktrees/alpha", + "/workspace/.kilo/worktrees/beta", + ]) + }) +}) + +describe("handlePermissionResponse", () => { + it("uses the recorded SSE directory instead of a stale session fallback", async () => { + const { fake, replies, permDirs } = ctx({ tracked: ["s1"] }) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", [], []) + + expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + }) + + it("saves selected rules and replies in the recorded SSE directory", async () => { + const { fake, saves, replies, permDirs } = ctx({ tracked: ["s1"] }) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "reject", ["bun *"], ["rm *"]) + + expect(saves).toEqual([ + { + requestID: "p1", + directory: "/workspace/.kilo/worktrees/feature", + approvedAlways: ["bun *"], + deniedAlways: ["rm *"], + }, + ]) + expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }]) + }) + + it("treats an SDK-wrapped 404 while saving rules as stale", async () => { + const error = new Error("Permission request not found: p1", { + cause: { status: 404, body: { name: "NotFoundError" } }, + }) + const { fake, messages, saves, replies, permDirs } = ctx({ tracked: ["s1"], errors: { save: error } }) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", ["bun *"], []) + + expect(saves).toEqual([ + { + requestID: "p1", + directory: "/workspace/.kilo/worktrees/feature", + approvedAlways: ["bun *"], + deniedAlways: [], + }, + ]) + expect(replies).toEqual([]) + expect(permDirs.has("p1")).toBe(false) + expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) + }) + + it("treats an SDK-wrapped 404 while replying as stale", async () => { + const error = new Error("Permission request not found: p1", { + cause: { status: 404, body: { name: "NotFoundError" } }, + }) + const { fake, messages, replies, permDirs } = ctx({ tracked: ["s1"], errors: { reply: error } }) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", [], []) + + expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(permDirs.has("p1")).toBe(false) + expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) + }) + + it("does not treat other SDK-wrapped errors as stale", async () => { + const error = new Error("Internal server error", { + cause: { status: 500, body: { name: "InternalServerError" } }, + }) + const { fake, messages, permDirs } = ctx({ tracked: ["s1"], errors: { reply: error } }) + const spy = spyOn(console, "error").mockImplementation(() => {}) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", [], []) + spy.mockRestore() + + expect(permDirs.has("p1")).toBe(true) + expect(messages).toEqual([{ type: "permissionError", permissionID: "p1" }]) + }) }) describe("recoverablePermissions", () => { @@ -129,6 +248,36 @@ describe("fetchAndSendPendingPermissions", () => { expect(queries).toHaveLength(3) }) + it("queries extra Agent Manager worktree directories", async () => { + const { fake, queries, permDirs } = ctx({ + tracked: ["s1"], + extra: ["/workspace/.kilo/worktrees/late"], + permsPerDir: { "/workspace/.kilo/worktrees/late": [pending("p1", "s1")] }, + }) + await fetchAndSendPendingPermissions(fake) + expect(queries).toEqual(["/workspace", "/workspace/.kilo/worktrees/late"]) + expect(permDirs.get("p1")).toBe("/workspace/.kilo/worktrees/late") + }) + + it("preserves cached routes for directories that fail to list", async () => { + const dirs = new Map([["s1", "/workspace/.kilo/worktrees/failing"]]) + const error = new Error("temporary failure") + const { fake, permDirs } = ctx({ + tracked: ["s1"], + dirs, + errors: { list: { "/workspace/.kilo/worktrees/failing": error } }, + }) + const spy = spyOn(console, "error").mockImplementation(() => {}) + permDirs.set("workspace-stale", "/workspace") + permDirs.set("worktree-pending", "/workspace/.kilo/worktrees/failing") + + await fetchAndSendPendingPermissions(fake) + spy.mockRestore() + + expect(permDirs.has("workspace-stale")).toBe(false) + expect(permDirs.get("worktree-pending")).toBe("/workspace/.kilo/worktrees/failing") + }) + it("deduplicates directories", async () => { const dirs = new Map([ ["s1", "/workspace/.kilo/worktrees/alpha"], @@ -182,6 +331,7 @@ describe("fetchAndSendPendingPermissions", () => { currentSessionId: undefined, trackedSessionIds: new Set(["s1"]), sessionDirectories: new Map(), + extraDirectories: () => [], postMessage: (msg) => messages.push(msg), getWorkspaceDirectory: () => "/workspace", recordPermissionDirectory: (id, dir) => permDirs.set(id, dir), @@ -189,9 +339,15 @@ describe("fetchAndSendPendingPermissions", () => { clearPermissionDirectory: (id) => { permDirs.delete(id) }, - prunePermissionDirectories: (active) => { - for (const key of permDirs.keys()) { - if (!active.has(key)) permDirs.delete(key) + prunePermissionDirectories: (active, dirs) => { + for (const [key, dir] of permDirs) { + if (active.has(key)) { + continue + } + if (dirs && !dirs.has(dir)) { + continue + } + permDirs.delete(key) } }, } diff --git a/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts index 638ce3ae2c4..afdaccef76d 100644 --- a/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts @@ -7,6 +7,7 @@ import { dict as de } from "@kilocode/kilo-i18n/de" import { dict as en } from "@kilocode/kilo-i18n/en" import { dict as es } from "@kilocode/kilo-i18n/es" import { dict as fr } from "@kilocode/kilo-i18n/fr" +import { dict as it } from "@kilocode/kilo-i18n/it" import { dict as ja } from "@kilocode/kilo-i18n/ja" import { dict as ko } from "@kilocode/kilo-i18n/ko" import { dict as nl } from "@kilocode/kilo-i18n/nl" @@ -28,6 +29,7 @@ const dicts: Record> = { en, es, fr, + it, ja, ko, nl, diff --git a/packages/kilo-vscode/tests/unit/project-directory.test.ts b/packages/kilo-vscode/tests/unit/project-directory.test.ts new file mode 100644 index 00000000000..306d5a38d74 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-directory.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { resolvePanelProjectDirectory, resolveProjectDirectory } from "../../src/project-directory" + +describe("project directory resolution", () => { + const folders = [{ uri: { fsPath: "/repo-a" } }, { uri: { fsPath: "/repo-b" } }] + + it("prefers the active editor project", () => { + expect(resolvePanelProjectDirectory("/repo-b", folders)).toBe("/repo-b") + }) + + it("uses the only open workspace", () => { + expect(resolvePanelProjectDirectory(undefined, [folders[0]])).toBe("/repo-a") + }) + + it("disables project scope when a multi-root workspace is ambiguous", () => { + expect(resolvePanelProjectDirectory(undefined, folders)).toBeNull() + }) + + it("preserves an explicit null project override", () => { + expect(resolveProjectDirectory(null, () => "/repo-a")).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts new file mode 100644 index 00000000000..9fa283f73ef --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx") +const src = readFileSync(path, "utf8") + +describe("PromptInput connection guard", () => { + it("rechecks the connection after resolving async attachments and before clearing the draft", () => { + const attachments = src.indexOf("const gitFile = await git.resolveAttachment") + const guard = src.indexOf("if (isDisabled()) return", attachments) + const send = src.indexOf("session.sendMessage(message", guard) + const clear = src.indexOf("drafts.delete(key)", send) + + expect(attachments).toBeGreaterThan(-1) + expect(guard).toBeGreaterThan(attachments) + expect(send).toBeGreaterThan(guard) + expect(clear).toBeGreaterThan(send) + }) +}) + +describe("PromptInput sandbox toggle", () => { + it("writes only the sandbox patch without saving pending settings drafts", () => { + const start = src.indexOf("") + const end = src.indexOf("", start) + const toggle = src.slice(start, end) + + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + expect(toggle).toContain("vscode.postMessage({") + expect(toggle).toContain('type: "updateConfig"') + expect(toggle).toContain("config: { experimental: { sandbox: !sandbox() } }") + expect(toggle).not.toContain("saveConfig") + expect(toggle).not.toContain("updateConfig(") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index 9ccd6448092..f38a45a1793 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -171,30 +171,34 @@ describe("isPromptBlocked", () => { describe("isPromptBusy", () => { it("returns true when busy and neither suggesting nor questioning", () => { - expect(isPromptBusy("busy", false, false)).toBe(true) + expect(isPromptBusy("busy", false, false, false)).toBe(true) + }) + + it("returns true while submitting before the backend reports busy", () => { + expect(isPromptBusy("idle", false, false, true)).toBe(true) }) it("returns false when idle regardless of suggesting/questioning", () => { - expect(isPromptBusy("idle", false, false)).toBe(false) - expect(isPromptBusy("idle", true, false)).toBe(false) - expect(isPromptBusy("idle", false, true)).toBe(false) - expect(isPromptBusy("idle", true, true)).toBe(false) + expect(isPromptBusy("idle", false, false, false)).toBe(false) + expect(isPromptBusy("idle", true, false, false)).toBe(false) + expect(isPromptBusy("idle", false, true, false)).toBe(false) + expect(isPromptBusy("idle", true, true, false)).toBe(false) }) it("returns false when busy but suggesting is true (suggestion decoupling)", () => { - expect(isPromptBusy("busy", true, false)).toBe(false) + expect(isPromptBusy("busy", true, false, false)).toBe(false) }) it("returns false when busy but questioning is true (question decoupling)", () => { - expect(isPromptBusy("busy", false, true)).toBe(false) + expect(isPromptBusy("busy", false, true, false)).toBe(false) }) it("returns false when busy and both suggesting and questioning", () => { - expect(isPromptBusy("busy", true, true)).toBe(false) + expect(isPromptBusy("busy", true, true, false)).toBe(false) }) it("returns true for non-idle non-busy status when not suggesting/questioning", () => { - expect(isPromptBusy("retry", false, false)).toBe(true) + expect(isPromptBusy("retry", false, false, false)).toBe(true) }) }) diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index 375a44efdcf..a6499bab1c6 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test" -import { connectProvider, disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" +import { + connectProvider, + disconnectProvider, + fetchProviderData, + resolveStoredKey, + saveCustomProvider, +} from "../../src/provider-actions" type ExistingGlobal = { disabled_providers?: string[]; provider?: Record } @@ -48,6 +54,9 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }, merged }), auth: async () => ({ data: {} }), }, + kilo: { + authStatus: async () => ({ data: { authenticated: false } }), + }, global: { config: { get: async () => ({ data: existing }), @@ -237,7 +246,7 @@ describe("saveCustomProvider", () => { expect(payload.myprovider.models["model-gone"]).toBeNull() }) - it("emits null sentinels for variants removed from a model that still exists", async () => { + it("emits null sentinels when reasoning and variants are removed from a model", async () => { const existing = { disabled_providers: [], provider: { @@ -264,11 +273,7 @@ describe("saveCustomProvider", () => { name: "My Provider", options: { baseURL: "https://example.com/v1" }, models: { - "model-1": { - name: "Model One", - reasoning: true, - variants: { high: { reasoningEffort: "high" } }, - }, + "model-1": { name: "Model One" }, }, } await saveCustomProvider(ctx, "req", "myprovider", next, undefined, false, null, setCachedConfig) @@ -277,11 +282,11 @@ describe("saveCustomProvider", () => { const model = ( calls.config[0].config.provider as Record< string, - { models: Record }> } + { models: Record }> } > ).myprovider.models["model-1"] - expect(model.variants).toBeDefined() - expect(model.variants?.high).toBeDefined() + expect(model.reasoning).toBeNull() + expect(model.variants?.high).toBeNull() expect(model.variants?.low).toBeNull() }) @@ -433,6 +438,9 @@ describe("fetchProviderData", () => { }), auth: async () => ({ data: {} }), }, + kilo: { + authStatus: async () => ({ data: { authenticated: false } }), + }, } as unknown as Parameters[0] const result = await fetchProviderData(client, "/tmp") @@ -441,4 +449,116 @@ describe("fetchProviderData", () => { expect(result.authStates).toEqual({ "groq-test": "api" }) expect("key" in item).toBe(false) }) + + it("uses local Kilo auth status instead of profile availability", async () => { + const client = { + provider: { + list: async () => ({ + data: { + all: [{ id: "kilo", name: "Kilo Gateway", source: "custom", env: [], models: {} }], + connected: ["kilo"], + default: { kilo: "kilo-auto/frontier" }, + }, + }), + auth: async () => ({ data: {} }), + }, + kilo: { + authStatus: async () => ({ data: { authenticated: true, type: "oauth" } }), + }, + } as unknown as Parameters[0] + + const result = await fetchProviderData(client, "/tmp") + + expect(result.authStates).toEqual({ kilo: "oauth" }) + }) + + it("does not infer Kilo speech access without stored Gateway auth", async () => { + const client = { + provider: { + list: async () => ({ + data: { + all: [{ id: "kilo", name: "Kilo Gateway", source: "config", key: "configured", env: [], models: {} }], + connected: ["kilo"], + default: { kilo: "kilo-auto/frontier" }, + }, + }), + auth: async () => ({ data: {} }), + }, + kilo: { + authStatus: async () => ({ data: { authenticated: false } }), + }, + } as unknown as Parameters[0] + + const result = await fetchProviderData(client, "/tmp") + + expect(result.authStates).toEqual({}) + }) + + it("retains stripped keys for providers with a configured baseURL", async () => { + const client = { + provider: { + list: async () => ({ + data: { + all: [ + { + id: "myprovider", + name: "My Provider", + source: "config", + key: "sk-stored", + env: [], + options: { baseURL: "https://example.com/v1" }, + models: {}, + }, + { + id: "no-url", + name: "No URL", + source: "config", + key: "sk-other", + env: [], + models: {}, + }, + ], + connected: [], + default: {}, + }, + }), + auth: async () => ({ data: {} }), + }, + kilo: { + authStatus: async () => ({ data: { authenticated: false } }), + }, + } as unknown as Parameters[0] + + const result = await fetchProviderData(client, "/tmp") + + expect(result.storedKeys).toEqual({ + myprovider: { key: "sk-stored", baseURL: "https://example.com/v1" }, + }) + expect(result.response.all.every((item) => !("key" in (item as Record)))).toBe(true) + }) +}) + +describe("resolveStoredKey", () => { + const storedKeys = { + myprovider: { key: "sk-stored", baseURL: "https://example.com/v1" }, + } + + it("returns the stored key when the fetch URL matches the configured baseURL", () => { + expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v1")).toBe("sk-stored") + }) + + it("tolerates trailing-slash differences", () => { + expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v1/")).toBe("sk-stored") + }) + + it("refuses to apply the stored key to a different host or path", () => { + expect(resolveStoredKey(storedKeys, "myprovider", "https://evil.example.net/v1")).toBeUndefined() + expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v2")).toBeUndefined() + }) + + it("returns undefined for unknown or missing provider ids", () => { + expect(resolveStoredKey(storedKeys, "other", "https://example.com/v1")).toBeUndefined() + expect(resolveStoredKey(storedKeys, undefined, "https://example.com/v1")).toBeUndefined() + expect(resolveStoredKey(storedKeys, "", "https://example.com/v1")).toBeUndefined() + }) }) diff --git a/packages/kilo-vscode/tests/unit/provider-catalog.test.ts b/packages/kilo-vscode/tests/unit/provider-catalog.test.ts new file mode 100644 index 00000000000..9716022fff6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/provider-catalog.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" + +import type { Provider } from "../../webview-ui/src/types/messages" +import { + isPopularProvider, + popularProviderIndex, + sortProviders, +} from "../../webview-ui/src/components/settings/provider-catalog" + +function provider(id: string, metadata?: Provider["metadata"]): Provider { + return { + id, + name: id, + models: {}, + metadata, + } +} + +describe("provider catalog", () => { + it("treats known provider objects as popular when metadata is unavailable", () => { + expect(isPopularProvider(provider("openai"))).toBe(true) + expect(isPopularProvider(provider("anthropic"))).toBe(true) + expect(isPopularProvider(provider("unknown"))).toBe(false) + }) + + it("uses fallback ordering for provider objects without metadata", () => { + const items = [provider("openai"), provider("anthropic"), provider("unknown")] + const ids = sortProviders(items).map((item) => item.id) + + expect(ids).toEqual(["anthropic", "openai", "unknown"]) + }) + + it("prefers metadata priority over fallback ordering", () => { + expect(popularProviderIndex(provider("openai", { priority: 1 }))).toBe(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/question-dock-contract.test.ts b/packages/kilo-vscode/tests/unit/question-dock-contract.test.ts index fb9b4ca761f..24337ca281b 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-contract.test.ts @@ -55,7 +55,7 @@ describe("QuestionDock explicit submit contract", () => { expect(reset, "reject should restore the agent before sending the dismissal").toBeLessThan(sending) }) - it("keeps the footer Submit button wired to submit()", () => { - expect(source).toContain(' @@ -2599,7 +2578,11 @@ const AgentManagerContent: Component = () => { {t("agentManager.session.openInWorktree")} - openLocally(s.id)}> + + openLocally(s.id), + )} + > {t("agentManager.session.openLocally")} @@ -2639,14 +2622,6 @@ const AgentManagerContent: Component = () => {
        -
        @@ -2703,8 +2678,8 @@ const AgentManagerContent: Component = () => { newTerminalLabel: t("agentManager.terminal.new"), newSessionMenuLabel: t("agentManager.session.newSession"), moreOptionsLabel: t("agentManager.tab.newOptions"), - onNewSession: handleAddSession, - onNewTerminal: () => termHandlers.requestNew(), + onNewSession: metrics.click("new_session", "tab_bar", handleAddSession), + onNewTerminal: metrics.click("embedded_terminal", "new_tab_menu", () => termHandlers.requestNew()), })}
        @@ -2730,7 +2705,7 @@ const AgentManagerContent: Component = () => { <> - @@ -2766,7 +2741,14 @@ const AgentManagerContent: Component = () => { variant="ghost" icon={active() ? "stop" : "play"} disabled={rs()?.state === "stopping"} - onClick={() => runWorktree(rid())} + onClick={metrics.click( + "run_script", + "tab_toolbar", + () => runWorktree(rid()), + () => ({ + action: active() ? "stop" : configured() ? "run" : "configure", + }), + )} > {active() ? "Stop" : "Run"} @@ -2786,7 +2768,9 @@ const AgentManagerContent: Component = () => { /> - + {t("agentManager.run.configure")} @@ -2805,6 +2789,9 @@ const AgentManagerContent: Component = () => {
        diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx index cbce7dbb7ab..b27ff31cdd1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx @@ -4,7 +4,7 @@ import { Button } from "@kilocode/kilo-ui/button" import { Spinner } from "@kilocode/kilo-ui/spinner" import type { AgentManagerApplyWorktreeDiffStatus, WorktreeFileDiff } from "../src/types/messages" import { useLanguage } from "../src/context/language" -import { FileTree } from "./FileTree" +import { FileTree } from "../diff-viewer/FileTree" import { mapApplyConflictReason, type ApplyConflictRow } from "./apply-conflicts" interface ApplyDialogProps { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/CurrentTabsMenu.tsx b/packages/kilo-vscode/webview-ui/agent-manager/CurrentTabsMenu.tsx deleted file mode 100644 index cc09f7a5f1f..00000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/CurrentTabsMenu.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/** @jsxImportSource solid-js */ - -import { For, Show, createEffect, createMemo, createSignal } from "solid-js" -import type { Accessor, Component } from "solid-js" -import { Popover } from "@kilocode/kilo-ui/popover" -import { Spinner } from "@kilocode/kilo-ui/spinner" -import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import type { PermissionRequest, QuestionRequest, SessionInfo, SessionStatusInfo } from "../src/types/messages" -import type { TerminalStateControls } from "./terminal" - -interface CurrentTabItem { - id: string - title: string - status?: string - working: boolean - tone: "active" | "busy" | "waiting" | "idle" -} - -interface CurrentTabItemsDeps { - tabIds: Accessor - tabLookup: Accessor> - statusMap: Accessor> - permissions: Accessor - questions: Accessor - visibleTabId: Accessor - terms: TerminalStateControls - reviewId: string - isTerminal: (id: string) => boolean - isPending: (id: string) => boolean - t: (key: string) => string -} - -interface FocusTabDeps { - id: string - terms: TerminalStateControls - isTerminal: (id: string) => boolean - isPending: (id: string) => boolean - reviewId: string - reviewOpen: Accessor - setReviewOpen: (open: boolean) => void - setReviewActive: (active: boolean) => void - tabLookup: Accessor> - setActivePendingId: (id: string | undefined) => void - clearSession: () => void - selectSession: (id: string) => void - activateTerminal: (id: string) => void -} - -export function focusCurrentTab(deps: FocusTabDeps) { - if (deps.isTerminal(deps.id)) { - deps.activateTerminal(deps.id) - return - } - deps.terms.setActiveId(undefined) - if (deps.id === deps.reviewId) { - if (!deps.reviewOpen()) deps.setReviewOpen(true) - deps.setReviewActive(true) - return - } - const target = deps.tabLookup().get(deps.id) - if (!target) return - deps.setReviewActive(false) - if (deps.isPending(target.id)) { - deps.setActivePendingId(target.id) - deps.clearSession() - return - } - deps.setActivePendingId(undefined) - deps.selectSession(target.id) -} - -export const createCurrentTabItems = (deps: CurrentTabItemsDeps): Accessor => - createMemo(() => { - const statuses = deps.statusMap() - const perms = deps.permissions() - const qs = deps.questions() - return deps - .tabIds() - .map((id) => buildItem(id, statuses, perms, qs, deps)) - .filter((item): item is CurrentTabItem => item !== undefined) - }) - -const basicItem = (id: string, title: string, deps: CurrentTabItemsDeps): CurrentTabItem => ({ - id, - title, - working: false, - tone: id === deps.visibleTabId() ? "active" : "idle", -}) - -function buildReviewItem(id: string, deps: CurrentTabItemsDeps) { - return basicItem(id, deps.t("session.tab.review"), deps) -} - -function buildTerminalItem(id: string, deps: CurrentTabItemsDeps) { - const term = deps.terms.lookup().get(id) - if (!term) return undefined - return basicItem(id, term.title, deps) -} - -function buildPendingItem(tab: SessionInfo, deps: CurrentTabItemsDeps) { - return basicItem(tab.id, tab.title || deps.t("agentManager.session.newSession"), deps) -} - -function buildSessionItem( - tab: SessionInfo, - status: SessionStatusInfo | undefined, - blocked: boolean, - deps: CurrentTabItemsDeps, -) { - const working = !blocked && (status?.type === "busy" || status?.type === "retry") - return { - id: tab.id, - title: tab.title || deps.t("agentManager.session.untitled"), - status: statusLabel(tab.id, blocked, status, deps), - working, - tone: statusTone(tab.id, blocked, working, deps), - } satisfies CurrentTabItem -} - -function buildItem( - id: string, - statuses: Record, - perms: PermissionRequest[], - qs: QuestionRequest[], - deps: CurrentTabItemsDeps, -): CurrentTabItem | undefined { - if (id === deps.reviewId) return buildReviewItem(id, deps) - if (deps.isTerminal(id)) return buildTerminalItem(id, deps) - const tab = deps.tabLookup().get(id) - if (!tab) return undefined - if (deps.isPending(id)) return buildPendingItem(tab, deps) - const blocked = perms.some((p) => p.sessionID === id) || qs.some((q) => q.sessionID === id) - return buildSessionItem(tab, statuses[id], blocked, deps) -} - -function statusLabel(id: string, blocked: boolean, status: SessionStatusInfo | undefined, deps: CurrentTabItemsDeps) { - if (blocked) return deps.t("agentManager.tabsMenu.status.waiting") - if (status?.type === "busy") return deps.t("agentManager.tabsMenu.status.working") - if (status?.type === "retry") return deps.t("agentManager.tabsMenu.status.retry") - return undefined -} - -function statusTone(id: string, blocked: boolean, working: boolean, deps: CurrentTabItemsDeps): CurrentTabItem["tone"] { - if (id === deps.visibleTabId()) return "active" - if (blocked) return "waiting" - if (working) return "busy" - return "idle" -} - -interface CurrentTabsMenuProps { - items: Accessor - label: string - searchLabel: string - emptyLabel: string - activeId: Accessor - onSelect: (id: string) => void -} - -function SearchIcon() { - return ( - - ) -} - -export const CurrentTabsMenu: Component = (props) => { - const [search, setSearch] = createSignal("") - const [open, setOpen] = createSignal(false) - const [mark, setMark] = createSignal(0) - let input: HTMLInputElement | undefined - - const focus = () => { - input?.focus({ preventScroll: true }) - } - - createEffect(() => { - if (!open()) return - setSearch("") - queueMicrotask(focus) - }) - - const rows = createMemo(() => { - const q = search().trim().toLowerCase() - if (!q) return props.items() - return props.items().filter((item) => item.title.toLowerCase().includes(q)) - }) - - createEffect(() => { - if (!open()) return - search() - setMark(0) - }) - - const select = (id: string) => { - props.onSelect(id) - setOpen(false) - } - - const move = (dir: 1 | -1) => { - const len = rows().length - if (len === 0) return - setMark((prev) => (prev + dir + len) % len) - } - - const onKeyDown = (e: KeyboardEvent) => { - e.stopPropagation() - if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - if (e.key === "ArrowDown") { - e.preventDefault() - move(1) - return - } - if (e.key === "ArrowUp") { - e.preventDefault() - move(-1) - return - } - if (e.key === "Enter") { - e.preventDefault() - const item = rows()[mark()] - if (item) select(item.id) - return - } - if (e.key === "Escape") { - e.preventDefault() - setOpen(false) - } - } - - return ( - - - - } - > - -
        - 0} fallback={
        {props.emptyLabel}
        }> - - {(item) => ( - - )} - -
        -
        -
        - ) -} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 5ecd82318f4..c4f91269ad5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,5 @@ -import { type Component, createSignal, createMemo, For, Show, createEffect, on } from "solid-js" +import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js" +import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" @@ -20,27 +21,44 @@ import { useProvider } from "../src/context/provider" import { useConfig } from "../src/context/config" import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" -import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" +import { + getDirectory, + getFilename, + lineCount, + sanitizeReviewComments, + type ReviewComment, +} from "../diff-viewer/review-comments" import { buildFileAnnotations, buildReviewAnnotation, + clearReviewComposer, + createReviewComposer, + reviewComposerDraft, + reviewComposerEdit, reviewDraftSpeechKey, reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, -} from "./review-annotations" -import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" + type ReviewComposer, + type ReviewDraft, +} from "../diff-viewer/review-annotations" +import { createReviewAnnotationSpeechRenderer } from "../diff-viewer/review-annotation-speech" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, initialOpenFiles, + isDiffExpandable, isLargeDiffFile, + sanitizeOpenFiles, + shouldVirtualizeDiff, toggleOpenFiles, -} from "./diff-open-policy" -import { DiffEndMarker } from "./DiffEndMarker" -import { treeOrder } from "./file-tree-utils" -import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" -import { diffToken } from "./diff-state" +} from "../diff-viewer/diff-open-policy" +import { DiffEndMarker } from "../diff-viewer/DiffEndMarker" +import { VirtualDiffList } from "../diff-viewer/VirtualDiffList" +import { treeOrder } from "../diff-viewer/file-tree-utils" +import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView" +import { ImageDiffView } from "../diff-viewer/ImageDiffView" +import { createDiffRows, diffToken } from "../diff-viewer/diff-state" // --- Data model --- @@ -56,7 +74,9 @@ interface DiffPanelProps { onMarkdownRenderChange?: (render: boolean) => void comments: ReviewComment[] onCommentsChange: (comments: ReviewComment[]) => void + composer?: ReviewComposer onSendAll?: () => void + onSendClick?: () => void onClose: () => void onExpand?: () => void onRequestDiff?: (file: string) => void @@ -73,7 +93,7 @@ export const DiffPanel: Component = (props) => { const provider = useProvider() const { config } = useConfig() const speech = useSpeechToText(vscode, server, { t }) - const canUseSpeech = () => canUseSpeechToText(config(), provider.connected(), server.profileData()) + const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) const speechModel = () => selectedSpeechToTextModel(config()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => @@ -89,11 +109,11 @@ export const DiffPanel: Component = (props) => { edit: t("common.edit"), delete: t("common.delete"), }) + const localComposer = createReviewComposer() + const composer = () => props.composer ?? localComposer const [open, setOpen] = createSignal([]) - const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>( - null, - ) - const [editing, setEditing] = createSignal(null) + const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) + const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) const speechKeys = createMemo(() => { const keys = new Set() const current = draft() @@ -110,28 +130,31 @@ export const DiffPanel: Component = (props) => { keys: speechKeys, }) let nextId = 0 - // Tracks the session key for which initial open state has already run. When the - // key changes (different worktree) we expand reviewable files. Within the same key, - // only pruning happens so the user's manual collapse state is preserved. + // Initialize each worktree with every file expanded, then preserve manual + // collapse state while adding and removing files from live summaries. let initializedKey: string | undefined + let known = new Set() const requested = new Map() // Reorder diffs to match the file-tree's depth-first visual order so // scrolling through the accordion matches the tree grouping. const sorted = createMemo(() => treeOrder(props.diffs)) + const rows = createDiffRows(sorted, () => props.sessionKey) const comments = () => props.comments const setComments = (next: ReviewComment[]) => props.onCommentsChange(next) const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments())) - // Stable draft metadata ref — avoids recreating the object on every signal read - // so pierre's annotation cache doesn't invalidate and destroy the textarea - let draftMeta: AnnotationMeta | null = null + // Stable composer metadata refs avoid recreating the object on every signal read + // so pierre's annotation cache doesn't invalidate and destroy the textarea. + let draftMeta: AnnotationMeta | null = composer().draft + let editMeta: AnnotationMeta | null = composer().edit // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs let rootRef: HTMLDivElement | undefined - let scroller: HTMLDivElement | undefined + const [scroller, setScroller] = createSignal() + const [virtualizer, setVirtualizer] = createSignal() const focusRoot = () => { requestAnimationFrame(() => { @@ -147,19 +170,20 @@ export const DiffPanel: Component = (props) => { return false } - // Run a callback while preserving the scroll position of the diff container. - // Pierre destroys and rebuilds the DOM on annotation changes (via innerHTML = ""), - // which resets scrollTop. We capture it before the update and restore it across - // two animation frames to account for the async shadow-DOM render of . + // Preserve the visible file and its intra-row offset while Pierre rebuilds a + // row. Raw scrollTop is not stable once the virtualizer remeasures dynamic rows. const preserveScroll = (fn: () => void) => { - const el = scroller - if (!el) return fn() - const top = el.scrollTop + const handle = virtualizer() + const index = handle?.findStartIndex() + const file = index === undefined ? undefined : rows()[index]?.file + const offset = index === undefined ? 0 : (handle?.scrollOffset ?? 0) - (handle?.getItemOffset(index) ?? 0) fn() + if (!file) return requestAnimationFrame(() => { - el.scrollTop = top requestAnimationFrame(() => { - el.scrollTop = top + const next = rows().findIndex((diff) => diff.file === file) + if (next < 0) return + virtualizer()?.scrollToIndex(next, { offset }) }) }) } @@ -168,6 +192,7 @@ export const DiffPanel: Component = (props) => { preserveScroll(() => { setDraft(null) draftMeta = null + composer().draft = null }) focusRoot() } @@ -191,16 +216,19 @@ export const DiffPanel: Component = (props) => { // New context: initialize open state from the diff policy. if (key !== initializedKey) { initializedKey = key + known = fileSet setOpen(initialOpenFiles(diffs)) return } - // Already initialized for this key — preserve manual expand/collapse, - // only prune files that no longer exist (e.g. deleted during session) + // Preserve manual collapse state for known files, while keeping newly + // arriving files expanded when a live summary grows. + const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file) + known = fileSet setOpen((prev) => { - const filtered = prev.filter((file) => fileSet.has(file)) - if (filtered.length === prev.length && prev.every((f) => fileSet.has(f))) return prev - return filtered + const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added]) + if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev + return next }) }, ), @@ -211,10 +239,25 @@ export const DiffPanel: Component = (props) => { () => props.sessionKey, () => { requested.clear() + setDraft(null) + draftMeta = null + setEditing(null) + editMeta = null + clearReviewComposer(composer()) }, + { defer: true }, ), ) + const request = (diff: WorktreeFileDiff) => { + if (!props.onRequestDiff || props.loadingFiles?.has(diff.file)) return + if (!isDiffExpandable(diff) || diff.summarized !== true) return + const value = diffToken(diff) + if (requested.get(diff.file) === value) return + requested.set(diff.file, value) + props.onRequestDiff(diff.file) + } + createEffect( on( () => [open(), props.diffs] as const, @@ -223,16 +266,10 @@ export const DiffPanel: Component = (props) => { for (const file of requested.keys()) { if (!files.has(file)) requested.delete(file) } - if (!props.onRequestDiff) return - const loading = props.loadingFiles ?? new Set() for (const file of next) { - if (loading.has(file)) continue const diff = props.diffs.find((item) => item.file === file) - if (!diff || diff.summarized !== true) continue - const value = diffToken(diff) - if (requested.get(file) === value) continue - requested.set(file, value) - props.onRequestDiff(file) + if (!diff || diff.kind === "image") continue + request(diff) } }, { defer: true }, @@ -247,6 +284,7 @@ export const DiffPanel: Component = (props) => { updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }]) setDraft(null) draftMeta = null + composer().draft = null }) focusRoot() } @@ -255,6 +293,8 @@ export const DiffPanel: Component = (props) => { preserveScroll(() => { updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c))) setEditing(null) + editMeta = null + composer().edit = null }) focusRoot() } @@ -262,12 +302,20 @@ export const DiffPanel: Component = (props) => { const deleteComment = (id: string) => { preserveScroll(() => { updateComments((prev) => prev.filter((c) => c.id !== id)) - if (editing() === id) setEditing(null) + if (editing() === id) { + setEditing(null) + editMeta = null + composer().edit = null + } }) focusRoot() } const setEditState = (id: string | null) => { + if (editing() !== id) { + editMeta = null + composer().edit = null + } preserveScroll(() => setEditing(id)) if (id === null) focusRoot() } @@ -284,6 +332,8 @@ export const DiffPanel: Component = (props) => { const edit = editing() if (edit && !valid.some((comment) => comment.id === edit)) { setEditing(null) + editMeta = null + composer().edit = null } const currentDraft = draft() @@ -292,6 +342,7 @@ export const DiffPanel: Component = (props) => { if (!diff) { setDraft(null) draftMeta = null + composer().draft = null return } const content = currentDraft.side === "deletions" ? diff.before : diff.after @@ -299,11 +350,13 @@ export const DiffPanel: Component = (props) => { if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null + composer().draft = null return } if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { setDraft(null) draftMeta = null + composer().draft = null } }, ), @@ -320,10 +373,24 @@ export const DiffPanel: Component = (props) => { } return map }) + const pinned = createMemo(() => { + const files = new Set() + const current = draft() + if (current) files.add(current.file) + const edit = editing() + if (edit) { + const comment = comments().find((item) => item.id === edit) + if (comment) files.add(comment.file) + } + return rows().flatMap((diff, index) => (files.has(diff.file) ? [index] : [])) + }) const annotationsForFile = (file: string): DiffLineAnnotation[] => { - const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta) + const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta + editMeta = result.editMeta + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null return result.annotations } @@ -353,7 +420,10 @@ export const DiffPanel: Component = (props) => { if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { - setDraft({ file, side, line: range.start, endLine: range.end }) + const next = { file, side, line: range.start, endLine: range.end } + draftMeta = { type: "draft", comment: null, ...next } + composer().draft = draftMeta + setDraft(next) }) } @@ -375,6 +445,11 @@ export const DiffPanel: Component = (props) => { props.onSendAll?.() } + const sendAllClick = () => { + props.onSendClick?.() + sendAllToChat() + } + const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Enter") return if (!(e.metaKey || e.ctrlKey)) return @@ -394,8 +469,8 @@ export const DiffPanel: Component = (props) => { files: props.diffs.length, additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0), deletions: props.diffs.reduce((sum, diff) => sum + diff.deletions, 0), - large: props.diffs.filter((diff) => isLargeDiffFile(diff)).length, - collapsed: Math.max(props.diffs.length - open().length, 0), + large: props.diffs.filter((diff) => isDiffExpandable(diff) && isLargeDiffFile(diff)).length, + collapsed: props.diffs.filter((diff) => isDiffExpandable(diff) && !open().includes(diff.file)).length, })) const allOpen = createMemo(() => allOpenFiles(props.diffs, open())) const openLabel = () => (allOpen() ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")) @@ -480,18 +555,31 @@ export const DiffPanel: Component = (props) => { 0}> -
        - - - {(diff) => { +
        + setOpen(sanitizeOpenFiles(props.diffs, files))}> + { const isAdded = () => diff.status === "added" const isDeleted = () => diff.status === "deleted" const isLargeCollapsed = () => isLargeDiffFile(diff) && !open().includes(diff.file) const isLoadingDetail = () => props.loadingFiles?.has(diff.file) ?? false const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length + createEffect(() => { + if (diff.kind === "image" && open().includes(diff.file)) request(diff) + }) + return ( - +
        @@ -527,6 +615,9 @@ export const DiffPanel: Component = (props) => { + + {t("agentManager.review.image")} + {t("agentManager.review.largeFileCollapsed")} @@ -583,9 +674,11 @@ export const DiffPanel: Component = (props) => { /> - - - + + + + +
        @@ -606,34 +699,43 @@ export const DiffPanel: Component = (props) => { } > - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - diffStyle={props.diffStyle ?? "unified"} - annotations={annotationsForFile(diff.file)} - renderAnnotation={buildAnnotation} - enableGutterUtility={true} - onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> + + before={{ name: diff.file, contents: diff.before }} + after={{ name: diff.file, contents: diff.after }} + patch={diff.patch} + diffStyle={props.diffStyle ?? "unified"} + virtualized={shouldVirtualizeDiff(diff)} + annotations={annotationsForFile(diff.file)} + renderAnnotation={buildAnnotation} + enableGutterUtility={true} + onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} + onLineNumberClick={(event) => { + if (event.annotationSide === "deletions") return + props.onOpenFile?.(diff.file, event.lineNumber) + }} + /> + } + > + handleGutterClick(diff.file, result)} + onLineNumberClick={(event) => { + if (event.annotationSide === "deletions") return + props.onOpenFile?.(diff.file, event.lineNumber) + }} + /> + } > - handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> + @@ -641,7 +743,7 @@ export const DiffPanel: Component = (props) => { ) }} -
        + />
        LONG_DIFF_MARKER_FILE_COUNT}> @@ -654,7 +756,7 @@ export const DiffPanel: Component = (props) => { {comments().length} comment{comments().length !== 1 ? "s" : ""} - diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx deleted file mode 100644 index 4dd0936b991..00000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ /dev/null @@ -1,752 +0,0 @@ -import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" -// Styles are co-located with the component so every consumer (sidebar diff viewer, -// agent manager, storybook) picks them up automatically. Do not move these out — -// see tests/unit/diff-viewer-css-arch.test.ts for the invariant. -import "./agent-manager.css" -import "./agent-manager-review.css" -import { Diff } from "@kilocode/kilo-ui/diff" -import { Accordion } from "@kilocode/kilo-ui/accordion" -import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" -import { FileIcon } from "@kilocode/kilo-ui/file-icon" -import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" -import { RadioGroup } from "@kilocode/kilo-ui/radio-group" -import { Icon } from "@kilocode/kilo-ui/icon" -import { Button } from "@kilocode/kilo-ui/button" -import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Spinner } from "@kilocode/kilo-ui/spinner" -import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle" -import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" -import type { WorktreeFileDiff } from "../src/types/messages" -import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" -import { useLanguage } from "../src/context/language" -import { useVSCode } from "../src/context/vscode" -import { useServer } from "../src/context/server" -import { useProvider } from "../src/context/provider" -import { useConfig } from "../src/context/config" -import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" -import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" -import { FileTree } from "./FileTree" -import { treeOrder } from "./file-tree-utils" -import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" -import { - buildFileAnnotations, - buildReviewAnnotation, - reviewDraftSpeechKey, - reviewEditSpeechKey, - type AnnotationLabels, - type AnnotationMeta, -} from "./review-annotations" -import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" -import { - LONG_DIFF_MARKER_FILE_COUNT, - allOpenFiles, - initialOpenFiles, - isLargeDiffFile, - toggleOpenFiles, -} from "./diff-open-policy" -import { DiffEndMarker } from "./DiffEndMarker" -import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" -import { diffToken } from "./diff-state" - -type DiffStyle = "unified" | "split" - -interface FullScreenDiffViewProps { - diffs: WorktreeFileDiff[] - loading: boolean - loadingFiles?: Set - sessionId?: string - sessionKey?: string - comments: ReviewComment[] - onCommentsChange: (comments: ReviewComment[]) => void - onSendAll?: () => void - diffStyle: DiffStyle - onDiffStyleChange: (style: DiffStyle) => void - markdownRender?: boolean - onMarkdownRenderChange?: (render: boolean) => void - onRequestDiff?: (file: string) => void - onOpenFile?: (relativePath: string, line?: number) => void - onRevertFile?: (file: string) => void - revertingFiles?: Set - activeTerminalId?: string - /** Defaults to true. Hides the per-file Revert action when false. */ - canRevert?: boolean - /** Defaults to true. Disables comment creation and "Send all" when false. */ - canComment?: boolean - onClose: () => void -} - -export const FullScreenDiffView: Component = (props) => { - const { t } = useLanguage() - const vscode = useVSCode() - const server = useServer() - const provider = useProvider() - const { config } = useConfig() - const speech = useSpeechToText(vscode, server, { t }) - const canUseSpeech = () => canUseSpeechToText(config(), provider.connected(), server.profileData()) - const speechModel = () => selectedSpeechToTextModel(config()) - const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) - const sendAllKeybind = () => - isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") - const labels = (): AnnotationLabels => ({ - commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }), - editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }), - placeholder: t("agentManager.review.commentPlaceholder"), - cancel: t("common.cancel"), - comment: t("agentManager.review.commentAction"), - save: t("common.save"), - sendToChat: t("agentManager.review.sendToChat"), - edit: t("common.edit"), - delete: t("common.delete"), - }) - const [open, setOpen] = createSignal([]) - const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>( - null, - ) - const [editing, setEditing] = createSignal(null) - const speechKeys = createMemo(() => { - const keys = new Set() - const current = draft() - const edit = editing() - if (current) keys.add(reviewDraftSpeechKey(current)) - if (edit) keys.add(reviewEditSpeechKey(edit)) - return keys - }) - const reviewSpeech = createReviewAnnotationSpeechRenderer({ - speech, - enabled: canUseSpeech, - model: speechModel, - label: t, - keys: speechKeys, - }) - const [activeFile, setActiveFile] = createSignal(null) - const [treeWidth, setTreeWidth] = createSignal(240) - let nextId = 0 - let draftMeta: AnnotationMeta | null = null - // Tracks the session key for which initial open state has already run. When the - // key changes (different worktree) we expand reviewable files. Within the same key, - // only pruning happens so the user's manual collapse state is preserved. - let initializedKey: string | undefined - const requested = new Map() - let rootRef: HTMLDivElement | undefined - let scrollRef: HTMLDivElement | undefined - let syncFrame: number | undefined - - // Reorder diffs to match the file-tree's depth-first visual order so - // scrolling through the diff panel matches the tree on the left. - const sorted = createMemo(() => treeOrder(props.diffs)) - - const comments = () => props.comments - const setComments = (next: ReviewComment[]) => props.onCommentsChange(next) - const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments())) - - const focusRoot = () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - rootRef?.focus() - }) - }) - } - - const keepNativeFocus = (target: EventTarget | null) => { - if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true - if (target instanceof HTMLElement && target.isContentEditable) return true - return false - } - - const preserveScroll = (fn: () => void) => { - const el = scrollRef - if (!el) return fn() - const top = el.scrollTop - fn() - requestAnimationFrame(() => { - el.scrollTop = top - requestAnimationFrame(() => { - el.scrollTop = top - }) - }) - } - - const cancelDraft = () => { - preserveScroll(() => { - setDraft(null) - draftMeta = null - }) - focusRoot() - } - - // Unified open-state effect: tracks both sessionKey and diffs in a single effect - // to eliminate the race condition between the old separate sessionKey-reset and - // diffs-watch effects. Uses the session key to decide when initialization is needed - // vs when we just prune stale entries from the open list. - createEffect( - on( - () => [props.sessionKey, props.diffs] as const, - ([key, diffs]) => { - if (diffs.length === 0) { - // No diffs yet — clear active file only for a new key; keep current - // selection for transient empty updates in the same key. - if (key !== initializedKey) setActiveFile(null) - return - } - - const fileSet = new Set(diffs.map((diff) => diff.file)) - - // Keep active file in sync — pick first if current is stale - const current = activeFile() - if (!current || !diffs.some((d) => d.file === current)) { - setActiveFile(diffs[0]!.file) - } - - // New context: initialize open state from the diff policy. - if (key !== initializedKey) { - initializedKey = key - setOpen(initialOpenFiles(diffs)) - return - } - - // Already initialized for this key — preserve manual expand/collapse, - // only prune files that no longer exist (e.g. deleted during session) - setOpen((prev) => { - const filtered = prev.filter((file) => fileSet.has(file)) - if (filtered.length === prev.length && prev.every((f) => fileSet.has(f))) return prev - return filtered - }) - }, - ), - ) - - createEffect( - on( - () => props.sessionKey, - () => { - requested.clear() - }, - ), - ) - - createEffect( - on( - () => [open(), props.diffs] as const, - ([next]) => { - const files = new Set(next) - for (const file of requested.keys()) { - if (!files.has(file)) requested.delete(file) - } - if (!props.onRequestDiff) return - const loading = props.loadingFiles ?? new Set() - for (const file of next) { - if (loading.has(file)) continue - const diff = props.diffs.find((item) => item.file === file) - if (!diff || diff.summarized !== true) continue - const value = diffToken(diff) - if (requested.get(file) === value) continue - requested.set(file, value) - props.onRequestDiff(file) - } - }, - { defer: true }, - ), - ) - - // --- CRUD --- - - const addComment = (file: string, side: AnnotationSide, line: number, text: string, selectedText: string) => { - preserveScroll(() => { - const id = `c-${++nextId}-${Date.now()}` - updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }]) - setDraft(null) - draftMeta = null - }) - focusRoot() - } - - const updateComment = (id: string, text: string) => { - preserveScroll(() => { - updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c))) - setEditing(null) - }) - focusRoot() - } - - const deleteComment = (id: string) => { - preserveScroll(() => { - updateComments((prev) => prev.filter((c) => c.id !== id)) - if (editing() === id) setEditing(null) - }) - focusRoot() - } - - const setEditState = (id: string | null) => { - preserveScroll(() => setEditing(id)) - if (id === null) focusRoot() - } - - const handleRootMouseDown = (e: MouseEvent) => { - if (keepNativeFocus(e.target)) return - focusRoot() - } - - createEffect( - on( - () => [props.diffs, comments()] as const, - ([diffs, current]) => { - const valid = sanitizeReviewComments(current, diffs) - if (valid.length !== current.length) { - setComments(valid) - } - - const edit = editing() - if (edit && !valid.some((comment) => comment.id === edit)) { - setEditing(null) - } - - const currentDraft = draft() - if (!currentDraft) return - const diff = diffs.find((item) => item.file === currentDraft.file) - if (!diff) { - setDraft(null) - draftMeta = null - return - } - const content = currentDraft.side === "deletions" ? diff.before : diff.after - const max = lineCount(content) - if (currentDraft.line < 1 || currentDraft.line > max) { - setDraft(null) - draftMeta = null - return - } - if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { - setDraft(null) - draftMeta = null - } - }, - ), - ) - - // --- Per-file memoized annotations --- - - const commentsByFile = createMemo(() => { - const map = new Map() - for (const c of comments()) { - const arr = map.get(c.file) ?? [] - arr.push(c) - map.set(c.file, arr) - } - return map - }) - - const annotationsForFile = (file: string): DiffLineAnnotation[] => { - const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta) - draftMeta = result.draftMeta - return result.annotations - } - - const buildAnnotation = (annotation: DiffLineAnnotation): HTMLElement | undefined => { - return buildReviewAnnotation(annotation, { - diffs: props.diffs, - editing: editing(), - setEditing: setEditState, - addComment, - updateComment, - deleteComment, - cancelDraft, - labels: labels(), - activeTerminalId: props.activeTerminalId, - speech: reviewSpeech, - }) - } - - const handleGutterClick = (file: string, range: SelectedLineRange) => { - if (props.canComment === false) return - if (draft()) return - const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" - preserveScroll(() => { - setDraft({ file, side, line: range.start, endLine: range.end }) - }) - } - - const sendAllToChat = () => { - const all = comments() - if (all.length === 0) return - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments", - comments: all, - autoSend: true, - targetTerminalId: props.activeTerminalId, - }, - }), - ) - preserveScroll(() => setComments([])) - props.onSendAll?.() - } - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Enter") return - if (!(e.metaKey || e.ctrlKey)) return - const target = e.target - if (keepNativeFocus(target)) return - if (props.canComment === false) return - if (comments().length === 0) return - e.preventDefault() - e.stopPropagation() - sendAllToChat() - } - - const handleFileSelect = (path: string) => { - setActiveFile(path) - // Ensure the accordion is open for this file - if (!open().includes(path)) { - setOpen((prev) => [...prev, path]) - } - // Scroll to the file in the diff viewer - requestAnimationFrame(() => { - const container = scrollRef - const el = container?.querySelector(`[data-slot="accordion-item"][data-file-path="${CSS.escape(path)}"]`) - if (!(container instanceof HTMLElement)) return - if (!(el instanceof HTMLElement)) return - - const gap = 8 - const top = container.scrollTop + el.getBoundingClientRect().top - container.getBoundingClientRect().top - gap - container.scrollTo({ top: Math.max(0, top), behavior: "smooth" }) - }) - } - - const handleExpandAll = () => { - setOpen(toggleOpenFiles(props.diffs, open())) - } - - const syncActiveFileFromScroll = () => { - const container = scrollRef - if (!container) return - const headers = Array.from(container.querySelectorAll('[data-slot="accordion-item"][data-file-path]')) - if (headers.length === 0) return - - const top = container.getBoundingClientRect().top + 1 - const first = headers[0]?.dataset.filePath - const selected = headers.reduce((carry, header) => { - const path = header.dataset.filePath - if (!path) return carry - if (header.getBoundingClientRect().top <= top) return path - return carry - }, first) - - if (selected) setActiveFile(selected) - } - - const scheduleSyncActiveFile = () => { - if (syncFrame !== undefined) cancelAnimationFrame(syncFrame) - syncFrame = requestAnimationFrame(() => { - syncFrame = undefined - syncActiveFileFromScroll() - }) - } - - // Keep file tree selection in sync with viewport during scroll in both directions. - createEffect(() => { - const container = scrollRef - if (!container) return - const onScroll = () => scheduleSyncActiveFile() - const resize = new ResizeObserver(() => scheduleSyncActiveFile()) - container.addEventListener("scroll", onScroll, { passive: true }) - resize.observe(container) - scheduleSyncActiveFile() - - onCleanup(() => { - container.removeEventListener("scroll", onScroll) - resize.disconnect() - if (syncFrame !== undefined) { - cancelAnimationFrame(syncFrame) - syncFrame = undefined - } - }) - }) - - createEffect( - on( - () => [props.diffs, open()] as const, - () => scheduleSyncActiveFile(), - ), - ) - - const totals = createMemo(() => ({ - files: props.diffs.length, - additions: props.diffs.reduce((s, d) => s + d.additions, 0), - deletions: props.diffs.reduce((s, d) => s + d.deletions, 0), - large: props.diffs.filter((diff) => isLargeDiffFile(diff)).length, - collapsed: Math.max(props.diffs.length - open().length, 0), - })) - const allOpen = createMemo(() => allOpenFiles(props.diffs, open())) - const openLabel = () => (allOpen() ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")) - - return ( -
        - {/* Toolbar */} -
        -
        - style} - label={(style) => - style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split") - } - onSelect={(style) => { - if (style) props.onDiffStyleChange(style) - }} - /> - - {t("session.review.filesChanged", { count: totals().files })} - +{totals().additions} - -{totals().deletions} - 0}> - - {totals().large > 0 - ? t("agentManager.review.collapsedWithLarge", { - collapsed: totals().collapsed, - large: totals().large, - }) - : t("agentManager.review.collapsedOnly", { count: totals().collapsed })} - - - -
        -
        - - 0 && props.canComment !== false}> - - - - - -
        -
        - - {/* Body: file tree + diff viewer */} -
        -
        -
        - -
        - setTreeWidth(Math.max(160, Math.min(w, 400)))} - /> -
        -
        - -
        - - {t("session.review.loadingChanges")} -
        -
        - - -
        - {t("session.review.noChanges")} -
        -
        - - 0}> -
        - - - {(diff) => { - const isAdded = () => diff.status === "added" - const isDeleted = () => diff.status === "deleted" - const isLargeCollapsed = () => isLargeDiffFile(diff) && !open().includes(diff.file) - const isLoadingDetail = () => props.loadingFiles?.has(diff.file) ?? false - const fileCommentCount = () => (commentsByFile().get(diff.file) ?? []).length - - return ( - - - -
        -
        { - e.dataTransfer?.setData(KILO_FILE_PATH_MIME, diff.file) - e.dataTransfer?.setData("text/plain", diff.file) - e.stopPropagation() - }} - > - -
        - - {`\u2066${getDirectory(diff.file)}\u2069`} - - {getFilename(diff.file)} - 0}> - {fileCommentCount()} - -
        -
        -
        - - - {t("ui.sessionReview.change.added")} - - - - - {t("ui.sessionReview.change.removed")} - - - - - {t("agentManager.review.largeFileCollapsed")} - - - untracked - - - generated - - - - { - e.stopPropagation() - props.onOpenFile?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onRevertFile?.(diff.file) - }} - /> - - - - - { - e.stopPropagation() - props.onMarkdownRenderChange?.(!props.markdownRender) - }} - /> - - - - - -
        -
        -
        -
        - - - - Diff preview loads on demand.}> - <> - - Loading diff... - - -
        - } - > - - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - diffStyle={props.diffStyle} - annotations={annotationsForFile(diff.file)} - renderAnnotation={buildAnnotation} - enableGutterUtility={props.canComment !== false} - onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> - } - > - handleGutterClick(diff.file, result)} - onLineNumberClick={(event) => { - if (event.annotationSide === "deletions") return - props.onOpenFile?.(diff.file, event.lineNumber) - }} - /> - -
        - - - - ) - }} - - - LONG_DIFF_MARKER_FILE_COUNT}> - - -
        - -
        -
        -
        - ) -} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx index e497771b9fd..71b39fed3c1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx @@ -1,9 +1,16 @@ import { type Component, createSignal, createMemo, For, Show, onMount } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useProvider } from "../src/context/provider" import type { EnrichedModel } from "../src/context/provider" import { useLanguage } from "../src/context/language" -import { KILO_GATEWAY_ID, providerSortKey } from "../src/components/shared/model-selector-utils" +import { + KILO_GATEWAY_ID, + freeDataLabel, + hasByok, + isDataCollectedModel, + providerSortKey, +} from "../src/components/shared/model-selector-utils" import { type ModelAllocations, MAX_MULTI_VERSIONS, @@ -32,6 +39,8 @@ export const MultiModelSelector: Component<{ const { t } = useLanguage() const [search, setSearch] = createSignal("") let searchRef: HTMLInputElement | undefined + const freeLabel = () => t("model.tag.free") + const dataLabel = () => freeDataLabel(t("model.tag.free"), t("model.tag.dataCollected")) const visibleModels = createMemo(() => { const c = connected() @@ -107,6 +116,23 @@ export const MultiModelSelector: Component<{ } /> {model.name} + + + + {freeLabel()} + + + BYOK + + + + + + + + + +