diff --git a/.changeset/fair-mice-compact.md b/.changeset/fair-mice-compact.md deleted file mode 100644 index 7fc069fb152..00000000000 --- a/.changeset/fair-mice-compact.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Fix queued-turn auto-compaction so overflow recovery runs instead of exhausting compaction attempts. diff --git a/.changeset/jetbrains-empty-panel-layout.md b/.changeset/jetbrains-empty-panel-layout.md new file mode 100644 index 00000000000..3f13739eeb8 --- /dev/null +++ b/.changeset/jetbrains-empty-panel-layout.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Center description text and remove the fixed-width cap from the empty session panel so recent sessions can stretch naturally. diff --git a/.changeset/jetbrains-existing-session-scroll.md b/.changeset/jetbrains-existing-session-scroll.md new file mode 100644 index 00000000000..534092cc3f9 --- /dev/null +++ b/.changeset/jetbrains-existing-session-scroll.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open existing JetBrains sessions scrolled to the latest message after history loads. diff --git a/.changeset/jetbrains-header-tooltips.md b/.changeset/jetbrains-header-tooltips.md new file mode 100644 index 00000000000..eabe4154aa9 --- /dev/null +++ b/.changeset/jetbrains-header-tooltips.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Remember the JetBrains session header metrics expansion preference and show file names in read/write timeline tooltips. diff --git a/.changeset/jetbrains-history-navigation.md b/.changeset/jetbrains-history-navigation.md new file mode 100644 index 00000000000..92f361fc775 --- /dev/null +++ b/.changeset/jetbrains-history-navigation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains history navigation with Back support and a Show History action in recent sessions. diff --git a/.changeset/jetbrains-history-tabs.md b/.changeset/jetbrains-history-tabs.md new file mode 100644 index 00000000000..859a598e3e8 --- /dev/null +++ b/.changeset/jetbrains-history-tabs.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve the JetBrains history panel with native tabs, independent local and cloud search, and date-grouped cloud sessions. diff --git a/.changeset/jetbrains-question-carousel.md b/.changeset/jetbrains-question-carousel.md new file mode 100644 index 00000000000..943811f3f0f --- /dev/null +++ b/.changeset/jetbrains-question-carousel.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains question prompts one question at a time with aligned native option rows. diff --git a/.changeset/jetbrains-scroll-bottom.md b/.changeset/jetbrains-scroll-bottom.md new file mode 100644 index 00000000000..b98469fac73 --- /dev/null +++ b/.changeset/jetbrains-scroll-bottom.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the JetBrains chat transcript pinned to bottom reliably while responses stream. diff --git a/.changeset/jetbrains-scrollable-prompts.md b/.changeset/jetbrains-scrollable-prompts.md new file mode 100644 index 00000000000..0a923961601 --- /dev/null +++ b/.changeset/jetbrains-scrollable-prompts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render active question and permission prompts inside the scrollable JetBrains chat transcript. diff --git a/.changeset/jetbrains-session-header.md b/.changeset/jetbrains-session-header.md new file mode 100644 index 00000000000..9c13aa6c9d5 --- /dev/null +++ b/.changeset/jetbrains-session-header.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show collapsible session metadata, context usage, token usage, and compaction controls in the JetBrains session header. diff --git a/.changeset/jetbrains-session-history.md b/.changeset/jetbrains-session-history.md new file mode 100644 index 00000000000..8aa827208cc --- /dev/null +++ b/.changeset/jetbrains-session-history.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a native session history panel to the JetBrains plugin. diff --git a/.changeset/jetbrains-session-loading-state.md b/.changeset/jetbrains-session-loading-state.md new file mode 100644 index 00000000000..bc930ebcbca --- /dev/null +++ b/.changeset/jetbrains-session-loading-state.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions. diff --git a/.changeset/jetbrains-session-scroll-layout.md b/.changeset/jetbrains-session-scroll-layout.md new file mode 100644 index 00000000000..ea065c67885 --- /dev/null +++ b/.changeset/jetbrains-session-scroll-layout.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Reduce redundant JetBrains chat scroll layout work while keeping bottom-follow behavior. diff --git a/.changeset/jetbrains-step-finish-timeline.md b/.changeset/jetbrains-step-finish-timeline.md new file mode 100644 index 00000000000..6afba10a4bc --- /dev/null +++ b/.changeset/jetbrains-step-finish-timeline.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show completed assistant steps in the JetBrains session timeline. diff --git a/.changeset/local-indexing-choice.md b/.changeset/local-indexing-choice.md deleted file mode 100644 index a9d3b903614..00000000000 --- a/.changeset/local-indexing-choice.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Respect project-specific semantic indexing decisions instead of enabling indexing globally across workspaces. diff --git a/.changeset/mention-autocomplete-trailing-space.md b/.changeset/mention-autocomplete-trailing-space.md deleted file mode 100644 index 621c0c55a80..00000000000 --- a/.changeset/mention-autocomplete-trailing-space.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Append a trailing space when autocompleting a file, folder, or special mention (terminal, git changes) via the `@` menu in the chat textarea, so typing immediately after selection no longer breaks the attachment. diff --git a/.changeset/no-home-indexing.md b/.changeset/no-home-indexing.md deleted file mode 100644 index cc5422900eb..00000000000 --- a/.changeset/no-home-indexing.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Prevent VS Code empty windows from starting codebase indexing against the home directory. diff --git a/.changeset/quiet-envs-ask.md b/.changeset/quiet-envs-ask.md deleted file mode 100644 index e18627def83..00000000000 --- a/.changeset/quiet-envs-ask.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Prompt before reading `.env` files even after broad read permissions were previously approved. diff --git a/.changeset/select-settings-viewport.md b/.changeset/select-settings-viewport.md deleted file mode 100644 index f08531526b0..00000000000 --- a/.changeset/select-settings-viewport.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@opencode-ai/ui": patch -"@kilocode/kilo-ui": patch ---- - -Keep settings select dropdowns within the viewport when opened near the edge. diff --git a/.changeset/signin-routes-to-profile.md b/.changeset/signin-routes-to-profile.md deleted file mode 100644 index f0a35615eff..00000000000 --- a/.changeset/signin-routes-to-profile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Route the "Sign In" action from the Providers settings tab, provider picker, and chat auth errors to the Profile view so the device-auth code, QR, and cancel button are always visible. diff --git a/.changeset/skill-tool-default-agents.md b/.changeset/skill-tool-default-agents.md deleted file mode 100644 index 5608e935f91..00000000000 --- a/.changeset/skill-tool-default-agents.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Restore Skill tool access for Plan, Ask, Explore, and other non-system agents so skill workflows are available by default. diff --git a/.changeset/snapshot-spinner.md b/.changeset/snapshot-spinner.md deleted file mode 100644 index d7da19ce3e0..00000000000 --- a/.changeset/snapshot-spinner.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show snapshot initialization progress in the extension's existing loading indicator. diff --git a/.changeset/stale-turns-recover.md b/.changeset/stale-turns-recover.md deleted file mode 100644 index da3d5d2deda..00000000000 --- a/.changeset/stale-turns-recover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Recover sessions left unable to continue after an assistant turn was created but never started. diff --git a/.changeset/trust-system-cas.md b/.changeset/trust-system-cas.md deleted file mode 100644 index 54fb3e3f279..00000000000 --- a/.changeset/trust-system-cas.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Trust the OS certificate store and honor corporate CA bundles for the bundled Kilo backend. The extension now defaults `NODE_USE_SYSTEM_CA=1` on the spawned CLI process so users behind MITM proxies (Zscaler, Netskope, Palo Alto, etc.) no longer hit TLS errors on sign-in. A new `kilo-code.new.extraCaCerts` setting accepts a PEM file path for additional CAs, and `http.proxyStrictSSL=false` is honored as an opt-out from verification. diff --git a/.changeset/utf-32-bom-encoding.md b/.changeset/utf-32-bom-encoding.md deleted file mode 100644 index 176380079fc..00000000000 --- a/.changeset/utf-32-bom-encoding.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": minor -"@kilocode/cli": minor ---- - -Detect and preserve UTF-32 (LE and BE) with BOM when reading and editing files. UTF-16 and UTF-32 without a BOM remain unsupported. diff --git a/.changeset/webview-font-size.md b/.changeset/webview-font-size.md deleted file mode 100644 index dd7b6b8d028..00000000000 --- a/.changeset/webview-font-size.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Add a Display setting for adjusting the Kilo webview UI font size. diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml index 0fff208f62e..0474b670a6e 100644 --- a/.github/actions/setup-git-committer/action.yml +++ b/.github/actions/setup-git-committer/action.yml @@ -39,6 +39,15 @@ runs: - name: Clear checkout auth run: | git config --local --unset-all http.https://github.com/.extraheader || true + # kilocode_change start + keys="$(git config --local --name-only --get-regexp '^includeIf\.gitdir:' || true)" + for key in $keys; do + path="$(git config --local --get "$key" || true)" + case "$path" in + *git-credentials-*.config) git config --local --unset-all "$key" || true ;; + esac + done + # kilocode_change end shell: bash - name: Configure git remote diff --git a/.github/workflows/auto-docs.yml b/.github/workflows/auto-docs.yml index 4364ee87480..5f84be47f38 100644 --- a/.github/workflows/auto-docs.yml +++ b/.github/workflows/auto-docs.yml @@ -1,35 +1,113 @@ -name: Trigger webhook for feat PRs +name: Auto-docs dry-run intake +# Trigger on merged feature PRs, or manually with a specific PR number. +# IMPORTANT: This workflow intentionally does NOT check out PR code and does NOT +# execute any code from the PR branch. It only reads PR metadata and forwards it +# to a webhook. This is required for safe use of pull_request_target on a public repo. on: - pull_request: + pull_request_target: types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to test" + required: true + +# Minimal permissions: read repo contents and PR metadata only. +permissions: + contents: read + pull-requests: read + +# One run per PR at a time. Does not cancel in progress — let the current run +# finish so we don't drop events when a PR is quickly closed/reopened/closed. +concurrency: + group: auto-docs-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: false jobs: - call-webhook: + call-valtown: if: > - github.event.pull_request.merged == true && - (startsWith(github.event.pull_request.title, 'feat:') || - startsWith(github.event.pull_request.title, 'feat(')) + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + ( + startsWith(github.event.pull_request.title, 'feat:') || + startsWith(github.event.pull_request.title, 'feat(') + ) + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: - - name: Send webhook safely + - name: Build webhook payload + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + PR_NUMBER_FROM_EVENT: ${{ github.event.pull_request.number }} + PR_NUMBER_FROM_INPUT: ${{ inputs.pr_number }} run: | - payload=$(jq -n \ - --arg repo "${GITHUB_REPOSITORY}" \ - --arg pr_number "${{ github.event.pull_request.number }}" \ - --arg title "${{ github.event.pull_request.title }}" \ - --arg body "${{ github.event.pull_request.body }}" \ - --arg author "${{ github.event.pull_request.user.login }}" \ - --arg merged_at "${{ github.event.pull_request.merged_at }}" \ + set -euo pipefail + + PR_NUMBER="${PR_NUMBER_FROM_EVENT:-$PR_NUMBER_FROM_INPUT}" + + if [ -z "$PR_NUMBER" ]; then + echo "Missing PR number" + exit 1 + fi + + gh api "repos/$REPO/pulls/$PR_NUMBER" > pr.json + + jq -n \ + --arg event_name "$EVENT_NAME" \ + --arg repo "$REPO" \ + --arg pr_number "$PR_NUMBER" \ + --slurpfile pr pr.json \ '{ + event_name: $event_name, repo: $repo, pr_number: $pr_number, - title: $title, - description: $body, - author: $author, - merged_at: $merged_at - }' - ) - curl --fail-with-body -X POST "${{ secrets.DOC_WEBHOOK_URL }}" \ + title: $pr[0].title, + description: ($pr[0].body // ""), + author: $pr[0].user.login, + merged_at: ($pr[0].merged_at // ""), + pr_url: $pr[0].html_url, + base_branch: $pr[0].base.ref, + head_branch: $pr[0].head.ref + }' > payload.json + + echo "Payload created:" + jq '{ + event_name, + repo, + pr_number, + title, + author, + merged_at, + pr_url, + base_branch, + head_branch + }' payload.json + + - name: Send webhook to Val Town + env: + DOC_WEBHOOK_URL: ${{ secrets.DOC_WEBHOOK_URL }} + DOC_WEBHOOK_SECRET: ${{ secrets.DOC_WEBHOOK_SECRET }} + run: | + set -euo pipefail + + if [ -z "${DOC_WEBHOOK_URL:-}" ]; then + echo "DOC_WEBHOOK_URL secret is not set" + exit 1 + fi + + if [ -z "${DOC_WEBHOOK_SECRET:-}" ]; then + echo "DOC_WEBHOOK_SECRET secret is not set" + exit 1 + fi + + curl --fail-with-body -sS -X POST "$DOC_WEBHOOK_URL" \ -H "Content-Type: application/json" \ - --data-raw "$payload" + -H "X-Docs-Webhook-Secret: $DOC_WEBHOOK_SECRET" \ + --data-binary @payload.json diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 7411e242b9e..90fd1021893 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -16,6 +16,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 # kilocode_change with: + persist-credentials: false # kilocode_change fetch-depth: 0 - name: Setup Bun diff --git a/.github/workflows/check-forbidden-strings.yml b/.github/workflows/check-forbidden-strings.yml new file mode 100644 index 00000000000..e199ab4b46e --- /dev/null +++ b/.github/workflows/check-forbidden-strings.yml @@ -0,0 +1,21 @@ +# kilocode_change - new file +name: Check forbidden strings + +on: + pull_request: + workflow_dispatch: + +jobs: + check: + name: Check forbidden strings + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 # kilocode_change + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: oven-sh/setup-bun@v2 + + - name: Run check + run: bun run script/check-forbidden-strings.ts diff --git a/.github/workflows/check-kilo-generated-artifacts.yml b/.github/workflows/check-kilo-generated-artifacts.yml new file mode 100644 index 00000000000..1fa1c083f28 --- /dev/null +++ b/.github/workflows/check-kilo-generated-artifacts.yml @@ -0,0 +1,27 @@ +# kilocode_change - new file +name: Check Kilo generated artifacts + +on: + pull_request: + paths: + - ".gitignore" + - ".github/workflows/check-kilo-generated-artifacts.yml" + - ".kilo/**" + - ".kilocode/**" + - "script/check-kilo-generated-artifacts.ts" + workflow_dispatch: + +jobs: + check: + name: Check Kilo generated artifacts + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 # kilocode_change + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: oven-sh/setup-bun@v2 + + - name: Run check + run: bun run script/check-kilo-generated-artifacts.ts diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 810371d6d3e..514958ad494 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -5,7 +5,6 @@ on: paths: - ".github/**" - "github/**" - - "sdks/vscode/**" - "packages/extensions/**" - "packages/opencode/**" - "packages/script/**" diff --git a/.github/workflows/disabled/daily-issues-recap.yml.disabled b/.github/workflows/disabled/daily-issues-recap.yml.disabled deleted file mode 100644 index 789712ef519..00000000000 --- a/.github/workflows/disabled/daily-issues-recap.yml.disabled +++ /dev/null @@ -1,170 +0,0 @@ -name: daily-issues-recap - -on: - schedule: - # Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving) - - cron: "0 23 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - daily-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://kilo.ai/install | bash - - - name: Generate daily issues recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - KILO_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - # Get today's date range - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the Kilo repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather today's issues - Search for all OPEN issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 - - IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) issues only. - - STEP 2: Analyze and categorize - For each issue created today, categorize it: - - **Severity Assessment:** - - CRITICAL: Crashes, data loss, security issues, blocks major functionality - - HIGH: Significant bugs affecting many users, important features broken - - MEDIUM: Bugs with workarounds, minor features broken - - LOW: Minor issues, cosmetic, nice-to-haves - - **Activity Assessment:** - - Note issues with high comment counts or engagement - - Note issues from repeat reporters (check if author has filed before) - - STEP 3: Cross-reference with existing issues - For issues that seem like feature requests or recurring bugs: - - Search for similar older issues to identify patterns - - Note if this is a frequently requested feature - - Identify any issues that are duplicates of long-standing requests - - STEP 4: Generate the recap - Create a structured recap with these sections: - - ===DISCORD_START=== - **Daily Issues Recap - ${TODAY}** - - **Summary Stats** - - Total issues opened today: [count] - - By category: [bugs/features/questions] - - **Critical/High Priority Issues** - [List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers] - - **Most Active/Discussed** - [Issues with significant engagement or from active community members] - - **Trending Topics** - [Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature'] - - **Duplicates & Related** - [Issues that relate to existing open issues] - ===DISCORD_END=== - - STEP 5: Format for Discord - Format the recap as a Discord-compatible message: - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report - - Use hyperlinked issue numbers with suppressed embeds: [#1234]() - - Group related issues on single lines where possible - - Add emoji sparingly for critical items only - - HARD LIMIT: Keep under 1800 characters total - - Skip sections that have nothing notable (e.g., if no critical issues, omit that section) - - Prioritize signal over completeness - only surface what matters - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt - - echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily recap to Discord" diff --git a/.github/workflows/disabled/daily-pr-recap.yml.disabled b/.github/workflows/disabled/daily-pr-recap.yml.disabled deleted file mode 100644 index ff026917f81..00000000000 --- a/.github/workflows/disabled/daily-pr-recap.yml.disabled +++ /dev/null @@ -1,173 +0,0 @@ -name: daily-pr-recap - -on: - schedule: - # Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving) - - cron: "0 22 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - pr-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://kilo.ai/install | bash - - - name: Generate daily PR recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - KILO_PERMISSION: | - { - "bash": { - "*": "deny", - "gh pr*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the Kilo repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - - # Open PRs created today - gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - # Open PRs with activity today (updated today) - gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) contributions only. - - - - STEP 2: For high-activity PRs, check comment counts - For promising PRs, run: - gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length' - - IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts: - - copilot-pull-request-reviewer - - github-actions - - STEP 3: Identify what matters (ONLY from today's PRs) - - **Bug Fixes From Today:** - - PRs with 'fix' or 'bug' in title created/updated today - - Small bug fixes (< 100 lines changed) that are easy to review - - Bug fixes from community contributors - - **High Activity Today:** - - PRs with significant human comments today (excluding bots listed above) - - PRs with back-and-forth discussion today - - **Quick Wins:** - - Small PRs (< 50 lines) that are approved or nearly approved - - PRs that just need a final review - - STEP 4: Generate the recap - Create a structured recap: - - ===DISCORD_START=== - **Daily PR Recap - ${TODAY}** - - **New PRs Today** - [PRs opened today - group by type: bug fixes, features, etc.] - - **Active PRs Today** - [PRs with activity/updates today - significant discussion] - - **Quick Wins** - [Small PRs ready to merge] - ===DISCORD_END=== - - STEP 5: Format for Discord - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - surface what we might miss - - Use hyperlinked PR numbers with suppressed embeds: [#1234]() - - Include PR author: [#1234]() (@author) - - For bug fixes, add brief description of what it fixes - - Show line count for quick wins: \"(+15/-3 lines)\" - - HARD LIMIT: Keep under 1800 characters total - - Skip empty sections - - Focus on PRs that need human eyes - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt - - echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/pr_recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/pr_recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily PR recap to Discord" diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index e7c44e371da..2a3f221c176 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -15,6 +15,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 # kilocode_change + # kilocode_change start + with: + persist-credentials: false + # kilocode_change end - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml new file mode 100644 index 00000000000..4db34d33be8 --- /dev/null +++ b/.github/workflows/publish-jetbrains.yml @@ -0,0 +1,167 @@ +# kilocode_change - new file +name: publish-jetbrains + +on: + push: + tags: + - "jetbrains/*" + +concurrency: + group: publish-jetbrains-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + publish: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - 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 + + - name: Install build tools + run: | + 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 + for name in JETBRAINS_MARKETPLACE_TOKEN JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: $name" >&2 + missing=1 + fi + done + exit "$missing" + env: + JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + + - name: Prepare CLI resources + working-directory: packages/kilo-jetbrains + run: bun script/build.ts --production --prepare-cli + env: + KILO_VERSION: ${{ steps.version.outputs.version }} + KILO_CHANNEL: ${{ steps.version.outputs.cli_channel }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + + - name: Verify plugin + working-directory: packages/kilo-jetbrains + run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" + env: + CHANNEL: ${{ steps.version.outputs.marketplace_channel }} + + - 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 }} + JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + + - name: Resolve plugin archive + id: archive + run: | + mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip") + if [[ "${#signed[@]}" -eq 1 ]]; then + echo "path=${signed[0]}" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "${#signed[@]}" -gt 1 ]]; then + echo "Expected exactly one signed JetBrains plugin ZIP, found ${#signed[@]}." >&2 + printf '%s\n' "${signed[@]}" >&2 + exit 1 + fi + + mapfile -t files < <(compgen -G "packages/kilo-jetbrains/build/distributions/*.zip") + if [[ "${#files[@]}" -ne 1 ]]; then + echo "Expected exactly one JetBrains plugin ZIP, found ${#files[@]}." >&2 + printf '%s\n' "${files[@]}" >&2 + exit 1 + fi + echo "path=${files[0]}" >> "$GITHUB_OUTPUT" + + - name: Upload to GitHub Release + run: | + tag="$GITHUB_REF_NAME" + title="JetBrains $VERSION" + notes="JetBrains plugin $VERSION." + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$tag" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY" + exit 0 + fi + gh release create "$tag" "$ARCHIVE" --title "$title" --notes "$notes" --prerelease --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + ARCHIVE: ${{ steps.archive.outputs.path }} + + - name: Upload workflow artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: kilo-jetbrains-${{ steps.version.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 54c78d10bc2..34f35c22ce0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -184,6 +184,10 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 # kilocode_change + # kilocode_change start + with: + persist-credentials: false + # kilocode_change end - uses: ./.github/actions/setup-bun diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index c7660a5d577..b0604eb8ebc 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -96,6 +96,16 @@ jobs: echo "cli_url=$URL" >> "$GITHUB_OUTPUT" echo "::notice::Testing CLI v${VERSION} via asset API: $URL" + # Harbor's default agent-setup timeout is 360s. The hello-world container + # (FROM ubuntu:24.04) needs apt-get update + apt-get install + a NodeSource + # curl|bash + apt install nodejs before the CLI even downloads, and on + # the Blacksmith runners that occasionally pushes past 6 min when an apt + # mirror or NodeSource cdn is slow, killing the run with + # AgentSetupTimeoutError. Doubling the multiplier to 2 gives enough + # headroom for transient mirror/cdn slowness while still finishing well + # under timeout-minutes. Note: --timeout-multiplier scales every timeout + # uniformly (setup, agent, verifier, env build), but they're all upper + # limits so this is harmless. - name: Run smoke test — hello-world env: KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }} @@ -104,7 +114,8 @@ jobs: ./scripts/run_eval.sh \ -m kilo/anthropic/claude-sonnet-4.6 \ -d hello-world \ - --job-name smoke-test-hello-world + --job-name smoke-test-hello-world \ + --timeout-multiplier 2 - name: Run smoke test — log-summary-date-ranges env: @@ -115,11 +126,18 @@ jobs: -m kilo/anthropic/claude-sonnet-4.6 \ -d terminal-bench-sample \ -t "log-summary-date-ranges" \ - --job-name smoke-test-log-summary + --job-name smoke-test-log-summary \ + --timeout-multiplier 2 - name: Validate results run: python3 scripts/validate_smoke_test.py jobs/smoke-test-*/ + # Also upload the agent setup logs (stdout/stderr/return-code from the + # CLI install script) so we can see exactly which step stalls when the + # next AgentSetupTimeoutError happens. These come from the install + # script in kilo-bench, which uses `set -euo pipefail` (no `set -x`) + # and never echoes auth tokens or API keys, so the captured output is + # safe to upload. - name: Upload results if: always() uses: actions/upload-artifact@v7 # kilocode_change @@ -128,5 +146,6 @@ jobs: path: | jobs/smoke-test-*/**/result.json jobs/smoke-test-*/**/trajectory.json + jobs/smoke-test-*/**/agent/setup/*.txt retention-days: 30 if-no-files-found: warn diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 559d65f0a9f..d0f567d9a52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,14 +41,32 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + # kilocode_change start - name: Setup Node - uses: actions/setup-node@v6 # kilocode_change + id: setup-node + continue-on-error: ${{ runner.os == 'Windows' }} + uses: actions/setup-node@v6 with: node-version: "24" + - name: Retry Setup Node on Windows + if: runner.os == 'Windows' && steps.setup-node.outcome == 'failure' + uses: actions/setup-node@v6 + with: + node-version: "24" + # kilocode_change end + - 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" @@ -64,7 +82,7 @@ jobs: turbo-${{ runner.os }}- - name: Run unit tests - run: bun turbo test:ci + run: bun turbo test:ci --filter=!@kilocode/kilo-jetbrains # kilocode_change env: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} diff --git a/.gitignore b/.gitignore index 95a44149d17..9b1c9faf3b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .husky/_ .DS_Store +.secrets/ node_modules .worktrees .sst @@ -36,6 +37,7 @@ target # Local dev files opencode-dev +.kilo-dev/ UPCOMING_CHANGELOG.md logs/ *.bun-build @@ -45,8 +47,22 @@ telemetry-id tsconfig.tsbuildinfo # Kilo +.kilo/.gitignore +.kilo/package.json +.kilo/package-lock.json +.kilo/pnpm-lock.yaml +.kilo/bun.lock +.kilo/yarn.lock +.kilo/node_modules .kilo/plans/*upstream-merge-report-*.md +.kilocode/.gitignore +.kilocode/package.json +.kilocode/package-lock.json +.kilocode/pnpm-lock.yaml +.kilocode/bun.lock +.kilocode/yarn.lock +.kilocode/node_modules # Test Artifacts packages/app/.artifacts/ -packages/opencode/.artifacts/ \ No newline at end of file +packages/opencode/.artifacts/ diff --git a/.kilo/command/upstream-manual-merge.md b/.kilo/agent/upstream-merge.md similarity index 58% rename from .kilo/command/upstream-manual-merge.md rename to .kilo/agent/upstream-merge.md index cebc4045353..559f59e890a 100644 --- a/.kilo/command/upstream-manual-merge.md +++ b/.kilo/agent/upstream-merge.md @@ -1,14 +1,59 @@ --- -description: Resolve upstream merge conflicts +description: Resolve upstream opencode merge conflicts interactively +mode: primary +permission: + read: ask + edit: ask + webfetch: ask + bash: + "*": ask + "git status *": allow + "git log *": allow + "git diff *": allow + "git show *": allow + "git ls-files *": allow + "git ls-tree *": allow + "git grep *": allow + "git hash-object *": allow + "git remote -v *": allow + "git rev-parse *": allow + "git merge-base *": allow + "git show-ref *": allow + "git worktree list": allow + "git branch --show-current": allow + "grep *": allow + "rg *": allow + "head *": allow + "tail *": allow + "cat *": allow + "wc *": allow + "ls *": allow + "pwd *": allow + "diff *": allow + "gh pr view *": allow + "gh run view *": allow + "gh api \"repos/sst/opencode/commits/dev\" *": allow + "axiom *": allow + "bun test *": allow + "bun run typecheck *": allow + "bun run lint *": allow + "bun run script/check-opencode-annotations.ts *": allow + "script/upstream/find-conflict-markers.sh *": allow + "./script/upstream/find-conflict-markers.sh *": allow --- Resolve the manual part of an upstream merge. -Arguments: `$ARGUMENTS` +**Do not load the `kilocode-merge-minimizer` skill.** That skill is for +authoring new Kilo changes against shared upstream files; during an upstream +merge it gives the wrong guidance (it nudges toward extracting Kilo logic out +of conflict regions, which is exactly the opposite of what merge resolution +needs). Follow the rules in this agent file instead. -Use the first argument as the upstream version, for example `v1.1.50` or -`1.1.50`. If no argument is provided, infer the version from the current branch -name, `upstream-merge-report-.md`, or the newest relevant report file. +The user will provide the upstream version (for example `v1.1.50` or `1.1.50`) +in their first message. If they don't, infer it from the current branch name, +from `upstream-merge-report-.md`, or from the newest relevant report +file. ## Workflow @@ -44,6 +89,11 @@ git log --all --oneline -- Look at the commit message and any PR reference. "We wrote our own because of PR #NNNN" is a real constraint; "we wrote our own because of a typo" is not. +When upstream narrows an externally-visible compatibility list (models, +providers, routes, config keys, file formats), verify the intent from upstream +PRs, issues, release notes, or current docs before dropping entries. Treat +silent list shrinkage during a refactor as suspicious until proven intentional. + ### 3. Write a plan in chat and get approval For every conflicted file (and any adjacent file the resolution forces you to @@ -112,6 +162,9 @@ Apply in order: there and list both paths in the final summary. Verify the new file already carries the Kilo-renamed symbols (e.g. `x-kilo-directory`) by diffing against pristine upstream. +- if upstream extracts shared policy into a helper, move Kilo-specific additions + into the helper when possible instead of keeping a pre-check at the old call + site. The extracted helper should stay the source of truth for all callers. - if upstream deleted a file, analyse whether the Kilo behaviour should be ported elsewhere or removed rather than restoring the deleted file - if tests fail only because upstream intentionally removed behaviour, remove @@ -151,6 +204,26 @@ ported in too. After resolving the flagged file, check: Add any such files to the plan as `hybrid` or `take-ours` with the same approval flow. +### 6.5. Scan auto-merged files for latent bugs + +Files not in `--diff-filter=U` merged without conflict markers but may still +be broken. Check every auto-merged file for: + +- **Duplicate declarations in the same scope.** If both sides added equivalent + code independently, auto-merge keeps both. Grep touched functions for + repeated identifiers before trusting the merge. +- **Duplicate keys in config/manifest files.** If both sides added the same + entry to a shared manifest (dependencies, scripts, workflow lists), the + merged file may have the key twice. This often breaks install/setup before + any test runs — a cheap early win to scan for. +- **Orphaned imports and references.** A rename upstream may leave a Kilo + callsite pointing at a now-missing export. Run full typecheck from the repo + root; references that silently survived the merge surface there. +- **Partial auto-merges.** Upstream may have refactored a region Kilo + deliberately stubbed out (commented blocks, removed fallbacks). If the + auto-merge pulled in references to names that only exist in the removed + path, the file compiles upstream but breaks on Kilo. + ### 7. Verify each resolution before moving on - confirm `script/upstream/find-conflict-markers.sh ` prints nothing @@ -176,7 +249,15 @@ approval flow. files changed. Note that this tool compares against the merge base via `HEAD` and will be silent until the merge commit lands - other CI guards that touched files imply (knip for `kilo-vscode/`, - `check-kilocode-change`, source-links, visual regression) + `check-kilocode-change`, source-links, visual regression, + `script/check-forbidden-strings.ts`) +- if you encounter a hardcoded upstream URL, repo path, or attribution string + during conflict resolution that obviously shouldn't ship in Kilo (e.g. another + `https://opencode.ai/...` link, an `anomalyco/opencode` reference, an + attribution header naming "opencode"), suggest adding a literal pattern for + it to `script/check-forbidden-strings.ts` in the merge summary so future + merges catch it automatically. Don't add it silently mid-merge — flag it for + the user. ### 9. Commit with the standard message @@ -189,6 +270,49 @@ git commit -m "resolve merge conflicts" The default `git merge` auto-message (`Merge branch '…' into …`) is also fine, but `resolve merge conflicts` is the convention for these PRs. +### 9.5. Handle downstream API renames as separate commits + +Upstream often renames exported APIs. The rename itself auto-merges cleanly in +shared code, but the change cascades into Kilo-only files (kilocode tests, +kilo-specific source, plugins) that still reference the old symbol. Those +files don't appear in `--diff-filter=U` because their own content didn't +conflict. + +Keep the behavioural merge commit focused on resolution decisions. Land the +cascade in one or more follow-up commits: + +- after the merge commit, run full repo typecheck and collect every "cannot + find name" / "property does not exist" error +- bulk-rename with a mechanical transform when the rename is one-to-one +- restructure or parameter-thread when upstream changed semantics, not just + the name (e.g. moved a helper behind a dependency-injected surface, so + callers now need the injected handle) +- split large downstream refactors into their own commits with messages that + name the rename + +Reviewers can then skim the behavioural commit without untangling mechanical +rename noise from merge decisions. + +### 9.6. Handle upstream-added tests that diverge from Kilo + +Upstream sometimes adds tests that encode design contracts Kilo intentionally +breaks. These auto-merge cleanly and then fail. Three resolution patterns: + +- **Rewrite the test** when the test is a contract assertion and Kilo has a + different but equally valid contract. Invert or adjust the assertion with a + `kilocode_change` marker explaining the divergence. +- **Skip the test** when the test relies on patterns that Kilo has replaced + (interception seams that are bypassed by dependency injection, fixture + helpers bound to a removed API, assumptions about serialization shape that + Kilo's extensions break). Mark with `kilocode_change` and a rationale + explaining what would need to change for the test to run. +- **Delete the test** when it covers functionality Kilo deliberately removed + (fallback paths, deprecated endpoints, products Kilo doesn't ship). Note + the deletion in the PR body. + +Never silently delete; always leave a breadcrumb. A future reviewer should be +able to understand why this one upstream test is treated differently. + ### 10. Resync version strings in a separate commit Upstream stamps its own version into shared files — notably @@ -261,3 +385,37 @@ config schema behaviour, migrations, provider routing, or security posture. - Stricter DOM lib types (upstream TS upgrade) can surface latent casting issues around `WebSocket.send`, `Headers`, etc. — prefer narrowing the Kilo type over adding `any` casts. +- Auto-merge can duplicate the same declaration twice in one scope when both + sides added equivalent code independently. Silent for git, caught by + typecheck. Same hazard for duplicated object keys in config/manifest files — + those can break install before any test runs. +- Kilo code may rely on ambient context (async-local storage, globally-set + flags, process env) being populated at a lifecycle moment that upstream + refactors away. If Kilo behaviour reads ambient state during init, forked + work, or event handlers, check the refactor still establishes that state at + the right time. Fix by restoring the ambient state, or by threading the + needed value through explicitly. +- Tests that intercept via process-global or module-global spies can become + no-ops after upstream moves the intercepted code path through dependency + injection. The production code no longer touches the spied symbol. Fixing + the test usually means injecting a mock at the new seam rather than tweaking + the spy. +- When Kilo extends a shared data shape with extra optional fields, different + serialization paths for that shape can diverge on whether missing values are + omitted or emitted as null. Parity tests between two such paths break on + every Kilo addition — audit the encoding assumption before adding fields. +- Rule ordering in allowlist/permission evaluation is usually last-match-wins. + Re-declaring a catch-all rule "for safety" in a later ruleset silently + overrides more specific allow rules from an earlier ruleset. Treat a + redundant catch-all as destructive, not defensive. +- Upstream-added tests can encode a design contract Kilo deliberately breaks. + The test passes upstream because upstream doesn't share Kilo's requirement. + Decide between refactoring Kilo to match the upstream contract or rewriting + the test to assert Kilo's divergent contract — with a `kilocode_change` + marker explaining the divergence. +- CI and local can show different test failures. Tests that read user-local + state (home dir, global config, auth tokens) pass in one environment but + fail in the other. A green local run does not imply green CI. +- Dependency manifests and lockfiles move together. When the merge edits one, + regenerate and commit the other in the same change — otherwise CI breaks on + the follow-up setup step. diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json deleted file mode 100644 index ce556edce27..00000000000 --- a/.kilo/package-lock.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "name": ".kilo", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@kilocode/plugin": "7.2.31" - } - }, - "node_modules/@kilocode/plugin": { - "version": "7.2.31", - "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz", - "integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==", - "license": "MIT", - "dependencies": { - "@kilocode/sdk": "7.2.31", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.100", - "@opentui/solid": ">=0.1.100" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@kilocode/sdk": { - "version": "7.2.31", - "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz", - "integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.kilo/skills/gh-issues/SKILL.md b/.kilo/skills/gh-issues/SKILL.md new file mode 100644 index 00000000000..9a5bfefdf30 --- /dev/null +++ b/.kilo/skills/gh-issues/SKILL.md @@ -0,0 +1,68 @@ +--- +name: gh-issues +description: Use when creating, triaging, or commenting on GitHub issues for the Kilo VS Code extension or JetBrains plugin via `gh`. Covers issue templates, project board assignment, title conventions, and required `gh` scopes. +--- + +# GitHub Issues + +Use this skill whenever you create or manage a GitHub issue with `gh` for either the VS Code extension or the JetBrains plugin. + +## Templates + +The repo defines issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template instead of opening a blank issue: + +| Template | When to use | +|---|---| +| `Bug report` (`bug-report.yml`) | Reproducible defects with steps, expected, and actual behavior | +| `Feature Request` (`feature-request.yml`) | New capabilities, enhancements, or behavior changes | +| `Question` (`question.yml`) | Usage or design questions that aren't obviously bugs or feature requests | + +Pass the template title to `gh issue create --template`. + +## Title Conventions + +- Use a plain, descriptive title that reads cleanly as a standalone sentence. +- Do not add platform-specific prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Routing happens through project boards, not the title. + +## Project Boards + +Every new issue must land on the correct project board: + +| Surface | Project | URL | +|---|---|---| +| VS Code extension | `VS Code Extension` | https://github.com/orgs/Kilo-Org/projects/25 | +| JetBrains plugin | `Jetbrains Plugin` | https://github.com/orgs/Kilo-Org/projects/39 | + +Pass the project title to `gh issue create --project`. + +## Recipes + +Create a VS Code extension bug report and add it to the board: + +```bash +gh issue create \ + --template "Bug report" \ + --project "VS Code Extension" \ + --title "Sidebar chat fails to render after reload" \ + --body "..." +``` + +Create a JetBrains feature request: + +```bash +gh issue create \ + --template "Feature Request" \ + --project "Jetbrains Plugin" \ + --title "Support Kotlin Multiplatform target detection" \ + --body "..." +``` + +## Scope Errors + +If `gh` reports a missing scope when assigning a project, refresh the auth token and retry: + +```bash +gh auth refresh -s project +``` + +After the refresh succeeds, re-run the original `gh issue create` command. Do not fall back to creating the issue without the project — the board assignment is required. diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json deleted file mode 100644 index 7a0e27b435c..00000000000 --- a/.kilocode/package-lock.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "name": ".kilocode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@kilocode/plugin": "7.2.31" - } - }, - "node_modules/@kilocode/plugin": { - "version": "7.2.31", - "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz", - "integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==", - "license": "MIT", - "dependencies": { - "@kilocode/sdk": "7.2.31", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.100", - "@opentui/solid": ">=0.1.100" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@kilocode/sdk": { - "version": "7.2.31", - "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz", - "integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.opencode-version b/.opencode-version new file mode 100644 index 00000000000..92f061fdfe4 --- /dev/null +++ b/.opencode-version @@ -0,0 +1 @@ +v1.14.34 diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index d85586c6678..69ab7ac86b6 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: kilo/minimax/minimax-m2.5 +model: openai/gpt-5-nano color: "#44BA81" tools: "*": false @@ -14,95 +14,38 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -## Labels +Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. -### windows +Do not add labels to issues. Only assign an owner. -Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. +When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows, jetbrains, extension -- Use if they mention WSL too +## Teams -#### perf +### TUI -Performance-related issues: +Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -- Slow performance -- High RAM usage -- High CPU usage +### Desktop / Web -**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. +Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -#### nix +### Core -**Only** add if the issue explicitly mentions nix. +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. -If the issue does not mention nix, do not add nix. +### Inference -If the issue mentions nix, assign to `catrielmuller`. +Kilo Provider, Provider implementation, and billing issues. -#### core +### Windows -Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. +Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. -Examples: +### Jetbrains -- LSP server behavior -- Harness behavior (agent + tools) -- Feature requests for server behavior -- Agent context construction -- API endpoints -- Provider integration issues -- New, broken, or poor-quality models +Jetbrains extension related issues. -#### vscode +### Extension -Use for issues related to the VS Code extension in `packages/kilo-vscode/`. - -#### gateway - -Use for issues related to the Kilo Gateway in `packages/kilo-gateway/`. - -When assigning to people here are the following rules: - -Nix: -ONLY assign if the issue will have the "nix" label. - -- catrielmuller - -Models / Providers: -Use for issues about model quality, provider integrations, or broken/new models. - -- chrarnoldus - -Cloud Agents: -Use for issues about cloud agent behavior or infrastructure. - -- pandemicsyn -- eshurakov - -Core (`packages/opencode/...`): - -- kevinvandijk -- marius-kilocode -- catrielmuller - -VSCode Extension (`packages/kilo-vscode/...`): - -- markijbema - -Kilo Gateway (`packages/kilo-gateway/...`): - -- jrf0110 - -Windows: - -- catrielmuller (assign any issue that mentions Windows or is likely Windows-specific) - -Determinism rules: - -- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" -- If title + body mentions nix/nixos, assign to `catrielmuller` -- If "vscode" label is added, assign to `markijbema` -- If "gateway" label is added, assign to `jrf0110` - -In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. +VSCode Extension related issues. diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 4cd30a704a4..b28d963d005 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution. Rules: -- Write the final file with sections in this order: +- Write the final file with release sections in this order: `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` - Only include sections that have at least one notable entry +- Within each release section, keep bug fixes grouped under `### Bugfixes` +- Keep other notable entries under `### Improvements` when a section has bug fixes too +- Omit empty subsections - Keep one bullet per commit you keep - Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing - Start each bullet with a capital letter diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md index 78216ab01c3..3a44fa88dcd 100644 --- a/.opencode/skills/effect/SKILL.md +++ b/.opencode/skills/effect/SKILL.md @@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 - In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior. - Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types. - Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first. + +## Testing Patterns + +- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations. +- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior. +- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root. +- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file. +- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state. diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 8f80f868577..ce07fdebc60 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,15 +1,16 @@ /// import { tool } from "@kilocode/plugin" -const TEAM = { - desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], - zen: ["fwang", "MrMushrooooom"], - tui: ["kommander", "rekram1-node", "simonklee"], - core: ["kitlangton", "rekram1-node", "jlongster"], - docs: ["R44VC0RP"], - windows: ["Hona"], -} as const -const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] +// kilocode_change start +const TEAM = { + tui: ["catrielmuller"], + desktop_web: ["markijbema"], + core: ["markijbema", "marius-kilocode", "catrielmuller", "chrarnoldus", "imanolmzd-svg"], + jetbrains: ["kirillk"], + inference: ["chrarnoldus", "markijbema"], + windows: ["catrielmuller"], +} as const +// kilocode_change end function pick(items: readonly T[]) { return items[Math.floor(Math.random() * items.length)]! @@ -38,70 +39,27 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: `Use this tool to assign and/or label a GitHub issue. + description: `Use this tool to assign a GitHub issue. -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - assignee: tool.schema - .enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"]) - .describe("The username of the assignee") - .default("rekram1-node"), - labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"])) - .describe("The labels(s) to add to the issue") - .default([]), + team: tool.schema + .enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) + .describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() - // const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) - const owner = "anomalyco" - const repo = "opencode" + // kilocode_change start + const owner = "Kilo-Org" + const repo = "kilocode" + // kilocode_change end + const assignee = pick(TEAM[args.team]) - const results: string[] = [] - - if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) { - throw new Error("Only desktop issues should be assigned to adamdotdevin") - } - - if (args.assignee === "fwang" && !args.labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang") - } - - if (args.assignee === "kommander" && !args.labels.includes("opentui")) { - throw new Error("Only opentui issues should be assigned to kommander") - } - - // await octokit.rest.issues.addAssignees({ - // owner, - // repo, - // issue_number: issue, - // assignees: [args.assignee], - // }) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", - body: JSON.stringify({ assignees: [args.assignee] }), + body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${args.assignee} to issue #${issue}`) - const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label)) - - if (labels.length > 0) { - // await octokit.rest.issues.addLabels({ - // owner, - // repo, - // issue_number: issue, - // labels, - // }) - await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { - method: "POST", - body: JSON.stringify({ labels }), - }) - results.push(`Added labels: ${args.labels.join(", ")}`) - } - - return results.join("\n") + return `Assigned @${assignee} from ${args.team} to issue #${issue}` }, }) diff --git a/AGENTS.md b/AGENTS.md index cc5eda0d054..31f9c961b76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,7 @@ Extension-specific settings should live in the Kilo extension settings, not defa ## Package Instructions -- When a task primarily touches `packages/kilo-jetbrains/`, read `packages/kilo-jetbrains/AGENTS.md` before planning or editing. -- For JetBrains Kotlin/Swing UI work, also apply `packages/kilo-jetbrains/.kilo/skills/jetbrains-ui-style/SKILL.md`. +- When a task primarily touches `packages/kilo-jetbrains/`, read `packages/kilo-jetbrains/AGENTS.md` before planning or editing. It covers split-mode architecture, IntelliJ source lookup, threading fundamentals, UI guidelines, and session component architecture. ## Monorepo Structure @@ -89,17 +88,6 @@ Prefer `const`. Good: -### Naming Enforcement (Read This) - -THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE. - -- Use single word names by default for new locals, params, and helper functions. -- Multi-word names are allowed only when a single word would be unclear or ambiguous. -- Do not introduce new camelCase compounds when a short single-word alternative is clear. -- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible. -- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`. -- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`. - ```ts const foo = condition ? 1 : 2 ``` @@ -113,6 +101,17 @@ if (condition) foo = 1 else foo = 2 ``` +### Naming Enforcement (Read This) + +THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE. + +- Use single word names by default for new locals, params, and helper functions. +- Multi-word names are allowed only when a single word would be unclear or ambiguous. +- Do not introduce new camelCase compounds when a short single-word alternative is clear. +- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible. +- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`. +- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`. + ### Avoid else statements Prefer early returns or using an `iife` to avoid else statements. @@ -223,12 +222,7 @@ PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus ## GitHub Issues -- When creating a GitHub issue for the VS Code extension or JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. -- Do not add platform-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Use a plain, descriptive title. -- Always add VS Code extension issues to the GitHub project `VS Code Extension`: https://github.com/orgs/Kilo-Org/projects/25 -- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 -- When using `gh`, prefer `gh issue create --template "..." --project "..."` with the matching project title. -- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. +When creating or managing GitHub issues for the VS Code extension or JetBrains plugin via `gh`, load `.kilo/skills/gh-issues/SKILL.md`. It covers templates, project boards (`VS Code Extension`, `Jetbrains Plugin`), title conventions, and the `gh auth refresh -s project` recovery path. ## Fork Merge Process @@ -262,50 +256,13 @@ The goal is to keep our diff from upstream as small as possible, making regular ### Kilocode Change Markers -To minimize merge conflicts when syncing with upstream, mark Kilo Code-specific changes in shared code with `kilocode_change` comments. +When editing shared upstream files, mark Kilo-specific lines with `kilocode_change` comments so future merges can find them. The basic forms are: -**Single line:** +- Single line: `const value = 42 // kilocode_change` +- Multi-line block: wrap with `// kilocode_change start` / `// kilocode_change end` +- New file in a shared path: `// kilocode_change - new file` at the top +- JSX/TSX: use `{/* kilocode_change */}` (and `{/* kilocode_change start */}` / `end`) -```typescript -const value = 42 // kilocode_change -``` +Markers are NOT needed in paths that contain `kilocode` in the name (e.g. `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`) — these are entirely Kilo Code additions and won't conflict with upstream. -**Multi-line:** - -```typescript -// kilocode_change start -const foo = 1 -const bar = 2 -// kilocode_change end -``` - -**New files:** - -```typescript -// kilocode_change - new file -``` - - -**JSX/TSX (inside JSX templates):** - - -```tsx -{/* kilocode_change */} -``` - - -```tsx -{/* kilocode_change start */} - -{/* kilocode_change end */} -``` - -#### When markers are NOT needed - -Code in these paths is Kilo Code-specific and does NOT need `kilocode_change` markers: - -- `packages/opencode/src/kilocode/` - All files in this directory -- `packages/opencode/test/kilocode/` - All test files for kilocode -- Any other path containing `kilocode` in filename or directory name - -These paths are entirely Kilo Code additions and won't conflict with upstream. +For decision rules on when to keep changes inline vs. extract Kilo logic, marker placement guidance, and verification commands, load `.kilo/skills/kilocode-merge-minimizer/SKILL.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 520978df09e..9bb4a775994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,29 +136,18 @@ There are also optional overrides for other services: If you open an issue through the GitHub web UI, GitHub will guide you through the correct template automatically. -If you open an issue through `gh issue create`, the API, or another tool that bypasses the web UI, include the equivalent required fields yourself so the issue still matches the template. +If you open an issue through `gh issue create`, the API, or another tool that bypasses the web UI, include the equivalent required fields yourself so the issue still matches the template. Issues that skip required fields may be auto-closed by the compliance bot. Current required fields by issue type: -- **Bug report:** include a `Description`. -- **Feature request:** prefix the title with `[FEATURE]:`, include confirmation that the feature has not already been suggested, and add a description of the enhancement. -- **Question:** include the `Question`. - -Recommended fields for bug reports, even when not strictly required by the template: - -- Plugins -- Kilo version -- Steps to reproduce -- Screenshot and/or share link -- Operating System -- Terminal +- **Bug report:** include a `Description`. When you can, also add Plugins, Kilo version, Steps to reproduce, Screenshot and/or share link, Operating System, and Terminal so the report matches the full bug template. +- **Feature request:** use a title prefixed with `[FEATURE]:`, complete the required checkbox confirming you have searched for duplicates, and fill in `Describe the enhancement you want to request`. +- **Question:** include the `Question` field. ## Pull Request Expectations -- **Issue First Policy:** All PRs must reference an existing issue. - **UI Changes:** Include screenshots or videos (before/after). - **Logic Changes:** Explain how you verified it works. -- **PR Titles:** Follow conventional commit standards (`feat:`, `fix:`, `docs:`, etc.). ## Issue First Policy diff --git a/README.md b/README.md index 5d48917f89c..81bc35bbd55 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ## Get Started in Visual Studio Code 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). -2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 – with transparent pricing that matches provider rates exactly. +2. Create your account to access 500+ cutting-edge AI models including GPT-5.5, Claude Opus 4.7, Claude Sonnet 4.6, and Gemini 3.1 Pro Preview, with transparent pricing that matches provider rates exactly. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: Watch the video @@ -102,7 +102,9 @@ kilo run --auto "run tests and fix any failures" We welcome contributions from developers, writers, and enthusiasts! To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests. -See [RELEASING.md](RELEASING.md) for the release process. +See [RELEASING.md](RELEASING.md) for the VS Code extension and CLI release process. + +See [packages/kilo-jetbrains/RELEASING.md](packages/kilo-jetbrains/RELEASING.md) for the JetBrains plugin release process. ## Code of Conduct @@ -113,6 +115,11 @@ Our community is built on respect, inclusivity, and collaboration. Please review This project is licensed under the MIT License. You’re free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE). -### Where did Kilo CLI come from? +## FAQ + +
+Where did Kilo CLI come from? Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform. + +
diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 00000000000..72876657091 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,90 @@ +# REVIEW.md + +Guidance for the automated reviewer (kilo-code-bot) on PRs in this repo. + +The goal of the review is to catch things CI **cannot** catch: bugs, design issues, and judgment calls about style and fork hygiene. Be helpful, not pedantic — frame everything as a suggestion the human can accept or reject. + +## Don't duplicate CI + +CI already runs and will report failures directly. Do **not** comment on: + +- Lint, formatting, or typecheck errors (root `lint`, `turbo typecheck`) +- Test failures (CLI tests, vscode tests) +- `knip` unused exports +- `kilocode_change` marker rules — both directions: + - Missing markers on shared opencode files (`script/check-opencode-annotations.ts`) + - Markers present in kilo-only paths like `packages/kilo-vscode/`, `packages/kilo-ui/`, `packages/opencode/src/kilocode/` (`bun run check-kilocode-change`) +- Workflow allowlist drift (`script/check-workflows.ts`) +- Stale `packages/kilo-docs/source-links.md` (`script/extract-source-links.ts`) +- Markdown table padding (`script/check-md-table-padding.ts`) +- Visual regression snapshots (CI generates baselines on Linux) +- SDK regeneration drift (`generate.yml`) +- Generated artifact freshness (`check-kilo-generated-artifacts.yml`) +- Docs link checks, nix evals, container builds + +If the only issue you'd raise is one of the above, just say `lgtm`. + +## What to focus on + +### 1. Bugs and correctness + +Read enough of the surrounding file to actually understand the change — diffs alone hide context. Look for: + +- Logic errors, off-by-one, wrong conditions, swapped arguments +- Unhandled error paths, swallowed promises, missing `await` +- Race conditions, especially around session/process lifecycle in the CLI and Agent Manager +- Resource leaks (unclosed file handles, child processes, subscriptions) +- Inputs that aren't validated where they cross trust boundaries (server routes, IPC, config loading) + +### 2. Style guide judgment calls + +The full guide is in `AGENTS.md`. Don't be a zealot — only flag actual violations, and recognize when the existing code already complies through a different mechanism. + +- **No `let`**: prefer `const` with ternary or IIFE (`packages/opencode/src/util/iife.ts`). But `let` is fine when it's genuinely the simplest option; don't demand IIFE rewrites for trivial cases. +- **No `else`**: prefer early returns. Don't complain about `else` if the code already uses early returns elsewhere. You **may** flag excessive nesting regardless. +- **No empty `catch`**: always flag — empty catches hide bugs. +- **Avoid `try`/`catch` where possible**: if a try/catch is added, consider whether it's needed at all. +- **Avoid `any`**: flag new `any` usage unless there's a clear reason. +- **Single-word names**: prefer `cfg`, `pid`, `dir`, `opts`, `err` over `inputPID`, `connectTimeout`. Only flag newly introduced multi-word names where a clear single-word alternative exists. +- **Avoid unnecessary destructuring**: prefer `obj.a` over `const { a } = obj` to preserve context. +- **Bun APIs**: prefer `Bun.file()` etc. over node equivalents in CLI code. +- **Type inference**: avoid explicit annotations unless needed for exports/clarity. + +When suggesting fixes, ensure the suggestion is valid TypeScript (matched braces, correct syntax). Prefer prose comments over `suggestion` blocks unless the fix is trivially mechanical. + +### 3. Fork merge hygiene + +Kilo CLI is a fork of opencode. Minimizing diff against upstream is a top priority. + +- If a change modifies a shared opencode file (anything under `packages/opencode/` not in a path containing `kilocode`), ask whether the logic could live in a Kilo-only directory instead (`packages/opencode/src/kilocode/`, `packages/kilo-gateway/`, etc.) or be reduced to a smaller hook. +- Refactors or reorganizations of upstream code are a red flag — flag them unless clearly justified. +- See `.kilo/skills/kilocode-merge-minimizer/SKILL.md` for the decision rules. + +### 4. Cloud config schema mirror + +When `Config.Info` in `packages/opencode/src/config/config.ts` gains a new `kilocode_change` field, the matching JSON Schema entry must also be added in the cloud repo (`apps/web/src/app/config.json/extras.ts`). CI does **not** check this — flag it as a reminder if you see a new config field added. + +### 5. Test quality + +- Tests should exercise real implementation, not duplicate logic into the test. +- Mocks should be avoided where reasonable; flag mock-heavy tests that look like they're testing the mock rather than the code. +- New behavior in `packages/opencode/` should generally come with a test under `packages/opencode/test/`. + +### 6. User-facing changes + +- Features, bug fixes, and breaking changes should include a changeset (`.changeset/*.md`). If a PR clearly changes user-visible behavior and has no changeset, mention it. +- Changeset descriptions are read by end users — if one is present but written as implementation notes ("Add a new export handler that serializes…"), suggest a user-facing rewrite ("Support exporting conversations as markdown"). +- PR descriptions should explain **why**, not enumerate files. Skip file-by-file inventories. + +### 7. UI changes + +For changes under `packages/kilo-vscode/webview-ui/`: + +- Significant visual or layout changes should have a Storybook story added under `webview-ui/src/stories/`. Minor tweaks and i18n-only changes don't need one. +- Don't ask for locally generated baseline PNGs — those must come from Linux CI. + +## How to comment + +- Leave comments on the exact line via `gh api .../pulls/{n}/comments`. +- Make it clear suggestions are suggestions; the human decides. +- If the PR is clean against the above, comment `lgtm` and nothing else. diff --git a/bun.lock b/bun.lock index 3ff080a4b21..3417296923f 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.40", + "version": "7.3.3", "bin": { "opencode": "./bin/opencode", }, @@ -68,15 +68,16 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", "@markdoc/markdoc": "^0.5.4", "@markdoc/next.js": "^0.5.0", "@vscode/codicons": "^0.0.44", + "@xyflow/react": "12.10.2", "js-yaml": "^4.1.0", - "mermaid": "11.12.3", + "mermaid": "11.15.0", "next": "^16.1.5", "posthog-js": "^1.335.3", "prismjs": "^1.30.0", @@ -97,7 +98,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -133,7 +134,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.40", + "version": "7.3.3", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -143,7 +144,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -153,6 +154,7 @@ "@qdrant/js-client-rest": "1.17.0", "async-mutex": "0.5.0", "chokidar": "4.0.3", + "effect": "catalog:", "glob": "13.0.6", "hono": "catalog:", "hono-openapi": "catalog:", @@ -177,7 +179,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -191,8 +193,9 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { + "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", @@ -200,6 +203,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/rootless": "1.5.2", + "diff": "catalog:", "lucide-solid": "0.576.0", "motion": "12.34.5", "motion-dom": "12.34.3", @@ -226,9 +230,10 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -244,14 +249,15 @@ "dotenv": "^16.4.7", "fastest-levenshtein": "^1.0.16", "friendly-words": "1.3.1", + "fuzzysort": "3.1.0", "ignore": "^7.0.3", "js-tiktoken": "^1.0.18", "lru-cache": "^11.0.2", + "marked": "catalog:", "openai": "^4.85.4", "quick-lru": "^7.0.0", - "simple-git": "3.35.2", + "simple-git": "3.36.0", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", @@ -287,7 +293,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.40", + "version": "7.3.3", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -390,9 +396,8 @@ "ripgrep": "0.3.1", "rotating-file-stream": "3.2.9", "semver": "^7.6.3", - "simple-git": "3.35.2", + "simple-git": "3.36.0", "solid-js": "catalog:", - "stream-chat": "9.38.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", @@ -446,7 +451,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -461,8 +466,8 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.1.105", - "@opentui/solid": ">=0.1.105", + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2", }, "optionalPeers": [ "@opentui/core", @@ -471,7 +476,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +487,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -497,7 +502,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.40", + "version": "7.3.3", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -520,7 +525,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.40", + "version": "7.3.3", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -535,14 +540,14 @@ "@solidjs/router": "catalog:", "@typescript/native-preview": "catalog:", "diff": "catalog:", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", - "mermaid": "11.14.0", + "mermaid": "11.15.0", "morphdom": "2.7.8", "motion": "12.34.5", "motion-dom": "12.34.3", @@ -580,7 +585,6 @@ "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.46", @@ -589,7 +593,7 @@ "@xmldom/xmldom": ">=0.8.12", "defu": "6.1.6", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "effect": "catalog:", "fastify": ">=5.8.3", "happy-dom": ">=20.8.9", @@ -608,8 +612,8 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.1.105", - "@opentui/solid": "0.1.105", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@solid-primitives/storage": "4.3.3", @@ -628,10 +632,10 @@ "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.12.12", "hono-openapi": "1.1.2", @@ -972,15 +976,7 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], - - "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], - - "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], - - "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], @@ -1352,7 +1348,7 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -1504,21 +1500,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], + "@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], - "@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], + "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2134,8 +2130,6 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], - "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], "@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="], @@ -2202,8 +2196,6 @@ "@types/vscode": ["@types/vscode@1.116.0", "", {}, "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -2318,6 +2310,10 @@ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="], + + "@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], @@ -2426,7 +2422,7 @@ "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], - "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -2494,13 +2490,13 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], @@ -2558,10 +2554,6 @@ "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], - "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -2574,6 +2566,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="], "clean-stack": ["clean-stack@4.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg=="], @@ -2744,7 +2738,7 @@ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -2818,7 +2812,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], + "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -2838,7 +2832,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], @@ -2878,6 +2872,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "esbuild-plugin-solid": ["esbuild-plugin-solid@0.6.0", "", { "dependencies": { "@babel/core": "^7.20.12", "@babel/preset-typescript": "^7.18.6", "babel-preset-solid": "^1.6.9" }, "peerDependencies": { "esbuild": ">=0.20", "solid-js": ">= 1.0" } }, "sha512-V1FvDALwLDX6K0XNYM9CMRAnMzA0+Ecu55qBUT9q/eAJh1KIDsTMFoOzMSgyHqbOfvrVfO3Mws3z7TW2GVnIZA=="], @@ -3114,8 +3110,6 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -3364,8 +3358,6 @@ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], - "langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], @@ -3404,8 +3396,6 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], - "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], - "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -3498,7 +3488,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="], + "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -4064,7 +4054,7 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "simple-git": ["simple-git@3.35.2", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.2", "@simple-git/argv-parser": "^1.0.3", "debug": "^4.4.0" } }, "sha512-ZMjl06lzTm1EScxEGuM6+mEX+NQd14h/B3x0vWU+YOXAMF8sicyi1K4cjTfj5is+35ChJEHDl1EjypzYFWH2FA=="], + "simple-git": ["simple-git@3.36.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.3", "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" } }, "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q=="], "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], @@ -4156,8 +4146,6 @@ "storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="], - "stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="], - "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -4428,16 +4416,8 @@ "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], @@ -4454,7 +4434,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4522,6 +4502,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@actions/artifact/@actions/core": ["@actions/core@2.0.3", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.2" } }, "sha512-Od9Thc3T1mQJYddvVPM4QGiLUewdh+3txmDYHHxoNdkqysR1MbCT+rFOtNUxYAz+7+6RIsqipVahY2GJqGPyxA=="], @@ -4730,7 +4712,7 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/plugin/effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], + "@opencode-ai/plugin/effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], "@opencode-ai/storybook/@storybook/addon-a11y": ["@storybook/addon-a11y@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.3.5" } }, "sha512-5k6lpgfIeLxvNhE8v3wEzdiu73ONKjF4gmH1AHvfqYd8kIVzQJai0KCDxgvqNncXHQhIWkaf1fg6+9hKaYJyaw=="], @@ -4744,8 +4726,6 @@ "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], - "@opencode-ai/ui/mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], - "@opencode-ai/ui/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -4780,9 +4760,9 @@ "@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], "@storybook/addon-links/storybook": ["storybook@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw=="], @@ -4908,8 +4888,6 @@ "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], - "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4950,6 +4928,8 @@ "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "esbuild-plugin-solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "eslint/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5026,8 +5006,6 @@ "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -5074,6 +5052,10 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], + + "opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], + "ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], @@ -5188,9 +5170,9 @@ "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], - "vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "vite-plugin-solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], - "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -5262,7 +5244,7 @@ "@kilocode/kilo-gateway/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], - "@kilocode/kilo-gateway/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], + "@kilocode/kilo-gateway/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "@kilocode/kilo-gateway/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -5308,8 +5290,6 @@ "@opencode-ai/plugin/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@opencode-ai/plugin/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - "@opencode-ai/plugin/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "@opencode-ai/storybook/@storybook/addon-docs/@storybook/csf-plugin": ["@storybook/csf-plugin@10.3.5", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.3.5", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw=="], @@ -5322,12 +5302,6 @@ "@opencode-ai/ui/@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - "@opencode-ai/ui/mermaid/dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - - "@opencode-ai/ui/mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - - "@opencode-ai/ui/mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], @@ -5338,14 +5312,10 @@ "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@standard-community/standard-json/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - "@standard-community/standard-json/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@standard-community/standard-openapi/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - "@standard-community/standard-openapi/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "@storybook/addon-links/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -5600,6 +5570,26 @@ "opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="], + + "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="], + + "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="], + + "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="], + + "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="], + + "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], + + "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + + "opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -5800,6 +5790,8 @@ "mocha/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/bunfig.toml b/bunfig.toml index 36a21d9332a..25923129a8c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,7 @@ [install] exact = true +minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) +minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser"] [test] root = "./do-not-run-tests-from-root" diff --git a/install b/install index c4186688256..f78cbc8d1b6 100755 --- a/install +++ b/install @@ -348,6 +348,10 @@ download_and_install() { # kilocode_change start mv "$tmp_dir/kilo" "$INSTALL_DIR" + rm -rf "${INSTALL_DIR}/tree-sitter" + if [ -d "$tmp_dir/tree-sitter" ]; then + mv "$tmp_dir/tree-sitter" "$INSTALL_DIR" + fi chmod 755 "${INSTALL_DIR}/kilo" # kilocode_change end rm -rf "$tmp_dir" @@ -385,6 +389,57 @@ add_to_path() { fi } +# Persistently add $INSTALL_DIR to the Windows user PATH via the registry. +# Works from Git Bash (MINGW64), MSYS2, and Cygwin. +# Converts the MSYS/Cygwin path to native Windows form so that cmd and +# PowerShell also see the entry. +add_to_windows_path() { + local win_install_dir + if command -v cygpath >/dev/null 2>&1; then + win_install_dir=$(cygpath -w "$INSTALL_DIR") + else + # Fallback: naive conversion /c/Users/... -> C:\Users\... + win_install_dir=$(echo "$INSTALL_DIR" | sed -e 's|^/\([a-zA-Z]\)/|\1:\\|' -e 's|/|\\|g') + fi + + # Escape single-quotes for PowerShell single-quoted strings ('' is the escape sequence) + local win_install_dir_ps="${win_install_dir//\'/\'\'}" + + local ps_cmd + ps_cmd=$(cat </dev/null 2>&1; then + ps_output=$(powershell.exe -NoProfile -NonInteractive -Command "$ps_cmd" 2>/dev/null) || return 1 + elif command -v pwsh >/dev/null 2>&1; then + ps_output=$(pwsh -NoProfile -NonInteractive -Command "$ps_cmd" 2>/dev/null) || return 1 + else + return 1 + fi + + if [[ "$ps_output" == *"added"* ]]; then + print_message info "${MUTED}Successfully added ${NC}kilo ${MUTED}to Windows user PATH${NC}" + fi + + # Also add to the current bash session so `kilo` works immediately + # without the user needing to open a new terminal. + if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + export PATH="$INSTALL_DIR:$PATH" + fi + + return 0 +} + XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} current_shell=$(basename "$SHELL") @@ -411,6 +466,13 @@ case $current_shell in esac if [[ "$no_modify_path" != "true" ]]; then + # Detect if we are running on Windows (MINGW, MSYS, Cygwin) regardless + # of whether the binary was installed from download or --binary. + _running_on_windows=false + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) _running_on_windows=true ;; + esac + config_file="" for file in $config_files; do if [[ -f $file ]]; then @@ -419,7 +481,12 @@ if [[ "$no_modify_path" != "true" ]]; then fi done - if [[ -z $config_file ]]; then + if [[ "$_running_on_windows" == "true" ]] && add_to_windows_path; then + # Handled via the Windows user-level PATH in the registry. + # The change is visible in new cmd / PowerShell / Git Bash sessions. + # We also exported it into the current session above. + : + elif [[ -z $config_file ]]; then print_message warning "No config file found for $current_shell. You may need to manually add to PATH:" print_message info " export PATH=$INSTALL_DIR:\$PATH" elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then @@ -454,10 +521,46 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then fi # kilocode_change start +logo_supports() { + case "${KILO_UNICODE_LOGO:-}" in + 1|true|TRUE|yes|YES|on|ON) + return 0 + ;; + 0|false|FALSE|no|NO|off|OFF) + return 1 + ;; + esac + + if [ "${TERM:-}" = "dumb" ]; then + return 1 + fi + + if [ -n "${SSH_TTY:-}" ] || [ -n "${SSH_CLIENT:-}" ] || [ -n "${SSH_CONNECTION:-}" ]; then + return 1 + fi + + if [ -n "${ConEmuPID:-}" ] || [ -n "${ANSICON:-}" ]; then + return 1 + fi + + return 0 +} + +print_logo() { + if logo_supports; then + echo -e "██ ██ ██🬺🬏 ██ ██ ██🬺🬏 ████ ██ ██🬺🬏 " + echo -e "████🬺🬏 ██ ██ ██ ██ ██ ██ ██ " + echo -e "██ ██ ██████ 🬁🬬████ 🬁🬬██ 🬁🬬████ 🬁🬬████ ██████ " + return + fi + + echo -e "██ ██ ████ ██ ██ ███ ████ ██ ████ " + echo -e "████ ██ ██ ██ ██ ██ ██ ██ " + echo -e "██ ██ ██████ ██████ ██ ████ ██████ ██████ " +} + echo -e "" -echo -e "██ ▄█▀ ██ ██ ▄████▄ ▄█████ ██ ██ " -echo -e "████ ██ ██ ██ ██ ██ ██ ██ " -echo -e "██ ▀█▄ ██ ██████ ▀████▀ ▀█████ ██████ ██ " +print_logo echo -e "" echo -e "" echo -e "${MUTED}Kilo includes free models so you can start immediately:${NC}" diff --git a/nix/hashes.json b/nix/hashes.json index 04605df30f9..2cebfd1e2e5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-BkFDOCEvivFrxKOvMjAIhj3QZwmb9vE/KvCZv5puw6E=", - "aarch64-linux": "sha256-sTkfQNjqCNCxmHbpQ8woA6azoiyD37e8Xy7UwB6eNsA=", - "aarch64-darwin": "sha256-lKuagsTHqij5C764DM3d+BOe4JKs2KV8XiwSlJItGMg=", - "x86_64-darwin": "sha256-g5gZMHLq8feSZHVaCOLGU6Tj2Qw56Zogif3pT9EIlz8=" + "x86_64-linux": "sha256-lENbjYvGZewVWQcr7zXJn+v1hrvipHEnjE1yS+kEwLs=", + "aarch64-linux": "sha256-yqTdPGtoLEOaFc7PhBFRiS/6gI3xUgHG/cQcULKytyM=", + "aarch64-darwin": "sha256-lyn6yjD8qv2KwIDwlA8LXBPkcHTCZCtXIsktK5acoXM=", + "x86_64-darwin": "sha256-diaLa3lmWzLcm4TeAwH1hFIqQYQQUcG4YD306vQWCqA=" } } diff --git a/package.json b/package.json index a67e5c4a566..a38a50a3dd6 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,16 @@ "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", - "typecheck": "bun turbo typecheck", + "typecheck": "bun turbo typecheck --filter=!@kilocode/kilo-jetbrains", "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", "test": "echo 'do not run tests from root' && exit 1", - "extension": "bun --cwd packages/kilo-vscode script/launch.ts" + "extension": "bun --cwd packages/kilo-vscode script/launch.ts", + "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup" }, "workspaces": { "packages": [ @@ -31,8 +31,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.1.105", - "@opentui/solid": "0.1.105", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -47,10 +47,10 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.12.12", @@ -82,7 +82,6 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", "glob": "13.0.5", @@ -93,6 +92,7 @@ "semver": "^7.6.0", "sst": "3.18.10", "turbo": "2.8.13", + "@types/bun": "catalog:", "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.27.10" }, @@ -137,15 +137,14 @@ "smol-toml": ">=1.6.1", "fastify": ">=5.8.3", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "happy-dom": ">=20.8.9" }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, - "version": "7.2.40", + "version": "7.3.3", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 26ae1f6220f..98d4c526642 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.40", + "version": "7.3.3", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -47,5 +47,6 @@ }, "overrides": { "drizzle-orm": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 8faaf95f760..2e7a450d9b4 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -7,6 +7,28 @@ import { Effect, FileSystem, Layer, Schema, Context } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" +// kilocode_change start - Windows-resilient mkdir -p. +// fs.mkdir(dir, { recursive: true }) should be idempotent, but on Windows +// with NTFS reparse points (OneDrive), directory junctions, or WSL-served +// paths, libuv can still throw EEXIST. This wrapper catches that specific +// error so callers get the promised directory-exists semantics. +// +// https://github.com/Kilo-Org/kilocode/issues/9618 +// https://github.com/Kilo-Org/kilocode/issues/9755 +function isEexist(err: unknown): boolean { + return typeof err === "object" && err !== null && "code" in err && (err as NodeJS.ErrnoException).code === "EEXIST" +} + +async function mkdirSafe(dir: string): Promise { + try { + await NFS.mkdir(dir, { recursive: true }) + } catch (err: unknown) { + if (isEexist(err)) return + throw err + } +} +// kilocode_change end + export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { method: Schema.String, @@ -24,6 +46,7 @@ export namespace AppFileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect readonly existsSafe: (path: string) => Effect.Effect + readonly readFileStringSafe: (path: string) => Effect.Effect readonly readJson: (path: string) => Effect.Effect readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect readonly ensureDir: (path: string) => Effect.Effect @@ -47,6 +70,12 @@ export namespace AppFileSystem { return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) }) + const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) { + return yield* fs + .readFileString(path) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + }) + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) return info?.type === "Directory" @@ -84,7 +113,12 @@ export namespace AppFileSystem { }) const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { - yield* fs.makeDirectory(path, { recursive: true }) + // kilocode_change start - use mkdirSafe to tolerate Windows EEXIST + yield* Effect.tryPromise({ + try: () => mkdirSafe(path), + catch: (cause) => new FileSystemError({ method: "ensureDir", cause }), + }) + // kilocode_change end }) const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( @@ -99,7 +133,12 @@ export namespace AppFileSystem { (e) => e.reason._tag === "NotFound", () => Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) + // kilocode_change start - use mkdirSafe to tolerate Windows EEXIST + yield* Effect.tryPromise({ + try: () => mkdirSafe(dirname(path)), + catch: (cause) => new FileSystemError({ method: "writeWithDirs:mkdir", cause }), + }) + // kilocode_change end yield* write }), ), @@ -163,6 +202,7 @@ export namespace AppFileSystem { return Service.of({ ...fs, existsSafe, + readFileStringSafe, isDir, isFile, readDirectoryEntries, diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ff01b402d0b..ffcf867286b 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -78,8 +83,16 @@ export const Flag = { KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"), KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"], - KILO_EXPERIMENTAL_HTTPAPI: truthy("KILO_EXPERIMENTAL_HTTPAPI"), + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + KILO_EXPERIMENTAL_HTTPAPI: + truthy("KILO_EXPERIMENTAL_HTTPAPI") || + (!falsy("KILO_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), KILO_EXPERIMENTAL_WORKSPACES: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES"), + KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a514d48d93b..645c1a976f3 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,6 +5,8 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { markNoIndex } from "./kilocode/spotlight" // kilocode_change +import { ensureRealDir } from "./kilocode/global" // kilocode_change +import { Flag } from "./flag/flag" const app = "kilo" // kilocode_change // kilocode_change start @@ -21,6 +23,7 @@ const cache = path.join(clean(xdgCache)!, app) const config = path.join(clean(xdgConfig)!, app) const state = path.join(clean(xdgState)!, app) // kilocode_change end +const tmp = path.join(os.tmpdir(), app) const paths = { get home() { @@ -32,6 +35,7 @@ const paths = { cache, config, state, + tmp, } export const Path = paths @@ -39,11 +43,12 @@ export const Path = paths Flock.setGlobal({ state }) await Promise.all([ - fs.mkdir(Path.data, { recursive: true }), - fs.mkdir(Path.config, { recursive: true }), - fs.mkdir(Path.state, { recursive: true }), - fs.mkdir(Path.log, { recursive: true }), - fs.mkdir(Path.bin, { recursive: true }), + ensureRealDir(Path.data), // kilocode_change + ensureRealDir(Path.config), // kilocode_change + ensureRealDir(Path.state), // kilocode_change + ensureRealDir(Path.tmp), // kilocode_change + ensureRealDir(Path.log), // kilocode_change + ensureRealDir(Path.bin), // kilocode_change ]) // kilocode_change start - keep generated Kilo data out of macOS Spotlight @@ -58,23 +63,34 @@ export interface Interface { readonly cache: string readonly config: string readonly state: string + readonly tmp: string readonly bin: string readonly log: string } +export function make(input: Partial = {}): Interface { + return { + home: Path.home, + data: Path.data, + cache: Path.cache, + config: Flag.KILO_CONFIG_DIR ?? Path.config, + state: Path.state, + tmp: Path.tmp, + bin: Path.bin, + log: Path.log, + ...input, + } +} + export const layer = Layer.effect( Service, - Effect.gen(function* () { - return Service.of({ - home: Path.home, - data: Path.data, - cache: Path.cache, - config: Path.config, - state: Path.state, - bin: Path.bin, - log: Path.log, - }) - }), + Effect.sync(() => Service.of(make())), ) +export const layerWith = (input: Partial) => + Layer.effect( + Service, + Effect.sync(() => Service.of(make(input))), + ) + export * as Global from "./global" diff --git a/packages/core/src/kilocode/global.ts b/packages/core/src/kilocode/global.ts new file mode 100644 index 00000000000..b57d06f7ae9 --- /dev/null +++ b/packages/core/src/kilocode/global.ts @@ -0,0 +1,23 @@ +import fs from "fs/promises" + +/** + * Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and + * junctions whose target no longer exists (a Windows edge-case where the user + * had a junction at e.g. `~/.kilocode` pointing to a deleted directory). + * + * `fs.mkdir({ recursive: true })` silently no-ops when a junction exists even + * if its target is gone, so subsequent writes inside that path fail with ENOENT. + * We detect this by calling `fs.stat` (which follows the symlink/junction) after + * mkdir: if stat fails the entry is broken and we remove + recreate it. + */ +export async function ensureRealDir(p: string) { + await fs.mkdir(p, { recursive: true }) + const ok = await fs + .stat(p) + .then(() => true) + .catch(() => false) + if (!ok) { + await fs.rm(p, { force: true }) + await fs.mkdir(p, { recursive: true }) + } +} diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 92e40427681..8dac8faf012 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -120,13 +120,17 @@ export const layer = Layer.effect( } })() - if (yield* afs.existsSafe(dir)) { + if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { return resolveEntryPoint(name, path.join(dir, "node_modules", name)) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + if (!first) { + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + if (Option.isSome(result.entrypoint)) return result + return yield* new InstallFailedError({ add: [pkg], dir }) + } return resolveEntryPoint(first.name, first.path) }, Effect.scoped) diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index 1181317e082..85ffbd915dc 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import { existsSync, writeFileSync } from "fs" // kilocode_change import fs from "fs/promises" diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index b77f4e356fa..1d9405333da 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -65,6 +65,34 @@ describe("AppFileSystem", () => { ) }) + describe("readFileStringSafe", () => { + it( + "returns file contents when file exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "hello") + + const result = yield* fs.readFileStringSafe(file) + expect(result).toBe("hello") + }), + ) + + it( + "returns undefined for missing file (NotFound)", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + + const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt")) + expect(result).toBeUndefined() + }), + ) + }) + describe("readJson / writeJson", () => { it( "round-trips JSON data", diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3dc3ee2c8b6..c442a62cf5c 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -18,20 +18,17 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -const msg: Msg = JSON.parse(process.argv[2]!) +const msg: Msg = JSON.parse(process.argv[2]) -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts new file mode 100644 index 00000000000..4e13e884243 --- /dev/null +++ b/packages/core/test/global.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Global } from "@opencode-ai/core/global" + +describe("global paths", () => { + test("tmp path is under the system temp directory", () => { + expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode")) + expect(Global.make().tmp).toBe(Global.Path.tmp) + }) + + test("tmp path is created on module load", async () => { + expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) + }) +}) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 3e94a08692c..3d0767aaffa 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,7 +1,12 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record) => }), ) +const npmLayer = (cache: string) => + Npm.layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), + Layer.provide(NodeFileSystem.layer), + ) + describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") @@ -29,6 +42,28 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.add", () => { + test("reifies when package cache directory exists without the package installed", async () => { + await using tmp = await tmpdir() + await fs.mkdir(path.join(tmp.path, "fixture-provider")) + await writePackage(path.join(tmp.path, "fixture-provider"), { + name: "fixture-provider", + main: "index.js", + }) + await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + + const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` + await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) + + const entry = await Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(spec) + }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) + + expect(Option.isSome(entry.entrypoint)).toBe(true) + }) +}) + describe("Npm.install", () => { test("respects omit from project .npmrc", async () => { await using tmp = await tmpdir() diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 9e8bc24ace2..76cee4f8e02 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) { // Test layer // --------------------------------------------------------------------------- -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 160c631887e..4bb417f5b41 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,13 +3,6 @@ "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "noUncheckedIndexedAccess": false, - "types": ["bun"], - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + "types": ["bun"] } } diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 67b25c223f5..d4947263315 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.40" +version = "7.3.3" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.3/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.3/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.3/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.3/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.3/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/__tests__/sitemap.test.ts b/packages/kilo-docs/__tests__/sitemap.test.ts new file mode 100644 index 00000000000..ba2a0161a10 --- /dev/null +++ b/packages/kilo-docs/__tests__/sitemap.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest" +import { buildSitemapXml } from "../pages/api/sitemap.xml" + +describe("sitemap.xml", () => { + it("produces valid XML with urlset root", () => { + const xml = buildSitemapXml() + expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) + expect(xml).toContain('') + expect(xml).toContain("") + }) + + it("all values start with https://kilo.ai/docs", () => { + const xml = buildSitemapXml() + const locs = [...xml.matchAll(/(.*?)<\/loc>/g)].map((m) => m[1]) + expect(locs.length).toBeGreaterThan(0) + for (const loc of locs) { + expect(loc).toMatch(/^https:\/\/kilo\.ai\/docs/) + } + }) + + it("includes the docs root URL", () => { + const xml = buildSitemapXml() + expect(xml).toContain("https://kilo.ai/docs") + }) + + it("includes known pages from the nav", () => { + const xml = buildSitemapXml() + expect(xml).toContain("https://kilo.ai/docs/getting-started/installing") + expect(xml).toContain("https://kilo.ai/docs/getting-started") + }) + + it("has no duplicate entries", () => { + const xml = buildSitemapXml() + const locs = [...xml.matchAll(/(.*?)<\/loc>/g)].map((m) => m[1]) + const unique = new Set(locs) + expect(locs.length).toBe(unique.size) + }) +}) diff --git a/packages/kilo-docs/components/BrowserFrame/index.tsx b/packages/kilo-docs/components/BrowserFrame/index.tsx new file mode 100644 index 00000000000..d1a80ae074b --- /dev/null +++ b/packages/kilo-docs/components/BrowserFrame/index.tsx @@ -0,0 +1,123 @@ +"use client" + +import React from "react" + +/** + * BrowserFrame wraps content (typically an image) in an abstract browser chrome. + * Provides a minimal title bar with traffic light dots and an optional URL bar. + */ +export function BrowserFrame({ + children, + url, + caption, +}: { + children: React.ReactNode + url?: string + caption?: string +}) { + return ( +
+
+ {/* Title bar */} +
+ {/* Traffic lights */} +
+
+
+
+
+ {/* URL bar */} + {url && ( +
+ {url} +
+ )} +
+ {/* Content */} + {/* + * The `browser-frame-content` class strips the default vertical margin + * applied by descendant `Image` figures (1.5rem top/bottom). The + * BrowserFrame already provides its own outer spacing via the `figure` + * wrapper, and the inner image should sit flush against the chrome. + */} +
+ {children} +
+ +
+ {caption && ( +
+ {caption} +
+ )} +
+ ) +} diff --git a/packages/kilo-docs/components/FlowDiagram/css.d.ts b/packages/kilo-docs/components/FlowDiagram/css.d.ts new file mode 100644 index 00000000000..eb03f42ff91 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/css.d.ts @@ -0,0 +1,9 @@ +declare module "*.css" { + const content: Record + export default content +} + +declare module "@xyflow/react/dist/style.css" { + const content: string + export default content +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/adversarial-loop.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/adversarial-loop.ts new file mode 100644 index 00000000000..da0a57fbbb3 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/adversarial-loop.ts @@ -0,0 +1,149 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const baseStyle = { + padding: "14px 22px", + borderRadius: "8px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "160px", +} + +const nodes: Node[] = [ + { + id: "polecat-write", + position: { x: 0, y: 80 }, + data: { label: "Polecat writes code" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(59,130,246,0.1)", + border: "1px solid rgba(59,130,246,0.5)", + color: "#3b82f6", + }, + }, + { + id: "push", + position: { x: 240, y: 80 }, + data: { label: "Push branch" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(255,255,255,0.03)", + border: "1px solid rgba(255,255,255,0.15)", + color: "#ccc", + minWidth: "120px", + }, + }, + { + id: "refinery-review", + position: { x: 440, y: 80 }, + data: { label: "Refinery reviews" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(251,191,36,0.1)", + border: "1px solid rgba(251,191,36,0.5)", + color: "#fbbf24", + }, + }, + { + id: "approve", + position: { x: 680, y: 20 }, + data: { label: "✓ Approve & Merge" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(34,197,94,0.1)", + border: "1px solid rgba(34,197,94,0.5)", + color: "#22c55e", + }, + }, + { + id: "feedback", + position: { x: 680, y: 140 }, + data: { label: "✗ Send feedback" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(249,115,22,0.1)", + border: "1px solid rgba(249,115,22,0.5)", + color: "#f97316", + }, + }, + { + id: "revise", + position: { x: 240, y: 220 }, + data: { label: "Polecat revises" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...baseStyle, + background: "rgba(59,130,246,0.1)", + border: "1px solid rgba(59,130,246,0.5)", + color: "#3b82f6", + }, + }, +] + +const edges: Edge[] = [ + { + id: "write-push", + source: "polecat-write", + target: "push", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "push-review", + source: "push", + target: "refinery-review", + animated: true, + style: { strokeWidth: 2, stroke: "#fbbf24" }, + }, + { + id: "review-approve", + source: "refinery-review", + target: "approve", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "review-feedback", + source: "refinery-review", + target: "feedback", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#f97316", strokeDasharray: "5 3" }, + }, + { + id: "feedback-revise", + source: "feedback", + target: "revise", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#f97316", strokeDasharray: "5 3" }, + }, + { + id: "revise-review", + source: "revise", + target: "refinery-review", + type: "smoothstep", + animated: true, + label: "re-submit", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" }, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, +] + +export const adversarialLoop: DiagramDefinition = { + nodes, + edges, + caption: "The micro-adversarial loop — write, review, revise until quality is met", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/bead-lifecycle.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/bead-lifecycle.ts new file mode 100644 index 00000000000..0f0366822aa --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/bead-lifecycle.ts @@ -0,0 +1,139 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const nodeBase = { + style: { + padding: "12px 20px", + borderRadius: "8px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + border: "1px solid", + textAlign: "center" as const, + minWidth: "120px", + }, +} as const satisfies Pick + +const nodes = [ + { + id: "open", + position: { x: 0, y: 120 }, + data: { label: "open" }, + // not sure why, but using Position.Top results in everything breaking at runtime + // we just settle for casing as Position instead + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(34,197,94,0.1)", + borderColor: "rgba(34,197,94,0.5)", + color: "#22c55e", + }, + }, + { + id: "in_progress", + position: { x: 200, y: 120 }, + data: { label: "in_progress" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(59,130,246,0.1)", + borderColor: "rgba(59,130,246,0.5)", + color: "#3b82f6", + }, + }, + { + id: "in_review", + position: { x: 420, y: 120 }, + data: { label: "in_review" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(251,191,36,0.1)", + borderColor: "rgba(251,191,36,0.5)", + color: "#fbbf24", + }, + }, + { + id: "closed", + position: { x: 640, y: 120 }, + data: { label: "closed" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(139,92,246,0.1)", + borderColor: "rgba(139,92,246,0.5)", + color: "#8b5cf6", + }, + }, + { + id: "failed", + position: { x: 310, y: 280 }, + data: { label: "failed" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(239,68,68,0.1)", + borderColor: "rgba(239,68,68,0.5)", + color: "#ef4444", + }, + }, +] as const satisfies Node[] + +const edgeBase = { + style: { strokeWidth: 2 }, + animated: true, +} + +const edges = [ + { + id: "open-progress", + source: "open", + target: "in_progress", + type: "smoothstep", + label: "agent dispatched", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" }, + ...edgeBase, + style: { ...edgeBase.style, stroke: "#3b82f6" }, + }, + { + id: "progress-review", + source: "in_progress", + target: "in_review", + type: "smoothstep", + label: "work complete", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888", zIndex: 10 }, + ...edgeBase, + style: { ...edgeBase.style, stroke: "#fbbf24" }, + }, + { + id: "review-closed", + source: "in_review", + target: "closed", + type: "smoothstep", + label: "approved & merged", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" }, + ...edgeBase, + style: { ...edgeBase.style, stroke: "#8b5cf6" }, + }, + { + id: "progress-failed", + source: "in_progress", + target: "failed", + type: "smoothstep", + label: "max retries", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888", marginTop: "100px" }, + ...edgeBase, + style: { ...edgeBase.style, strokeWidth: 2, stroke: "#ef4444", strokeDasharray: "5 3" }, + }, +] as const satisfies Edge[] + +export const beadLifecycle = { + nodes, + edges, + caption: "The bead lifecycle — from open to closed, with adversarial review in between", +} as const satisfies DiagramDefinition diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/claim-to-stamp.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/claim-to-stamp.ts new file mode 100644 index 00000000000..d992b00ec18 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/claim-to-stamp.ts @@ -0,0 +1,213 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const laneStyle = (rgb: string, color: string) => ({ + padding: "10px 18px", + borderRadius: "8px", + fontSize: "12px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "140px", + background: `rgba(${rgb},0.1)`, + border: `1px solid rgba(${rgb},0.5)`, + color, +}) + +const gt = laneStyle("59,130,246", "#3b82f6") +const wl = laneStyle("248,160,32", "#f8a020") +const val = laneStyle("167,139,250", "#a78bfa") +const rep = laneStyle("34,197,94", "#22c55e") + +const nodes: Node[] = [ + { + id: "browse", + position: { x: 0, y: 0 }, + data: { label: "Mayor browse" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: gt, + }, + { + id: "wl-return", + position: { x: 250, y: 0 }, + data: { label: "Wasteland returns\nwanted items" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: wl, + }, + { + id: "claim", + position: { x: 0, y: 100 }, + data: { label: "Mayor claim" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: gt, + }, + { + id: "wl-lock", + position: { x: 250, y: 100 }, + data: { label: "Wasteland locks\nitem → DoltHub PR" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: wl, + }, + { + id: "work", + position: { x: 0, y: 210 }, + data: { label: "Polecats work\n→ push branch" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: gt, + }, + { + id: "done", + position: { x: 0, y: 320 }, + data: { label: "Mayor done\n(evidence)" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: gt, + }, + { + id: "wl-attach", + position: { x: 250, y: 320 }, + data: { label: "Wasteland attaches\nevidence to PR" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: wl, + }, + { + id: "review", + position: { x: 500, y: 320 }, + data: { label: "Validator\nreviews PR" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: val, + }, + { + id: "stamp", + position: { x: 500, y: 450 }, + data: { label: "Stamps:\nquality · reliability\n· creativity" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: val, + }, + { + id: "reputation", + position: { x: 250, y: 560 }, + data: { label: "Reputation ledger\nupdates" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: rep, + }, + { + id: "identity", + position: { x: 0, y: 560 }, + data: { label: "Portable\nidentity" }, + sourcePosition: "top" as Position, + targetPosition: "right" as Position, + style: rep, + }, +] + +const labelStyle = { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" } + +const edges: Edge[] = [ + { + id: "browse-return", + source: "browse", + target: "wl-return", + type: "smoothstep", + animated: true, + label: "1 browse", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "claim-lock", + source: "claim", + target: "wl-lock", + type: "smoothstep", + animated: true, + label: "2 claim", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "lock-work", + source: "wl-lock", + target: "work", + type: "smoothstep", + label: "3 work", + labelStyle, + style: { strokeWidth: 2, stroke: "#f8a020" }, + }, + { + id: "work-done", + source: "work", + target: "done", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "done-attach", + source: "done", + target: "wl-attach", + type: "smoothstep", + animated: true, + label: "4 evidence", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "attach-review", + source: "wl-attach", + target: "review", + type: "smoothstep", + animated: true, + label: "5 review", + labelStyle, + style: { strokeWidth: 2, stroke: "#a78bfa" }, + }, + { + id: "review-stamp", + source: "review", + target: "stamp", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#a78bfa" }, + }, + { + id: "stamp-rep", + source: "stamp", + target: "reputation", + type: "smoothstep", + animated: true, + label: "6 reputation", + labelStyle, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "rep-identity", + source: "reputation", + target: "identity", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "return-claim", + source: "wl-return", + target: "wl-lock", + type: "smoothstep", + style: { strokeWidth: 1, stroke: "#f8a020", strokeDasharray: "4 4" }, + }, +] + +export const claimToStamp: DiagramDefinition = { + nodes, + edges, + caption: + "Claim-to-stamp flow — from browsing wanted items through claiming, working, submitting evidence, and earning stamps", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/convoy-execution.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/convoy-execution.ts new file mode 100644 index 00000000000..b96ca725c0a --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/convoy-execution.ts @@ -0,0 +1,174 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const stageStyle = (color: string) => ({ + padding: "12px 18px", + borderRadius: "8px", + fontSize: "12px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + background: `rgba(${color},0.1)`, + border: `1px solid rgba(${color},0.5)`, + textAlign: "center" as const, + minWidth: "140px", +}) + +const closedColor = "139,92,246" +const activeColor = "59,130,246" +const pendingColor = "255,255,255" +const reviewColor = "251,191,36" +const mergeColor = "34,197,94" + +const nodes: Node[] = [ + { + id: "bead1", + position: { x: 0, y: 0 }, + data: { label: "1. Explore codebase" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(closedColor), color: "#8b5cf6" }, + }, + { + id: "review1", + position: { x: 0, y: 100 }, + data: { label: "✓ reviewed" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { ...stageStyle(mergeColor), color: "#22c55e", minWidth: "140px", fontSize: "11px" }, + }, + { + id: "bead2", + position: { x: 200, y: 0 }, + data: { label: "2. Design schema" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(closedColor), color: "#8b5cf6" }, + }, + { + id: "review2", + position: { x: 200, y: 100 }, + data: { label: "✓ reviewed" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { ...stageStyle(mergeColor), color: "#22c55e", minWidth: "140px", fontSize: "11px" }, + }, + { + id: "bead3", + position: { x: 400, y: 0 }, + data: { label: "3. Implement API" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(activeColor), color: "#3b82f6" }, + }, + { + id: "review3", + position: { x: 400, y: 100 }, + data: { label: "⟳ in review" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { ...stageStyle(reviewColor), color: "#fbbf24", minWidth: "140px", fontSize: "11px" }, + }, + { + id: "bead4", + position: { x: 600, y: 0 }, + data: { label: "4. Write tests" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(pendingColor), color: "#666", opacity: 0.6 }, + }, + { + id: "landing", + position: { x: 800, y: 35 }, + data: { label: "Landing Review" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...stageStyle(reviewColor), + color: "#fbbf24", + minWidth: "140px", + opacity: 0.4, + border: "1px dashed rgba(251,191,36,0.4)", + }, + }, + { + id: "main", + position: { x: 990, y: 35 }, + data: { label: "→ main" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...stageStyle(mergeColor), + color: "#22c55e", + minWidth: "100px", + opacity: 0.4, + border: "1px dashed rgba(34,197,94,0.4)", + }, + }, +] + +const edges: Edge[] = [ + { + id: "b1-r1", + source: "bead1", + target: "review1", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "r1-b2", + source: "review1", + target: "bead2", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + label: "builds on", + labelStyle: { fontSize: 9, fontFamily: "'JetBrains Mono', monospace", fill: "#666" }, + }, + { + id: "b2-r2", + source: "bead2", + target: "review2", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "r2-b3", + source: "review2", + target: "bead3", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + label: "builds on", + labelStyle: { fontSize: 9, fontFamily: "'JetBrains Mono', monospace", fill: "#666" }, + }, + { + id: "b3-r3", + source: "bead3", + target: "review3", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "r3-b4", + source: "review3", + target: "bead4", + style: { strokeWidth: 1, stroke: "#444", strokeDasharray: "4 4" }, + }, + { + id: "b4-landing", + source: "bead4", + target: "landing", + style: { strokeWidth: 1, stroke: "#444", strokeDasharray: "4 4" }, + }, + { + id: "landing-main", + source: "landing", + target: "main", + style: { strokeWidth: 1, stroke: "#444", strokeDasharray: "4 4" }, + }, +] + +export const convoyExecution: DiagramDefinition = { + nodes, + edges, + caption: "A convoy executing — each bead builds on reviewed work from previous stages", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts new file mode 100644 index 00000000000..20de5b64002 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts @@ -0,0 +1,22 @@ +import type { Node, Edge } from "@xyflow/react" +import { beadLifecycle } from "./bead-lifecycle" +import { adversarialLoop } from "./adversarial-loop" +import { convoyExecution } from "./convoy-execution" +import { wastelandFederation } from "./wasteland-federation" +import { wantedLifecycle } from "./wanted-lifecycle" +import { claimToStamp } from "./claim-to-stamp" + +export interface DiagramDefinition { + nodes: Node[] + edges: Edge[] + caption?: string +} + +export const diagrams: Record = { + "bead-lifecycle": beadLifecycle, + "adversarial-loop": adversarialLoop, + "convoy-execution": convoyExecution, + "wasteland-federation": wastelandFederation, + "wanted-lifecycle": wantedLifecycle, + "claim-to-stamp": claimToStamp, +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts new file mode 100644 index 00000000000..54c7574bec2 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts @@ -0,0 +1,143 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const stateStyle = (color: string, rgb: string) => ({ + padding: "12px 20px", + borderRadius: "8px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "120px", + background: `rgba(${rgb},0.1)`, + border: `1px solid rgba(${rgb},0.5)`, + color, +}) + +const nodes: Node[] = [ + { + id: "posted", + position: { x: 0, y: 0 }, + data: { label: "Posted" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: stateStyle("#22c55e", "34,197,94"), + }, + { + id: "claimed", + position: { x: 200, y: 0 }, + data: { label: "Claimed" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: stateStyle("#3b82f6", "59,130,246"), + }, + { + id: "in_progress", + position: { x: 400, y: 0 }, + data: { label: "In progress" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: stateStyle("#f8a020", "248,160,32"), + }, + { + id: "evidence_submitted", + position: { x: 600, y: 0 }, + data: { label: "Evidence submitted" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: stateStyle("#a78bfa", "167,139,250"), + }, + { + id: "stamped", + position: { x: 850, y: 0 }, + data: { label: "Stamped" }, + sourcePosition: "top" as Position, + targetPosition: "left" as Position, + style: stateStyle("#22c55e", "34,197,94"), + }, + { + id: "rejected", + position: { x: 600, y: 160 }, + data: { label: "Rejected" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: stateStyle("#ef4444", "239,68,68"), + }, + { + id: "abandoned", + position: { x: 200, y: 160 }, + data: { label: "Abandoned" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: stateStyle("#888", "255,255,255"), + }, +] + +const labelStyle = { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" } + +const edges: Edge[] = [ + { + id: "posted-claimed", + source: "posted", + target: "claimed", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "claimed-progress", + source: "claimed", + target: "in_progress", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#f8a020" }, + }, + { + id: "progress-evidence", + source: "in_progress", + target: "evidence_submitted", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#a78bfa" }, + }, + { + id: "evidence-stamped", + source: "evidence_submitted", + target: "stamped", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "evidence-rejected", + source: "evidence_submitted", + target: "rejected", + type: "smoothstep", + label: "rejects", + labelStyle, + style: { strokeWidth: 2, stroke: "#ef4444", strokeDasharray: "5 3" }, + }, + { + id: "rejected-progress", + source: "rejected", + target: "in_progress", + type: "smoothstep", + label: "rework", + labelStyle, + style: { strokeWidth: 2, stroke: "#f8a020", strokeDasharray: "5 3" }, + }, + + { + id: "claimed-abandoned", + source: "claimed", + target: "abandoned", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#888", strokeDasharray: "5 3" }, + }, +] + +export const wantedLifecycle: DiagramDefinition = { + nodes, + edges, + caption: "Wanted item lifecycle — from posted through claimed, in-progress, evidence, and stamped (or rejected)", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/wasteland-federation.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/wasteland-federation.ts new file mode 100644 index 00000000000..5d3ec3d4c97 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/wasteland-federation.ts @@ -0,0 +1,186 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const townStyle = { + padding: "10px 16px", + borderRadius: "8px", + fontSize: "12px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "130px", + background: "rgba(59,130,246,0.1)", + border: "1px solid rgba(59,130,246,0.5)", + color: "#3b82f6", +} + +const wastelandStyle = { + padding: "14px 24px", + borderRadius: "10px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 700, + textAlign: "center" as const, + minWidth: "180px", + background: "rgba(248,160,32,0.1)", + border: "1px solid rgba(248,160,32,0.5)", + color: "#f8a020", +} + +const dbStyle = { + padding: "10px 18px", + borderRadius: "6px", + fontSize: "11px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "140px", + background: "rgba(139,92,246,0.08)", + border: "1px solid rgba(139,92,246,0.4)", + color: "#8b5cf6", +} + +const nodes: Node[] = [ + { + id: "town-you", + position: { x: -60, y: 0 }, + data: { label: "Your Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "town-acme", + position: { x: 140, y: 0 }, + data: { label: "Acme Eng Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "town-oss", + position: { x: 360, y: 0 }, + data: { label: "Open Source Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "commons-wl", + position: { x: 60, y: 180 }, + data: { label: "Commons Wasteland" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: wastelandStyle, + }, + { + id: "commons-db", + position: { x: 80, y: 340 }, + data: { label: "DoltHub DB" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: dbStyle, + }, + { + id: "private-wl", + position: { x: 520, y: 180 }, + data: { label: "Private Team\nWasteland" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: wastelandStyle, + }, + { + id: "private-town1", + position: { x: 540, y: 0 }, + data: { label: "Private Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "private-db", + position: { x: 540, y: 340 }, + data: { label: "DoltHub DB" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: dbStyle, + }, +] + +const labelStyle = { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" } + +const edges: Edge[] = [ + { + id: "town-you-wl", + source: "town-you", + target: "commons-wl", + type: "smoothstep", + animated: true, + label: "claim / submit", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "town-acme-wl", + source: "town-acme", + target: "commons-wl", + type: "smoothstep", + animated: true, + label: "claim / submit", + labelStyle, + zIndex: 10, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "town-oss-wl", + source: "town-oss", + target: "commons-wl", + type: "smoothstep", + animated: true, + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "commons-wl-db", + source: "commons-wl", + target: "commons-db", + type: "smoothstep", + label: "persist", + labelStyle, + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "private-town1-wl", + source: "private-town1", + target: "private-wl", + type: "smoothstep", + animated: true, + label: "claim / submit", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "private-wl-db", + source: "private-wl", + target: "private-db", + type: "smoothstep", + label: "persist", + labelStyle, + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "wl-federation", + source: "commons-wl", + target: "private-wl", + type: "smoothstep", + label: "portable identity", + labelStyle, + style: { strokeWidth: 2, stroke: "#f8a020", strokeDasharray: "6 3" }, + }, +] + +export const wastelandFederation: DiagramDefinition = { + nodes, + edges, + caption: "Federated Wasteland architecture — towns connect to shared Wasteland instances backed by DoltHub", +} diff --git a/packages/kilo-docs/components/FlowDiagram/index.tsx b/packages/kilo-docs/components/FlowDiagram/index.tsx new file mode 100644 index 00000000000..d6b32190b8b --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/index.tsx @@ -0,0 +1,144 @@ +"use client" + +import React, { useState, useEffect, useRef } from "react" +import { diagrams } from "./diagrams" + +/** + * Re-fits the viewport whenever the React Flow container resizes. + * Defined at module scope so its component identity is stable across + * re-renders of FlowDiagram (otherwise React would unmount/remount it + * on every parent render and tear down the ResizeObserver each time). + */ +function FitOnResize({ useReactFlow }: { useReactFlow: typeof import("@xyflow/react").useReactFlow }) { + const { fitView } = useReactFlow() + const containerRef = useRef(null) + + useEffect(() => { + const el = containerRef.current?.closest(".react-flow") as HTMLElement | null + if (!el) return + const observer = new ResizeObserver(() => { + fitView({ padding: 0.15 }) + }) + observer.observe(el) + return () => observer.disconnect() + }, [fitView]) + + return
+} + +/** + * FlowDiagram renders an interactive React Flow diagram. + * Loaded lazily to avoid bundling the entire @xyflow/react library on pages that don't use it. + * Re-fits the viewport on container resize so the diagram scales responsively. + * + * Usage in markdown: + * {% flowDiagram name="bead-lifecycle" /%} + * {% flowDiagram name="adversarial-loop" height="500px" /%} + */ +export function FlowDiagram({ name, height = "400px" }: { name: string; height?: string }) { + const [mod, setMod] = useState(null) + const [cssLoaded, setCssLoaded] = useState(false) + + useEffect(() => { + Promise.all([import("@xyflow/react"), import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true))]).then( + ([xyflow]) => { + setMod(xyflow) + }, + ) + }, []) + + const diagram = diagrams[name] + + if (!diagram) { + return ( +
+ Diagram "{name}" not found +
+ ) + } + + if (!mod || !cssLoaded) { + return ( +
+ Loading diagram... +
+ ) + } + + const { ReactFlow, Background, BackgroundVariant, useReactFlow, ReactFlowProvider } = mod + + return ( +
+
+ + + + + + +
+ {diagram.caption && ( +
+ {diagram.caption} +
+ )} +
+ ) +} diff --git a/packages/kilo-docs/components/Image.tsx b/packages/kilo-docs/components/Image.tsx index e65d4ae0a03..a8ec09cb19e 100644 --- a/packages/kilo-docs/components/Image.tsx +++ b/packages/kilo-docs/components/Image.tsx @@ -6,6 +6,7 @@ interface ImageProps { width?: string height?: string caption?: string + compact?: boolean } // Helper to add 'px' to numeric values that don't have units @@ -17,7 +18,7 @@ function addPxIfNeeded(value: string): string { return value } -export function Image({ src, alt, width, height, caption }: ImageProps) { +export function Image({ src, alt, width, height, caption, compact }: ImageProps) { const imgStyle: React.CSSProperties = { maxWidth: "100%", height: "auto", @@ -27,7 +28,7 @@ export function Image({ src, alt, width, height, caption }: ImageProps) { if (height) imgStyle.height = addPxIfNeeded(height) const figureStyle: React.CSSProperties = { - margin: "1.5rem 0", + margin: compact ? "0" : "1.5rem 0", maxWidth: "100%", overflow: "hidden", } diff --git a/packages/kilo-docs/components/ImageGallery.tsx b/packages/kilo-docs/components/ImageGallery.tsx new file mode 100644 index 00000000000..b7b8d6e0a2d --- /dev/null +++ b/packages/kilo-docs/components/ImageGallery.tsx @@ -0,0 +1,52 @@ +import React, { Children, ReactElement, ReactNode, cloneElement, isValidElement } from "react" + +interface ImageGalleryProps { + children: ReactNode + columns?: string + width?: string +} + +interface GalleryImageProps { + compact?: boolean +} + +function addPxIfNeeded(value: string) { + if (/^\d+(\.\d+)?$/.test(value)) return `${value}px` + return value +} + +export function ImageGallery({ children, columns = "3", width = "220px" }: ImageGalleryProps) { + const count = Number(columns) + const cols = Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 4) : 3 + const size = addPxIfNeeded(width) + + return ( +
+ {Children.map(children, (child) => { + const item = isValidElement(child) + ? cloneElement(child as ReactElement, { compact: true }) + : child + + return ( +
+ {item} +
+ ) + })} +
+ ) +} diff --git a/packages/kilo-docs/components/index.js b/packages/kilo-docs/components/index.js index 22e0aff051b..95694ac103c 100644 --- a/packages/kilo-docs/components/index.js +++ b/packages/kilo-docs/components/index.js @@ -5,6 +5,7 @@ export * from "./CopyPageButton" export * from "./Heading" export * from "./Icon" export * from "./Image" +export * from "./ImageGallery" export * from "./KiloCodeIcon" export * from "./PageFooter" export * from "./PageVersionSwitcher" @@ -16,3 +17,5 @@ export * from "./ThemeToggle" export * from "./TopNav" export * from "./VideoEmbed" export * from "./YouTube" +export * from "./FlowDiagram" +export * from "./BrowserFrame" diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index 3668c18ef72..d0011e9d75f 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -19,10 +19,37 @@ export const CodeWithAiNav: NavSection[] = [ children: "CLI", subLinks: [{ href: "/code-with-ai/platforms/cli-reference", children: "Command Reference" }], }, + { + href: "/code-with-ai/platforms/kilo-connect", + children: "Kilo Connect", + subLinks: [ + { href: "/code-with-ai/platforms/slack", children: "Slack" }, + { href: "/code-with-ai/platforms/github", children: "GitHub" }, + { href: "/code-with-ai/platforms/linear", children: "Linear" }, + ], + }, { href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" }, { href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" }, - { href: "/code-with-ai/platforms/slack", children: "Slack" }, { href: "/code-with-ai/app-builder", children: "App Builder" }, + { + href: "/code-with-ai/gastown", + children: "Gas Town by Kilo", + subLinks: [ + { href: "/code-with-ai/gastown/quick-start", children: "Quick Start" }, + { href: "/code-with-ai/gastown/concepts", children: "Concepts" }, + { href: "/code-with-ai/gastown/mayor", children: "The Mayor" }, + { href: "/code-with-ai/gastown/sling-work", children: "Sling Work" }, + { href: "/code-with-ai/gastown/code-review", children: "Code Review" }, + { href: "/code-with-ai/gastown/settings", children: "Settings" }, + { href: "/code-with-ai/gastown/troubleshooting", children: "Troubleshooting" }, + { href: "/code-with-ai/gastown/wasteland", children: "Wasteland" }, + { href: "/code-with-ai/gastown/wasteland/quick-start", children: "Wasteland: Quick Start" }, + { href: "/code-with-ai/gastown/wasteland/concepts", children: "Wasteland: Concepts" }, + { href: "/code-with-ai/gastown/wasteland/workflow", children: "Wasteland: Workflow" }, + { href: "/code-with-ai/gastown/wasteland/admin", children: "Wasteland: Administration" }, + { href: "/code-with-ai/gastown/wasteland/settings", children: "Wasteland: Settings" }, + ], + }, ], }, { @@ -62,6 +89,7 @@ export const CodeWithAiNav: NavSection[] = [ }, ], }, + { title: "Productivity Tools", links: [ @@ -80,6 +108,7 @@ export const CodeWithAiNav: NavSection[] = [ children: "Git Commit Generation", }, { href: "/code-with-ai/features/speech-to-text", children: "Voice Transcription" }, + { href: "/code-with-ai/features/message-feedback", children: "Message Feedback" }, { href: "/code-with-ai/features/browser-use", children: "Agent Behavior", diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 7e38c2c03cc..5d15d940df1 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -43,4 +43,8 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # Example punycode domain used in homograph attack documentation — does not exist + '^https?://xn--pitest', + # OpenAI docs return 404 to plain GET link checks but resolve in browsers + '^https?://platform\.openai\.com/docs/api-reference/responses/create', ] diff --git a/packages/kilo-docs/markdoc/partials/cli-commands-table.md b/packages/kilo-docs/markdoc/partials/cli-commands-table.md index 6243af9f0a8..e2ae23c4c5e 100644 --- a/packages/kilo-docs/markdoc/partials/cli-commands-table.md +++ b/packages/kilo-docs/markdoc/partials/cli-commands-table.md @@ -14,6 +14,7 @@ | `kilo uninstall` | uninstall kilo and remove all related files | | `kilo serve` | starts a headless kilo server | | `kilo models [provider]` | list all available models | +| `kilo roll-call ` | batch-test text models matching a filter for connectivity and latency | | `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 | diff --git a/packages/kilo-docs/markdoc/tags/browser-frame.markdoc.ts b/packages/kilo-docs/markdoc/tags/browser-frame.markdoc.ts new file mode 100644 index 00000000000..eec24ead458 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/browser-frame.markdoc.ts @@ -0,0 +1,16 @@ +import { BrowserFrame } from "../../components" + +export const browserFrame = { + render: BrowserFrame, + children: ["paragraph", "tag", "list"], + attributes: { + url: { + type: String, + description: "Optional URL to display in the address bar", + }, + caption: { + type: String, + description: "Optional caption below the frame", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/flow-diagram.markdoc.ts b/packages/kilo-docs/markdoc/tags/flow-diagram.markdoc.ts new file mode 100644 index 00000000000..f395b80fed7 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/flow-diagram.markdoc.ts @@ -0,0 +1,18 @@ +import { FlowDiagram } from "../../components" + +export const flowDiagram = { + render: FlowDiagram, + selfClosing: true, + attributes: { + name: { + type: String, + required: true, + description: "Name of the diagram to render (must match a key in diagrams/index.ts)", + }, + height: { + type: String, + default: "400px", + description: "Height of the diagram container", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/image-gallery.markdoc.ts b/packages/kilo-docs/markdoc/tags/image-gallery.markdoc.ts new file mode 100644 index 00000000000..33f9be98371 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/image-gallery.markdoc.ts @@ -0,0 +1,16 @@ +import { ImageGallery } from "../../components" + +export const imageGallery = { + render: ImageGallery, + children: ["tag"], + attributes: { + columns: { + type: String, + description: "Preferred number of image columns on wide screens", + }, + width: { + type: String, + description: "Preferred width of each gallery image", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/index.ts b/packages/kilo-docs/markdoc/tags/index.ts index 27b8d66a201..6fc54c6d902 100644 --- a/packages/kilo-docs/markdoc/tags/index.ts +++ b/packages/kilo-docs/markdoc/tags/index.ts @@ -4,6 +4,9 @@ export * from "./codicon.markdoc" export * from "./tabs.markdoc" export * from "./icon.markdoc" export * from "./image.markdoc" +export * from "./image-gallery.markdoc" export * from "./kilo-code-icon.markdoc" export * from "./video.markdoc" export * from "./youtube.markdoc" +export * from "./flow-diagram.markdoc" +export * from "./browser-frame.markdoc" diff --git a/packages/kilo-docs/next.config.js b/packages/kilo-docs/next.config.js index 3a8835980ff..bc017eda4b1 100644 --- a/packages/kilo-docs/next.config.js +++ b/packages/kilo-docs/next.config.js @@ -30,6 +30,11 @@ module.exports = withMarkdoc(/* config: https://markdoc.io/docs/nextjs#options * source: "/llms.txt", destination: "/api/llms.txt", }, + { + // Rewrite /docs/sitemap.xml to the API endpoint (internal to basePath) + source: "/sitemap.xml", + destination: "/api/sitemap.xml", + }, ], afterFiles: [ { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 1623d1511fb..06d6bbf67f9 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.40", + "version": "7.3.3", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", @@ -13,9 +13,10 @@ "@docsearch/js": "^4", "@markdoc/markdoc": "^0.5.4", "@markdoc/next.js": "^0.5.0", - "js-yaml": "^4.1.0", "@vscode/codicons": "^0.0.44", - "mermaid": "11.12.3", + "@xyflow/react": "12.10.2", + "js-yaml": "^4.1.0", + "mermaid": "11.15.0", "next": "^16.1.5", "posthog-js": "^1.335.3", "prismjs": "^1.30.0", @@ -32,5 +33,6 @@ "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vitest": "^3.2.3" - } + }, + "peerDependencies": {} } diff --git a/packages/kilo-docs/pages/_app.tsx b/packages/kilo-docs/pages/_app.tsx index 245ab29af70..2739752f3a6 100644 --- a/packages/kilo-docs/pages/_app.tsx +++ b/packages/kilo-docs/pages/_app.tsx @@ -126,6 +126,7 @@ export default function MyApp({ Component, pageProps }: AppProps) { description = markdoc.frontmatter.description } } + const noindex = markdoc?.frontmatter?.noindex === true const toc = pageProps.markdoc?.content ? collectHeadings(pageProps.markdoc.content) : [] @@ -139,6 +140,7 @@ export default function MyApp({ Component, pageProps }: AppProps) { + {noindex && } diff --git a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md index 086eb76de33..78eea600978 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md +++ b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md @@ -1,11 +1,26 @@ --- title: "Using ChatGPT Plus/Pro with Kilo Code" -description: "Use your ChatGPT Plus or Pro subscription with Kilo Code. Setup guide for authenticating without a separate API key." +description: "Use your ChatGPT Plus or Pro subscription with Kilo Code. Flat-rate access to OpenAI Codex models with OAuth login — no separate API key required." sidebar_label: ChatGPT Plus/Pro --- # Using ChatGPT Subscriptions With Kilo Code +If you already pay for ChatGPT Plus or Pro, you can use that subscription to run OpenAI's top coding models directly inside Kilo Code — with no extra API charges beyond your subscription. + +## Why use your ChatGPT subscription? + +- **Flat-rate access to OpenAI models:** Your subscription covers usage without pay-as-you-go API costs. +- **OAuth login — no API keys:** Click "Sign in to OpenAI Codex," authenticate in your browser, and you're done. +- **Full agentic workflows:** Generate, refactor, debug, edit files, and run terminal commands inside Kilo Code. +- **Multiple AI modes:** Switch between Code, Plan, Debug, and Ask modes for different tasks. + +{% callout type="note" %} +Your ChatGPT subscription works with Kilo Code's core functionality (VS Code extension and CLI), but does **not** include cloud features such as Cloud Agents, Kilo Deploy, or KiloClaw. To use GPT models in those features, use the [Kilo Gateway](/docs/gateway). +{% /callout %} + +## Setup + {% tabs %} {% tab label="VSCode (Legacy)" %} @@ -47,12 +62,31 @@ Then set your default model to one of the OpenAI Codex models available in Kilo ## Tips and Notes -- **Subscription Required:** You need an active ChatGPT Plus or Pro subscription. This provider won't work with free ChatGPT accounts. See [OpenAI's ChatGPT plans](https://chatgpt.com/pricing/) for more information. -- **Authentication Errors:** If you receive a CSRF or other error when completing OAuth authentication, ensure you do not have another application already listening on port 1455. You can check on Linux and Mac by using `lsof -i :1455`. +- **Subscription Required:** You need an active ChatGPT Plus or Pro subscription. This provider won't work with free ChatGPT accounts. [Codex is included](https://developers.openai.com/codex/pricing/) in ChatGPT Plus, Pro, Business, Edu, and Enterprise plans. See [OpenAI's ChatGPT plans](https://chatgpt.com/pricing/) for more information. - **No API Costs:** Usage through this provider counts against your ChatGPT subscription, not separately billed API usage. +- **Authentication Errors:** If you receive a CSRF or other error when completing OAuth authentication, ensure you do not have another application already listening on port 1455. You can check on Linux and Mac by using `lsof -i :1455`. - **Sign Out:** To disconnect in VS Code, use the "Disconnect" button in the provider settings. In the CLI, run `kilo auth logout` and choose OpenAI. +- **Switching providers:** You can switch to Claude, Gemini, or local models at any time — this provider is optional. ## Limitations -- **You can't use arbitrary OpenAI API models.** This provider only exposes the models listed in Kilo Code's Codex model catalog. -- **You can't export/migrate your sign-in state with settings export.** OAuth tokens are stored in VS Code SecretStorage, which isn't included in Kilo Code's settings export. +- **Codex catalog models only.** This provider only exposes the models listed in Kilo Code's Codex model catalog. It does not give access to every model available through the OpenAI API. +- **OAuth tokens can't be exported with settings.** Tokens are stored in VS Code SecretStorage, which isn't included in Kilo Code's settings export. +- **Cloud features not included.** Cloud Agents, Kilo Deploy, and KiloClaw require the [Kilo Gateway](/docs/gateway). + +## FAQ + +**Do I need a separate API key?** +No — just sign in with OAuth using your ChatGPT subscription. + +**Which ChatGPT plans include Codex access?** +ChatGPT Plus, Pro, Business, Edu, and Enterprise. Free accounts are not supported. + +**How is usage billed?** +Usage counts against your ChatGPT subscription limits — there are no separate API charges. + +**Can I still switch to other AI providers?** +Yes. Use OpenAI when it fits and switch to Claude, Gemini, or any local model at any time. + +**Can I use my ChatGPT subscription in KiloClaw?** +No. For KiloClaw and other cloud features, use GPT models through the [Kilo Gateway](/docs/gateway), which gives access to 500+ models including the latest GPT releases. diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 89551016dcd..02af740576e 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -1,6 +1,6 @@ --- title: "Using OpenAI-Compatible Providers with Kilo Code" -description: "Connect any OpenAI-compatible API endpoint to Kilo Code, including Azure OpenAI, Together AI, and self-hosted servers." +description: "Connect OpenAI-compatible API endpoints like Together AI, Perplexity, and self-hosted servers." sidebar_label: OpenAI Compatible --- @@ -14,6 +14,12 @@ Kilo Code supports a wide range of AI model providers that offer APIs compatible This document focuses on setting up providers _other than_ the official OpenAI API (which has its own [dedicated configuration page](/docs/ai-providers/openai)). +{% callout type="warning" %} +Do not use a custom OpenAI-compatible provider for Azure OpenAI GPT-5 deployments. Azure GPT-5 rejects the `max_tokens` parameter used by generic OpenAI-compatible providers and requires Azure-specific handling. + +Use Kilo Code's native `azure` provider instead. If your Azure deployment name differs from the model name you select in Kilo, map it with the model `id` field in `kilo.json`. +{% /callout %} + ## General Configuration {% tabs %} @@ -21,7 +27,7 @@ This document focuses on setting up providers _other than_ the official OpenAI A The key to using an OpenAI-compatible provider is to configure two main settings: -1. **Base URL:** This is the API endpoint for the provider. It will _not_ be `https://api.openai.com/v1` (that's for the official OpenAI API). +1. **Base URL:** This is the API endpoint for the provider. It will _not_ be `https://api.openai.com/v1` (that's for the official OpenAI API). For Azure OpenAI GPT-5, do not enter your Azure endpoint here. Configure the native `azure` provider instead. 2. **API Key:** This is the secret key you obtain from the provider. 3. **Model ID:** This is the model name of the specific model. @@ -53,7 +59,7 @@ 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. +- **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. - **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. @@ -114,7 +120,7 @@ Then set your default model using the `provider-id/model-id` format: **Configuration fields:** - **`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. +- **`options.baseURL`** — The base URL of your OpenAI-compatible 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: @@ -172,6 +178,7 @@ This enhancement allows you to: - **"Invalid API Key":** Double-check that you've entered the API key correctly. - **"Model Not Found":** Make sure you're using a valid model ID for your chosen provider. - **Connection Errors:** Verify the Base URL is correct and that your provider's API is accessible. +- **Azure GPT-5 rejects `max_tokens`:** Azure GPT-5 deployments must use Kilo Code's native `azure` provider. Generic OpenAI-compatible custom providers send `max_tokens`, which Azure GPT-5 rejects because it expects `max_completion_tokens`. - **Unexpected Results:** If you're getting unexpected results, try a different model. By using an OpenAI-compatible provider, you can leverage the flexibility of Kilo Code with a wider range of AI models. Remember to always consult your provider's documentation for the most accurate and up-to-date information. diff --git a/packages/kilo-docs/pages/ai-providers/openai.md b/packages/kilo-docs/pages/ai-providers/openai.md index f100680ccfa..867d6399bed 100644 --- a/packages/kilo-docs/pages/ai-providers/openai.md +++ b/packages/kilo-docs/pages/ai-providers/openai.md @@ -10,6 +10,10 @@ Kilo Code supports accessing models directly through the official OpenAI API. **Website:** [https://openai.com/](https://openai.com/) +{% callout type="tip" %} +**Already have a ChatGPT Plus or Pro subscription?** You can use it to access OpenAI's Codex models inside Kilo Code — no separate API key or pay-as-you-go charges needed. See the [ChatGPT Plus/Pro provider page](/docs/ai-providers/openai-chatgpt-plus-pro) for setup instructions. +{% /callout %} + ## Getting an API Key 1. **Sign Up/Sign In:** Go to the [OpenAI Platform](https://platform.openai.com/). Create an account or sign in. @@ -71,4 +75,4 @@ Then set your default model: ## Tips and Notes - **Pricing:** Refer to the [OpenAI Pricing](https://openai.com/pricing) page for details on model costs. -- **Azure OpenAI Service:** If you'd like to use the Azure OpenAI service, please see our section on [OpenAI-compatible](/docs/ai-providers/openai-compatible) providers. +- **Azure OpenAI Service:** Use Kilo Code's native `azure` provider for Azure OpenAI, especially GPT-5 deployments. Do not configure Azure GPT-5 through a generic [OpenAI-compatible](/docs/ai-providers/openai-compatible) custom provider. diff --git a/packages/kilo-docs/pages/api/sitemap.xml.ts b/packages/kilo-docs/pages/api/sitemap.xml.ts new file mode 100644 index 00000000000..76b67a40f22 --- /dev/null +++ b/packages/kilo-docs/pages/api/sitemap.xml.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { Nav } from "../../lib/nav" +import type { NavLink, NavSection } from "../../lib/types" + +const origin = "https://kilo.ai" +const base = "/docs" + +function links(sections: NavSection[]): string[] { + const out: string[] = [] + function walk(items: NavLink[]) { + for (const item of items) { + out.push(item.href) + if (item.subLinks) walk(item.subLinks) + } + } + for (const s of sections) walk(s.links) + return out +} + +export function buildSitemapXml(): string { + const all = Object.values(Nav).flatMap(links) + const hrefs = Array.from(new Set(["", ...all])).sort() + const urls = hrefs.map((h) => ` ${origin}${base}${h}`).join("\n") + return ` + +${urls} +` +} + +export default function handler(_: NextApiRequest, res: NextApiResponse) { + const xml = buildSitemapXml() + res.setHeader("Content-Type", "application/xml; charset=utf-8") + res.setHeader("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=86400") + res.status(200).send(xml) +} diff --git a/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md b/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md index c82099bcd8c..ab55916f407 100644 --- a/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md +++ b/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md @@ -1,3 +1,8 @@ +--- +title: Access MCP Resource Tool | Kilo Code Docs +description: Learn how the access_mcp_resource tool retrieves data from Model Context Protocol servers to provide additional context for Kilo Code tasks. +--- + # access_mcp_resource The `access_mcp_resource` tool retrieves data from resources exposed by connected Model Context Protocol (MCP) servers. It allows Kilo Code to access files, API responses, documentation, or system information that provides additional context for tasks. diff --git a/packages/kilo-docs/pages/automate/tools/apply-diff.md b/packages/kilo-docs/pages/automate/tools/apply-diff.md index c5098861f5f..b224cae3b09 100644 --- a/packages/kilo-docs/pages/automate/tools/apply-diff.md +++ b/packages/kilo-docs/pages/automate/tools/apply-diff.md @@ -1,3 +1,8 @@ +--- +title: Apply Diff Tool | Kilo Code Docs +description: Learn how the apply_diff tool makes precise, surgical changes to files using search/replace blocks with intelligent matching algorithms. +--- + # apply_diff The `apply_diff` tool makes precise, surgical changes to files by specifying exactly what content to replace. It uses multiple sophisticated strategies for finding and applying changes while maintaining proper code formatting and structure. diff --git a/packages/kilo-docs/pages/automate/tools/ask-followup-question.md b/packages/kilo-docs/pages/automate/tools/ask-followup-question.md index c19c8475e04..1a1af591cc0 100644 --- a/packages/kilo-docs/pages/automate/tools/ask-followup-question.md +++ b/packages/kilo-docs/pages/automate/tools/ask-followup-question.md @@ -1,3 +1,8 @@ +--- +title: Ask Followup Question Tool | Kilo Code Docs +description: Learn how the ask_followup_question tool enables Kilo Code to gather additional information needed to complete tasks through interactive communication. +--- + # ask_followup_question The `ask_followup_question` tool enables interactive communication by asking specific questions to gather additional information needed to complete tasks effectively. diff --git a/packages/kilo-docs/pages/automate/tools/attempt-completion.md b/packages/kilo-docs/pages/automate/tools/attempt-completion.md index fb85bbf952b..b0a0185a93e 100644 --- a/packages/kilo-docs/pages/automate/tools/attempt-completion.md +++ b/packages/kilo-docs/pages/automate/tools/attempt-completion.md @@ -1,3 +1,8 @@ +--- +title: Attempt Completion Tool | Kilo Code Docs +description: Learn how the attempt_completion tool signals task completion, presents results to the user, and supports continued refinement through feedback. +--- + # attempt_completion The `attempt_completion` tool signals that Kilo Code believes a task is complete and presents results to the user. It provides a summary of what was accomplished, optionally includes a command to demonstrate the result, and supports continued refinement through user feedback. diff --git a/packages/kilo-docs/pages/automate/tools/browser-action.md b/packages/kilo-docs/pages/automate/tools/browser-action.md index 670e6ec5f77..6f9a486ba33 100644 --- a/packages/kilo-docs/pages/automate/tools/browser-action.md +++ b/packages/kilo-docs/pages/automate/tools/browser-action.md @@ -1,3 +1,8 @@ +--- +title: Browser Action Tool | Kilo Code Docs +description: Learn how the browser_action tool enables web automation via a Puppeteer-controlled browser to navigate sites, click elements, and capture screenshots. +--- + # browser_action The `browser_action` tool enables web automation and interaction via a Puppeteer-controlled browser. It allows Kilo Code to launch browsers, navigate to websites, click elements, type text, and scroll pages with visual feedback through screenshots. diff --git a/packages/kilo-docs/pages/automate/tools/delete-file.md b/packages/kilo-docs/pages/automate/tools/delete-file.md index 67c8ef6a1f2..22215c3a68f 100644 --- a/packages/kilo-docs/pages/automate/tools/delete-file.md +++ b/packages/kilo-docs/pages/automate/tools/delete-file.md @@ -1,3 +1,8 @@ +--- +title: Delete File Tool | Kilo Code Docs +description: Learn how the delete_file tool safely removes files and directories from the workspace across all platforms as an alternative to rm commands. +--- + # delete_file Delete a file or directory from the workspace. This tool provides a safe alternative to rm commands and works across all platforms. diff --git a/packages/kilo-docs/pages/automate/tools/execute-command.md b/packages/kilo-docs/pages/automate/tools/execute-command.md index e5f33087922..923b1972fce 100644 --- a/packages/kilo-docs/pages/automate/tools/execute-command.md +++ b/packages/kilo-docs/pages/automate/tools/execute-command.md @@ -1,3 +1,8 @@ +--- +title: Execute Command Tool | Kilo Code Docs +description: Learn how the execute_command tool runs CLI commands on your system to install dependencies, build projects, start servers, and run terminal operations. +--- + # execute_command The `execute_command` tool runs CLI commands on the user's system. It allows Kilo Code to perform system operations, install dependencies, build projects, start servers, and execute other terminal-based tasks needed to accomplish user objectives. diff --git a/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md b/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md index e540d79b8e8..aa9a23e90dd 100644 --- a/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md +++ b/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md @@ -1,3 +1,8 @@ +--- +title: List Code Definition Names Tool | Kilo Code Docs +description: Learn how the list_code_definition_names tool provides a structural overview of your codebase by listing functions, classes, and definitions with line numbers. +--- + # list_code_definition_names The `list_code_definition_names` tool provides a structural overview of your codebase by listing code definitions from source files at the top level of a specified directory. It helps Kilo Code understand code architecture by displaying line numbers and definition snippets. diff --git a/packages/kilo-docs/pages/automate/tools/list-files.md b/packages/kilo-docs/pages/automate/tools/list-files.md index a381f3e9af3..b245b1bf5ef 100644 --- a/packages/kilo-docs/pages/automate/tools/list-files.md +++ b/packages/kilo-docs/pages/automate/tools/list-files.md @@ -1,3 +1,8 @@ +--- +title: List Files Tool | Kilo Code Docs +description: Learn how the list_files tool displays files and directories within a specified location to help Kilo Code understand your project structure. +--- + # list_files The `list_files` tool displays the files and directories within a specified location. It helps Kilo Code understand your project structure and navigate your codebase effectively. diff --git a/packages/kilo-docs/pages/automate/tools/new-task.md b/packages/kilo-docs/pages/automate/tools/new-task.md index 93469f7c3e1..a678fc6f112 100644 --- a/packages/kilo-docs/pages/automate/tools/new-task.md +++ b/packages/kilo-docs/pages/automate/tools/new-task.md @@ -1,3 +1,8 @@ +--- +title: New Task Tool | Kilo Code Docs +description: Learn how the new_task tool creates subtasks with specialized modes to break down complex projects into manageable pieces with parent-child relationships. +--- + # new_task The `new_task` tool creates subtasks with specialized modes while maintaining a parent-child relationship. It breaks down complex projects into manageable pieces, each operating in the mode best suited for specific work. diff --git a/packages/kilo-docs/pages/automate/tools/read-file.md b/packages/kilo-docs/pages/automate/tools/read-file.md index 9320df853e9..458350bb8b9 100644 --- a/packages/kilo-docs/pages/automate/tools/read-file.md +++ b/packages/kilo-docs/pages/automate/tools/read-file.md @@ -1,3 +1,8 @@ +--- +title: Read File Tool | Kilo Code Docs +description: Learn how the read_file tool examines file contents with line number support to help Kilo Code understand code, configuration, and documentation. +--- + # read_file The `read_file` tool examines the contents of files in a project. It allows Kilo Code to understand code, configuration files, and documentation to provide better assistance. diff --git a/packages/kilo-docs/pages/automate/tools/search-files.md b/packages/kilo-docs/pages/automate/tools/search-files.md index b98d5018c87..74eb2094b9b 100644 --- a/packages/kilo-docs/pages/automate/tools/search-files.md +++ b/packages/kilo-docs/pages/automate/tools/search-files.md @@ -1,3 +1,8 @@ +--- +title: Search Files Tool | Kilo Code Docs +description: Learn how the search_files tool performs regex searches across multiple files in your project to locate specific code patterns, text, and content with context. +--- + # search_files The `search_files` tool performs regex searches across multiple files in your project. It helps Kilo Code locate specific code patterns, text, or other content throughout your codebase with contextual results. diff --git a/packages/kilo-docs/pages/automate/tools/semantic-search.md b/packages/kilo-docs/pages/automate/tools/semantic-search.md index 75867ee2d85..bdc91e99b82 100644 --- a/packages/kilo-docs/pages/automate/tools/semantic-search.md +++ b/packages/kilo-docs/pages/automate/tools/semantic-search.md @@ -1,3 +1,8 @@ +--- +title: Semantic Search Tool | Kilo Code Docs +description: Learn how the semantic_search tool uses AI embeddings and vector search to find semantically relevant code across your indexed codebase. +--- + # semantic_search {% callout type="info" title="Setup Required" %} diff --git a/packages/kilo-docs/pages/automate/tools/switch-mode.md b/packages/kilo-docs/pages/automate/tools/switch-mode.md index 092c495d378..30442c93c0a 100644 --- a/packages/kilo-docs/pages/automate/tools/switch-mode.md +++ b/packages/kilo-docs/pages/automate/tools/switch-mode.md @@ -1,3 +1,8 @@ +--- +title: Switch Mode Tool | Kilo Code Docs +description: Learn how the switch_mode tool enables Kilo Code to transition between operational modes like Code, Architect, Ask, and Debug for specialized task handling. +--- + # switch_mode The `switch_mode` tool enables Kilo Code to change between different operational modes, each with specialized capabilities for specific types of tasks. This allows seamless transitions between modes like Code, Architect, Ask, or Debug when the current task requires different expertise. diff --git a/packages/kilo-docs/pages/automate/tools/update-todo-list.md b/packages/kilo-docs/pages/automate/tools/update-todo-list.md index a855c6f0cc0..5c617b2399f 100644 --- a/packages/kilo-docs/pages/automate/tools/update-todo-list.md +++ b/packages/kilo-docs/pages/automate/tools/update-todo-list.md @@ -1,3 +1,8 @@ +--- +title: Update Todo List Tool | Kilo Code Docs +description: Learn how the update_todo_list tool tracks step-by-step task progress, replacing the entire TODO list to reflect current completion state during complex tasks. +--- + # update_todo_list The `update_todo_list` tool replaces the entire TODO list with an updated checklist reflecting the current state. It provides step-by-step task tracking, allowing confirmation of completion before updating and dynamic addition of new todos discovered during complex tasks. diff --git a/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md b/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md index 3fb498f0182..f20a38f290d 100644 --- a/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md +++ b/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md @@ -1,3 +1,8 @@ +--- +title: Use MCP Tool | Kilo Code Docs +description: Learn how the use_mcp_tool tool enables Kilo Code to interact with external tools provided by Model Context Protocol servers for extended capabilities. +--- + # use_mcp_tool The `use_mcp_tool` tool enables interaction with external tools provided by connected Model Context Protocol (MCP) servers. It extends Kilo Code's capabilities with domain-specific functionality through a standardized protocol. diff --git a/packages/kilo-docs/pages/automate/tools/write-to-file.md b/packages/kilo-docs/pages/automate/tools/write-to-file.md index 92756491e5a..18aced1a54c 100644 --- a/packages/kilo-docs/pages/automate/tools/write-to-file.md +++ b/packages/kilo-docs/pages/automate/tools/write-to-file.md @@ -1,3 +1,8 @@ +--- +title: Write to File Tool | Kilo Code Docs +description: Learn how the write_to_file tool creates new files or replaces existing file content with an interactive diff-based approval process. +--- + # write_to_file The `write_to_file` tool creates new files or completely replaces existing file content with an interactive approval process. It provides a diff view for reviewing changes before they're applied. 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 04481e37130..3708f9c8095 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 @@ -76,6 +76,12 @@ Find the Kilo Code icon ({% kiloCodeIcon /%}) in VS Code's Primary Side Bar. Cli The extension automatically passes context from your editor, including your open tabs and active file. You can type `@` in the chat input to get file and terminal autocomplete suggestions — use `@filename` to attach a file or `@terminal` to include your active terminal output. You can also mention file paths naturally in your message (e.g., "update src/utils.ts to add a helper function"). The agent can also discover files on its own using its built-in tools. +**Exporting local transcripts:** + +Run `/export` in chat, or open a local session's **History** context menu and choose **Export session transcript**. The save dialog lets you choose the Markdown (`.md`) destination. + +Kilo builds the export from the complete local session history, not only the messages currently loaded in the chat view. + {% /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 5d84f04f8d2..a8b035662c7 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 @@ -309,6 +309,38 @@ If the model key in your config differs from what the provider expects, use the Here `my-local-llama` is the key you use in your config and model picker, while `meta-llama-3.1-8b-instruct` is the actual model identifier sent to the LM Studio API. +For Azure OpenAI, use the native `azure` provider and set `id` to your Azure deployment name when it differs from the model key. Do not configure Azure GPT-5 family deployments under `openai-compatible`, because that provider sends `max_tokens` and Azure GPT-5 expects `max_completion_tokens`. + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "azure/gpt-5.5", + "provider": { + "azure": { + "options": { + "apiKey": "{env:AZURE_API_KEY}", + "resourceName": "my-azure-resource", + }, + "models": { + "gpt-5.5": { + "id": "my-gpt-5-5-deployment", + "name": "GPT-5.5 on Azure", + "reasoning": true, + "tool_call": true, + "temperature": false, + "limit": { + "context": 400000, + "output": 128000, + }, + }, + }, + }, + }, +} +``` + +Here `azure/gpt-5.5` is the model you select in Kilo Code, while `my-gpt-5-5-deployment` is the Azure deployment name sent to Azure. If you prefer to configure the full Azure endpoint instead of a resource name, replace `resourceName` with `baseURL`, for example `"baseURL": "https://my-resource.openai.azure.com/openai"`. If both are configured, Kilo Code uses `baseURL` and ignores `resourceName` to avoid sending conflicting Azure SDK options. + ## Model Loading Priority When Kilo starts, it resolves the active model in this order: diff --git a/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md b/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md index 45b5072fb05..ca9c6b1bdaa 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md +++ b/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md @@ -17,7 +17,7 @@ The extension uses **Fill-in-the-Middle (FIM)** completion routed through the ** You can choose between two FIM models: - **Codestral** (`mistralai/codestral-2508`) by Mistral AI — the default, billed through your Kilo account. -- **Mercury Edit** (`inception/mercury-edit`) by Inception — temporarily available via **BYOK** (Bring Your Own Key) only; Kilo Gateway support is coming soon. +- **Mercury Edit 2** (`inception/mercury-edit-2`) by Inception — temporarily available via **BYOK** (Bring Your Own Key) only; Kilo Gateway support is coming soon. ## Triggering Options @@ -38,10 +38,10 @@ This keybinding requires `kilo-code.new.autocomplete.enableSmartInlineTaskKeybin Autocomplete requests are routed through the **Kilo Gateway**. You can pick the FIM model under **Settings → Models → Autocomplete model**: - **Codestral** (`mistralai/codestral-2508`) — the default. Billed through your Kilo account, or free when you add your own Mistral Codestral key via BYOK. See [Setting Up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup). -- **Mercury Edit** (`inception/mercury-edit`) — a fast diffusion-based FIM model by Inception. Temporarily requires an **Inception BYOK key** until Kilo Gateway support lands. Add one from the [BYOK page](https://app.kilo.ai/byok) in the Kilo platform. See [Bring Your Own Key (BYOK)](/docs/getting-started/byok) for setup details. +- **Mercury Edit 2** (`inception/mercury-edit-2`) — a fast diffusion-based FIM model by Inception. Temporarily requires an **Inception BYOK key** until Kilo Gateway support lands. Add one from the [BYOK page](https://app.kilo.ai/byok) in the Kilo platform. See [Bring Your Own Key (BYOK)](/docs/getting-started/byok) for setup details. {% callout type="note" %} -Mercury Edit is only available through BYOK for now — Kilo Gateway support is coming soon. If you select Mercury Edit without a valid Inception BYOK key configured, autocomplete requests will fail — switch back to Codestral or add an Inception key to continue. +Mercury Edit 2 is only available through BYOK for now — Kilo Gateway support is coming soon. If you select Mercury Edit 2 without a valid Inception BYOK key configured, autocomplete requests will fail — switch back to Codestral or add an Inception key to continue. {% /callout %} ## Status Bar diff --git a/packages/kilo-docs/pages/code-with-ai/features/message-feedback.md b/packages/kilo-docs/pages/code-with-ai/features/message-feedback.md new file mode 100644 index 00000000000..43200d4c9a0 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/features/message-feedback.md @@ -0,0 +1,74 @@ +--- +title: "Message Feedback" +description: "Rate assistant responses with thumbs up/down to help Kilo improve" +--- + +# Message Feedback + +You can give a thumbs up or thumbs down to any response Kilo gives you. It's a quick way to tell us when something worked well, or when it didn't. + +{% callout type="info" %} +Feedback is only available when telemetry is on. If you turn telemetry off, the buttons no longer display. See [Turning it off](#turning-it-off) below. +{% /callout %} + +## How to use it + +{% tabs %} +{% tab label="VSCode" %} + +When Kilo finishes a response, you'll see thumbs-up and thumbs-down buttons next to the **Copy response** button. Click one to send your rating. Click the same button again to take it back, or click the other one to change your mind. + +If you reload the window or switch to a different session, the feedback status will be reset. + +{% /tab %} + +{% tab label="CLI" %} + +In the terminal, two keybinds rate the most recent assistant message: + +| Action | Keybind | +|---|---| +| Helpful | `=` | +| Not helpful | `-` | + +`` defaults to `Ctrl+X`, so you press `Ctrl+X` and then `=` for thumbs-up, or `Ctrl+X` and then `-` for thumbs-down. + +{% /tab %} +{% /tabs %} +## Why it matters + +Your feedback is the most direct way to tell us what's working and what isn't. We use it to tune the prompts behind Kilo's behavior, pick better default models, and find and fix problems faster than we could on our own. + +## What we send + +When you rate a response, here's what gets sent: + +- That you rated a response thumbs up or thumbs down. +- The provider and model ID that produced the response (for example, `anthropic` and `claude-sonnet-4-5`). +- For responses that came through Kilo Gateway, the request ID so we can match the rating back to the session. + +No further information is included in the rating feedback. For more information on telemetry, see [PRIVACY.md](https://github.com/Kilo-Org/kilocode/blob/main/PRIVACY.md). + +## Turning it off + +Feedback is part of telemetry, so turning telemetry off will cause the feedback buttons to no longer appear. + +{% tabs %} +{% tab label="VSCode" %} + +Open Settings (`Cmd+,` on Mac, `Ctrl+,` on Windows/Linux), search for **`telemetry.telemetryLevel`**, and set it to **Off**. The feedback buttons will no longer appear. Set it back to **All** to bring them back. (For more on what this setting controls, see [VS Code's telemetry docs](https://code.visualstudio.com/docs/getstarted/telemetry).) + +{% /tab %} + +{% tab label="CLI" %} + +Set `KILO_TELEMETRY_LEVEL=off` in your environment before starting Kilo: + +```bash +KILO_TELEMETRY_LEVEL=off kilo +``` + +With telemetry off, pressing the feedback keybinds shows a message saying feedback is disabled, and nothing is sent. + +{% /tab %} +{% /tabs %} 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 0f82886d335..e66a937f10d 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 @@ -1,21 +1,23 @@ --- title: Voice Transcription -description: Kilo Code now includes experimental support for voice input in the chat interface. +description: Dictate prompts through your signed-in Kilo account. --- # Voice Transcription -{% callout type="warning" title="🧪 Experimental Feature" %} -Voice Transcription / speech-to-text (STT) is currently in experimental status. Expect potential issues and changes as the feature matures. +{% callout type="warning" title="Experimental feature" %} +Speech to Text is experimental. Expect issues and changes as it matures. {% /callout %} -Kilo Code now includes experimental support for voice input in the chat interface. This feature allows you to dictate your messages using speech-to-text (STT) technology powered by OpenAI's Whisper API. +Use voice input in prompt fields instead of typing. Transcription uses your Kilo account through Kilo Gateway. -## Prerequisites +--- -Voice transcription requires two components to be set up: +## Get ready -### 1. FFmpeg Installation +Voice input needs FFmpeg plus access to the Kilo provider. + +### Install FFmpeg FFmpeg is required for audio capture and processing. Install it for your platform: @@ -35,56 +37,72 @@ sudo apt install ffmpeg **Windows:** Download from [ffmpeg.org/download.html](https://ffmpeg.org/download.html) and add to your system PATH. -### 2. OpenAI API Key +### Sign in -Voice transcription uses OpenAI's Whisper API for speech recognition. You need an OpenAI API configuration in Kilo Code: +Enable and sign in to the Kilo provider to use voice input in prompt fields. Requests use your Kilo account through Kilo Gateway, so no separate OpenAI provider profile or API key is needed. -1. Configure an OpenAI provider profile in Kilo Code settings -2. Add your OpenAI API key to the profile -3. Either **OpenAI** or **OpenAI Native** provider types will work +--- -## Enabling Voice Transcription +## Enable input -Voice transcription is an experimental feature that must be enabled: +Voice input is experimental and must be enabled: 1. Open Kilo Code settings -2. Navigate to **Experimental Features** +2. Open **Experimental** settings 3. Enable the **Speech to Text** experiment -## Using Voice Input +Kilo stores this toggle in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`), not VS Code user settings: -Once configured and enabled, a microphone button will appear in the chat input area: +```json +{ + "experimental": { + "speech_to_text": true + } +} +``` + +--- + +## Record prompts + +Once enabled, a microphone button appears in prompt fields: 1. Click the microphone button to start recording 2. Speak your message clearly 3. Click again to stop recording -4. Your speech will be automatically transcribed into text +4. Your speech is transcribed into text The feature includes real-time audio level visualization and voice activity detection to automatically detect when you're speaking. -## Technical Details +--- -- **Audio Processing**: Uses FFmpeg for system audio capture -- **Voice Recognition**: OpenAI Whisper API for transcription +## Review details -## Troubleshooting +- **Audio processing**: Uses FFmpeg for system audio capture +- **Transcription**: Sends audio through Kilo Gateway with the selected transcription model + +--- + +## Fix issues **Microphone button not appearing:** - Ensure the Speech to Text experiment is enabled - Verify FFmpeg is installed and in your PATH -- Check that you have an OpenAI provider configured with a valid API key +- Enable and sign in to the Kilo provider **Transcription errors:** -- Verify your OpenAI API key is valid and has available credits +- Confirm the Kilo provider remains enabled and signed in - Check your internet connection - Try speaking more clearly or adjusting your microphone settings -## Limitations +--- -This feature is currently experimental and may have limitations: +## Know limits -- Requires active internet connection -- Uses OpenAI API credits based on audio duration +Speech to Text is experimental and may have limitations: + +- Requires an active internet connection +- Requires Kilo Gateway access through your Kilo account - Transcription accuracy depends on audio quality and speech clarity diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/code-review.md b/packages/kilo-docs/pages/code-with-ai/gastown/code-review.md new file mode 100644 index 00000000000..217cda3159c --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/code-review.md @@ -0,0 +1,135 @@ +--- +title: "Code Review" +description: "How the refinery agent reviews and merges code" +--- + +# {% $markdoc.frontmatter.title %} + +Every piece of code produced by Gas Town agents goes through automated review before merging. The **refinery** agent is dedicated to critiquing, verifying, and gatekeeping what lands in your codebase. + +## The Review Pipeline + +When a polecat finishes a bead and pushes its branch, the work enters the review pipeline: + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="A bead in review — the refinery is evaluating the polecat's work" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-bead-in-review.png" alt="Gas Town rig page showing a bead in review status" /%} +{% /browserFrame %} + +The refinery evaluates: +- **Correctness** — does the code do what the task asked? +- **Style** — does it follow project conventions? +- **Completeness** — are tests included? Are edge cases handled? +- **Safety** — any security issues, data leaks, or breaking changes? + +## Micro-Adversarial Loops + +The core insight behind Gas Town's review system is **adversarial iteration**. Rather than one agent producing a final answer, two agents with different objectives improve the output through tension: + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +This pattern is fundamentally different from having a single agent self-review: +- Self-review has a **confirmation bias** — the same "mind" that wrote the code evaluates it +- Adversarial review creates **genuine tension** — the refinery has different priorities than the polecat +- Each revision cycle **measurably improves** the output because feedback is specific and actionable + +### The Loop in Practice + +A typical bead goes through 1-2 revision cycles: + +| Cycle | What happens | +|---|---| +| **Write** | Polecat reads the task, writes code, runs tests, pushes | +| **Review 1** | Refinery finds 2 issues: missing test case, inconsistent naming | +| **Revise 1** | Polecat adds the test, fixes naming, pushes again | +| **Review 2** | Refinery approves — code meets quality bar | +| **Merge** | Code lands on target branch | + +After 3 failed revision cycles, the bead escalates rather than looping forever. + +## Merge Strategies + +Gas Town supports two merge strategies, configurable per-town or per-rig: + +### Direct Merge + +The refinery merges directly to the target branch (convoy feature branch or main) without creating a GitHub PR. This is faster but gives you less visibility into individual merges. + +**Best for:** trusted agent output, internal projects, rapid iteration. + +### Pull Request Mode + +The refinery creates a GitHub PR for each merge. The PR includes: +- The diff +- Review comments from the refinery +- Status checks from CI + +You can configure whether PRs auto-merge after refinery approval or require human approval. + +**Best for:** production codebases, team environments, audit trails. + +## Convoy-Level Review + +Convoys add an additional review layer beyond per-bead review: + +{% flowDiagram name="convoy-execution" height="200px" /%} + +{% browserFrame url="app.kilo.ai/gastown/town/merges" caption="The merge queue — review detail showing refinery feedback" %} +{% image src="/docs/img/gastown/gt-merge-queue-page-review-detail.png" alt="Gas Town merge queue with review detail" /%} +{% /browserFrame %} + +| Review Layer | What's checked | Who reviews | +|---|---|---| +| Per-bead | Individual contribution quality | Refinery agent | +| Landing | Combined feature coherence | Refinery agent | +| Human (optional) | Business logic, architecture | Your team | + +## Combining with Kilo Code Review + +Gas Town's refinery works independently, but combining it with [Kilo Code Review](/docs/code-with-ai/platforms/cloud-agent) creates an even stronger pipeline: + +1. **Agent writes** → agent-level refinery reviews (fast, automated) +2. **Code lands as PR** → Kilo Code Review provides human-readable review (deeper, contextual) +3. **Human approves** → code ships + +This gives you automated adversarial review for speed **plus** AI-assisted human review for judgment — the best of both approaches. + +## The Merge Queue + +The merge queue page shows all active and completed reviews in your town: + +{% browserFrame url="app.kilo.ai/gastown/town/merges" caption="The merge queue — all reviews at a glance" %} +{% image src="/docs/img/gastown/gt-merge-queue-page.png" alt="Gas Town merge queue page" /%} +{% /browserFrame %} + +## Review Configuration + +Customize the refinery's behavior in **Town Settings** → **Review**: + +| Setting | Options | Default | +|---|---|---| +| `review_mode` | `always` / `never` / `pr_only` | `always` | +| `merge_strategy` | `direct` / `pr` | `direct` | +| `auto_merge` | `true` / `false` | `true` | +| `review_gates` | Strictness level (1-5) | 3 | +| `max_review_cycles` | How many revision attempts | 3 | + +### Review Mode + +- **`always`** — every bead goes through refinery review (recommended) +- **`never`** — skip review, merge directly on polecat completion (fast but risky) +- **`pr_only`** — only review work that creates a PR + +### Review Gates + +Higher gate levels make the refinery stricter: +- **Level 1** — basic sanity (compiles, doesn't break tests) +- **Level 3** — standard (style, tests, correctness) — default +- **Level 5** — strict (architecture review, performance, security) + +## Handling Review Feedback + +When the refinery rejects a submission, it provides specific, actionable feedback. The polecat receives this feedback and revises accordingly. You can see the feedback exchange in the bead's event history. + +If a bead fails review 3 times, it transitions to `failed` and creates an **escalation** for human attention. This prevents infinite loops while ensuring difficult code doesn't slip through without proper quality. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/concepts.md b/packages/kilo-docs/pages/code-with-ai/gastown/concepts.md new file mode 100644 index 00000000000..406d6885b05 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/concepts.md @@ -0,0 +1,156 @@ +--- +title: "Concepts" +description: "Understand the building blocks of Gas Town — towns, beads, convoys, rigs, and agents" +--- + +# {% $markdoc.frontmatter.title %} + +Gas Town by Kilo is built around a small set of composable primitives. Understanding these concepts is key to getting the most out of agent orchestration. + +## Towns + +A **town** is a persistent workspace where agents operate on your code. It maintains: + +- **Configuration** — models, merge strategy, review settings, custom instructions +- **Agent state** — which agents exist, what they're working on, their capabilities +- **Work history** — every bead that's been created, worked on, reviewed, and closed +- **Rig connections** — which repositories are connected and how they're configured + +Think of a town as a team's workspace — it accumulates institutional knowledge over time. Agents in your town learn from the history of what's been built, reviewed, and merged. + +A town can be personal (owned by you) or organizational (shared across a team). + +## Beads + +A **bead** is the fundamental unit of work. Every task, review, and coordination action is represented as a bead with a lifecycle: + +{% flowDiagram name="bead-lifecycle" height="320px" /%} + +| Status | What's happening | +|---|---| +| `open` | Waiting to be picked up by an agent | +| `in_progress` | An agent is actively working on it | +| `in_review` | Work is complete, awaiting review by the refinery | +| `closed` | Successfully completed and merged | +| `failed` | Could not be completed (agent exhausted retries) | + +### Bead Types + +| Type | Purpose | +|---|---| +| `issue` | A coding task — bug fix, feature, refactor | +| `merge_request` | A review task for the refinery | +| `convoy` | A container for multi-step workflows | +| `escalation` | An issue the agents couldn't resolve — needs human input | +| `message` | Inter-agent communication | + +## Convoys + +A **convoy** is a multi-bead workflow where tasks can depend on each other. Instead of slinging isolated tasks, convoys let you express complex work as a directed graph. + +{% flowDiagram name="convoy-execution" height="200px" /%} + +When you create a convoy, you define: +- **Tasks** — what needs to be done (each becomes a bead) +- **Dependencies** — which tasks must complete before others can start +- **Feature branch** — a shared branch that convoy work lands on + +The reconciler ensures beads are only dispatched when their dependencies are met. This means agents naturally build on each other's work. + +### Staged Convoys + +Convoys can be **staged** — created but not started immediately. This lets you review the plan before agents begin executing. Un-stage when you're ready to go. + +## Rigs + +A **rig** connects a repository to your town. Each rig has: + +- Its own set of agents (polecats, refinery) +- Branch configuration (default branch, merge target) +- Override settings (model, review mode, merge strategy) + +A town can have **multiple rigs** — useful when your project spans several repositories. + +## Agents + +Agents are the workers in your town. Each has a specialized role: + +### Polecats (Coding Agents) + +Polecats do the actual software engineering: + +- Read and understand your codebase +- Write code changes in isolated git worktrees +- Run tests and commands to verify their work +- Push branches when done + +Multiple polecats can work in **parallel** on different beads. The default is 2 per rig, configurable up to 5+. + +Each polecat gets its own git worktree — they never conflict with each other or with your local development. + +### The Refinery (Review Agent) + +The refinery is the quality gate. When a polecat finishes a bead: + +1. The refinery reviews the diff +2. Checks for issues, style violations, missing tests +3. Either **approves and merges** or **sends feedback** +4. If feedback is sent, the polecat revises and resubmits + +This creates a **micro-adversarial loop** — one agent writes, another critiques, forcing iterative improvement before code lands. + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +### The Mayor (Coordination Agent) + +The mayor is your interface to the town: + +- Plans convoys from high-level descriptions +- Reports on status and progress +- Triages issues and escalations +- Manages agent configuration +- Answers questions about the codebase and work history + +The mayor runs persistently — always available for conversation. + +## The Reconciler + +The reconciler is the engine that drives the town forward. It runs on every alarm tick (every 5 seconds when work is active) and: + +1. **Drains events** — agent completions, status changes, failures +2. **Evaluates rules** — which beads need agents, which convoys are ready to advance +3. **Emits actions** — dispatch an agent, create a review, update convoy progress +4. **Enforces invariants** — no double-dispatch, no orphaned hooks, bounded retries + +You don't interact with the reconciler directly — it's the autonomous engine that keeps the town moving. + +## The Micro-Adversarial Loop + +The most powerful concept in Gas Town is the **micro-adversarial loop**. Rather than trusting a single agent's output, every piece of work goes through an adversarial cycle: + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +This pattern compounds when combined with **convoys**: + +1. Bead 1: Explore the codebase → reviewed → merged to convoy branch +2. Bead 2: Design the schema (builds on bead 1's context) → reviewed → merged +3. Bead 3: Implement the feature (builds on beads 1+2) → reviewed → merged +4. **Landing review**: The full convoy branch is reviewed as a cohesive unit before merging to main + +At every stage, work is critiqued and refined. Combined with Kilo's [Code Review](/docs/code-with-ai/gastown/code-review) product, this creates a pipeline where code is reviewed multiple times by different agents with different perspectives before it ever reaches your main branch. + +## How It All Fits Together + +{% browserFrame url="app.kilo.ai/gastown/town" caption="The complete Gas Town experience — Mayor chat, convoy progress, and agent coordination" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town overview showing the full architecture in action" /%} +{% /browserFrame %} + +| Component | Responsibility | +|---|---| +| **You** | Describe work, review PRs, set direction | +| **Mayor** | Plan, coordinate, communicate | +| **Reconciler** | Schedule, dispatch, enforce rules | +| **Polecats** | Write code, run tests, push branches | +| **Refinery** | Review, critique, merge | +| **Container** | Isolated environment with git, tools, runtime | +| **Rig** | Repository connection and configuration | diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/index.md b/packages/kilo-docs/pages/code-with-ai/gastown/index.md new file mode 100644 index 00000000000..55f665f5896 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/index.md @@ -0,0 +1,113 @@ +--- +title: "Gas Town by Kilo" +description: "Autonomous AI agent orchestration for your codebase" +--- + +# {% $markdoc.frontmatter.title %} + +Gastown by Kilo is an autonomous agent orchestration platform that manages teams of AI agents working on your codebase. Built on [Gastown](https://gastown.dev) — the open protocol for agent orchestration — Kilo's implementation coordinates coding agents, a code review agent, and a conversational coordinator to ship features, fix bugs, and maintain your projects with minimal human intervention. + +You describe the work. Agents figure out how to do it, write the code, review each other's output, and land clean PRs — while you stay in control of what ships. + +{% browserFrame url="app.kilo.ai/gastown/town" caption="A Gas Town with active agents, convoy progress, and Mayor chat" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town overview showing active work and Mayor chat" /%} +{% /browserFrame %} + +## What Makes Gastown Different + +Unlike single-agent coding tools that handle one task at a time, Gastown orchestrates **multiple specialized agents** working in parallel: + +| Agent | Role | What it does | +|---|---|---| +| **Polecats** | Coding | Write code, run tests, push branches. Multiple can work simultaneously on different tasks. | +| **Refinery** | Code Review | Reviews polecat output, runs quality checks, merges approved work. | +| **Mayor** | Coordination | Your conversational interface. Plans work, answers questions, triages issues, keeps things moving. | + +These agents operate within a **town** — a persistent workspace connected to your repository. The town maintains state across sessions: work history, agent configuration, and institutional knowledge about your codebase. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="A staged convoy ready to be kicked off" %} +{% image src="/docs/img/gastown/gt-rig-page-staged-convoy.png" alt="Gas Town rig page with a staged convoy" /%} +{% /browserFrame %} + +## How It Works + +### 1. Create a town + +Connect a GitHub repository to a new town. Gastown provisions a dedicated environment with agents ready to work. + +### 2. Sling work + +Describe what needs to be done — a bug to fix, a feature to build, a refactor to execute. You can sling a single task or a **convoy** (a multi-step plan where tasks depend on each other). + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Agents working on a convoy — beads flowing through the pipeline" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-in-progress.png" alt="Gas Town rig page with convoy in progress" /%} +{% /browserFrame %} + +### 3. Agents pick it up + +The reconciler assigns open work to available polecats. Each agent gets its own git worktree, writes code, runs commands, and pushes a branch when done. + +### 4. Code gets reviewed + +Completed work flows to the refinery for automated code review. Depending on your merge strategy, the refinery either merges directly or creates a PR for your approval. + +### 5. You stay in control + +Monitor progress from the town dashboard. Chat with the mayor. Review PRs. Adjust priorities. Intervene when agents need guidance. Everything the agents do is visible — branches, commits, review comments, and decision reasoning. + +{% browserFrame url="app.kilo.ai/gastown/town/beads" caption="The beads page — all work items with status, type, and history" %} +{% image src="/docs/img/gastown/gt-beads-page.png" alt="Gas Town beads page showing beads in various states" /%} +{% /browserFrame %} + +## Core Concepts + +| Concept | What it is | +|---|---| +| **Town** | A persistent workspace connecting your repo to a team of agents. Maintains configuration, history, and state. | +| **Bead** | A unit of work — an issue to fix, a task to complete, a review to perform. Beads have a lifecycle: open, in progress, in review, closed. | +| **Convoy** | A multi-bead workflow. Beads within a convoy can depend on each other, forming a DAG that agents execute in order. | +| **Rig** | A repository connection within a town. Each rig has its own branch configuration, agents, and settings. A town can have multiple rigs. | +| **Mayor** | The coordination agent. Your primary interface for interacting with the town through natural language. | +| **Polecat** | A coding agent. Works on individual beads — reads code, makes changes, runs tests, pushes branches. | +| **Refinery** | The review agent. Checks polecat output for quality, performs merges, and gates what lands in your codebase. | + +For a deeper dive, see [Concepts](/docs/code-with-ai/gastown/concepts). + +## The Mayor — Your Interface to the Town + +The mayor is how you interact with Gastown conversationally. Think of it as a technical lead that knows your codebase, tracks what all agents are doing, and can take action on your behalf. + +You can ask the mayor to: + +- Plan and create convoys from high-level descriptions +- Check on the status of in-progress work +- Investigate failures and stuck agents +- Update town settings and configuration +- Triage incoming issues or bug reports + +The mayor runs persistently in your town — it's always available, even when no coding agents are active. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Staged convoy detail — review the plan before agents start" %} +{% image src="/docs/img/gastown/gt-rig-page-staged-convoy-detail.png" alt="Gas Town staged convoy detail view" /%} +{% /browserFrame %} + +## What You Can Build With Gastown + +- **Feature development** — Describe a feature, get back a PR with tests +- **Bug fixes** — Point agents at an issue, they investigate and fix it +- **Refactoring** — Large-scale codebase changes executed methodically across convoys +- **Maintenance** — Dependency updates, lint fixes, documentation generation +- **Code review** — Automated first-pass review on all agent and human PRs + +## Getting Started + +- [Quick Start](/docs/code-with-ai/gastown/quick-start) — Create your first town and see agents work in under 5 minutes +- [Concepts](/docs/code-with-ai/gastown/concepts) — Detailed explanation of towns, beads, convoys, and the agent lifecycle +- [The Mayor](/docs/code-with-ai/gastown/mayor) — How to interact with the coordination agent effectively + +## Guides + +- [Sling Work](/docs/code-with-ai/gastown/sling-work) — Creating tasks, convoys, and staged workflows +- [Code Review](/docs/code-with-ai/gastown/code-review) — Merge strategies, review gates, and the refinery pipeline +- [Settings](/docs/code-with-ai/gastown/settings) — Model configuration, agent limits, custom instructions, and environment variables +- [Troubleshooting](/docs/code-with-ai/gastown/troubleshooting) — Common issues and how to resolve them diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/mayor.md b/packages/kilo-docs/pages/code-with-ai/gastown/mayor.md new file mode 100644 index 00000000000..9330e6d96ca --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/mayor.md @@ -0,0 +1,143 @@ +--- +title: "The Mayor" +description: "How to interact with your town's coordination agent" +--- + +# {% $markdoc.frontmatter.title %} + +The Mayor is your primary interface to a Gas Town. It's a persistent conversational agent that coordinates work, answers questions, and takes action on your behalf. + +## What the Mayor Does + +The Mayor operates as a technical lead that: + +- **Plans work** — converts high-level descriptions into convoys and beads +- **Reports status** — knows what every agent is doing, what's stuck, what's shipped +- **Triages issues** — investigates failures, stuck agents, and escalations +- **Configures the town** — updates settings, manages rigs, adjusts agent behavior +- **Answers questions** — about the codebase, work history, and town state + +Unlike polecats (which spin up to work on specific beads), the Mayor runs **persistently**. It's always available, even when no coding agents are active. + +{% browserFrame url="app.kilo.ai/gastown/town" caption="The Mayor chat — always available for coordination and questions" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town Mayor chat interface" /%} +{% /browserFrame %} + +## Talking to the Mayor + +Open the Mayor panel from your town dashboard. The conversation is persistent — the Mayor remembers context from previous messages within the same session. + +### Planning Work + +Ask the Mayor to create work for the town: + +> *"Create a convoy to add authentication to the API. We need JWT token generation, middleware for protected routes, and integration tests."* + +The Mayor will: +1. Break this into individual beads with dependencies +2. Propose a convoy plan for your review +3. Create the convoy (staged by default, so you can review before agents start) + +### Checking Status + +> *"What's everyone working on?"* +> *"Is anything stuck?"* +> *"How did the last convoy go?"* + +The Mayor has full visibility into agent state, bead progress, and recent history. + +### Investigating Problems + +> *"The auth refactor bead has been in progress for 20 minutes — what's happening?"* +> *"Why did the refinery reject the last review?"* + +The Mayor can inspect agent status messages, review feedback, and container logs to diagnose issues. + +### Updating Configuration + +> *"Switch the model to Auto Frontier for this town"* +> *"Set max polecats to 4"* +> *"Add a custom instruction: always use TypeScript strict mode"* + +The Mayor can modify town settings on your behalf through natural language. + +## Mayor Tools + +The Mayor has access to 21 specialized tools for town management: + +### Work Creation + +| Tool | What it does | +|---|---| +| `gt_sling` | Delegate a single task to a polecat agent in a specific rig | +| `gt_sling_batch` | Create a multi-bead convoy with dependency ordering, merge mode, and staging options | + +### Convoy Management + +| Tool | What it does | +|---|---| +| `gt_convoy_status` | Show detailed status of a convoy — each bead's progress and assignee | +| `gt_convoy_start` | Start a staged convoy — begin agent dispatch | +| `gt_convoy_close` | Force-close a convoy and optionally its tracked beads | +| `gt_convoy_update` | Edit convoy metadata (merge mode, feature branch) | +| `gt_convoy_add_bead` | Add an existing bead to a convoy's tracking | +| `gt_convoy_remove_bead` | Remove a bead from a convoy | +| `gt_list_convoys` | List active convoys with progress counts | + +### Bead Management + +| Tool | What it does | +|---|---| +| `gt_bead_update` | Edit a bead's status, title, body, priority, labels, or dependencies | +| `gt_bead_reassign` | Reassign a bead to a different agent | +| `gt_bead_delete` | Delete one or more beads (supports bulk — up to 5000) | +| `gt_list_beads` | List beads in a rig, filterable by status and type | + +### Agent Management + +| Tool | What it does | +|---|---| +| `gt_agent_reset` | Force-reset an agent to idle, unhooking it from any bead | +| `gt_nudge` | Send a real-time nudge to a polecat (immediate, wait-idle, or queued) | +| `gt_list_agents` | List all agents in a rig with their roles and status | +| `gt_mail_send` | Send a persistent mail message to any agent in any rig | + +### Town & UI + +| Tool | What it does | +|---|---| +| `gt_list_rigs` | List all rigs (repositories) in the town | +| `gt_ui_action` | Trigger UI actions — open drawers, navigate pages, highlight items | +| `gt_escalation_acknowledge` | Acknowledge an escalation as reviewed | +| `gt_report_bug` | File a bug report on the Gastown GitHub repo (checks for duplicates first) | + +These tools are used automatically when you make requests — you don't need to invoke them directly. + +## Tips for Effective Communication + +### Be specific about scope + +Instead of: *"Fix the bugs"* + +Try: *"Fix the TypeScript type errors in src/auth/. There are 3 reported in the CI output."* + +### Use convoys for complex work + +Instead of: *"Add a user dashboard with charts, settings, and notifications"* + +Try: *"Create a staged convoy for the user dashboard. Break it into: 1) dashboard layout and navigation, 2) chart components with mock data, 3) settings page, 4) notification system. Each should build on the previous."* + +### Let the Mayor triage + +When something goes wrong, ask the Mayor before investigating yourself: + +> *"Bead abc123 has been stuck for 30 minutes — can you investigate?"* + +The Mayor can often diagnose and resolve issues (reset agents, close stuck convoys, re-dispatch work) without you needing to dig into the admin panel. + +## Mayor Limitations + +- The Mayor coordinates but doesn't write code itself — that's what polecats are for +- It can only see what's happening inside your town, not external systems +- Complex multi-repo orchestration may need manual coordination between towns +- The Mayor's context window is bounded — very long conversations may lose early context diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/quick-start.md b/packages/kilo-docs/pages/code-with-ai/gastown/quick-start.md new file mode 100644 index 00000000000..fb940a31317 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/quick-start.md @@ -0,0 +1,100 @@ +--- +title: "Quick Start" +description: "Get your first Gas Town running in minutes" +--- + +# {% $markdoc.frontmatter.title %} + +This guide walks you through creating your first town, connecting a repository, and watching agents work on real code. + +## Prerequisites + +- A [Kilo account](https://app.kilo.ai) (free tier works) +- A GitHub repository you want agents to work on +- A GitHub Personal Access Token (recommended) + +## 1. Create a Town + +When you first visit Gas Town with no existing towns, you'll be taken directly into the new town onboarding flow. Give your town a name — this is just for your reference. + +{% browserFrame url="app.kilo.ai/gastown" caption="The new town onboarding flow" %} +{% image src="/docs/img/gastown/gt-new-town-onboarding.png" alt="Gas Town new town onboarding flow" /%} +{% /browserFrame %} + +## 2. Connect a Repository + +Add a **rig** to your town. A rig is a connection to a specific repository. + +1. Click **Add Rig** +2. Select your GitHub repository (or paste the URL) +3. Choose the default branch (usually `main`) +4. Click **Connect** + +Gastown uses the [Kilo GitHub App](https://github.com/apps/kilo-code) to access your repository. You'll be prompted to install it if you haven't already. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/new" caption="Adding a new rig — connect your repository" %} +{% image src="/docs/img/gastown/gt-new-rig.png" alt="Gas Town new rig creation flow" /%} +{% /browserFrame %} + +## 3. Add a GitHub Personal Access Token + +{% callout type="tip" title="Recommended" %} +Adding a GitHub PAT means all commits, branches, and PRs created by your town's agents will appear as **you** — not a bot account. It also enables agents to use `gh` CLI commands (creating issues, commenting on PRs, etc.) on your behalf. +{% /callout %} + +1. Go to **Town Settings** → **Git & Authentication** +2. Generate a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) scoped to the repository your town is connected to +3. Required permissions: **Contents** (read/write), **Pull requests** (read/write), **Metadata** (read) +4. Optional: add **Actions** (read/write) if your repo uses GitHub Actions workflows +5. Paste the token and save + +{% callout type="info" %} +Use a fine-grained token scoped to only the repository your town works on. Since agents act autonomously on your behalf, limiting the token's scope reduces risk. +{% /callout %} + +Without a PAT, agents use the GitHub App installation token — functional but shows up as a bot in your git history. + +## 4. Sling Your First Task + +Now let's give the agents something to do. The easiest way is to ask the Mayor: + +> *"Add a CONTRIBUTING.md file with basic setup instructions"* + +Or use the **Sling Work** action in the town header to describe the task directly. + +## 5. Watch Agents Work + +The reconciler assigns your task to an available polecat agent. Head to the **rig page** to watch it in action: + +1. A **bead** appears in the kanban board's open column +2. It moves to `in_progress` as a polecat picks it up +3. The agent reads your code, makes changes, and pushes a branch +4. The bead moves to `in_review` as the refinery checks the work +5. The refinery merges (or creates a PR depending on your settings) +6. The bead lands in the `closed` column + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="The rig page — convoy tracker and kanban board showing beads in various states" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-in-progress.png" alt="Gas Town rig page with an active convoy and beads in progress" /%} +{% /browserFrame %} + +The whole cycle typically takes 2-10 minutes depending on complexity and the model you're using. + +## 6. Talk to the Mayor + +Click the **Mayor** chat to interact with your town's coordinator. Try: + +- *"What's the status of the town?"* +- *"Create a convoy to add unit tests for the auth module"* +- *"What settings should I change for faster reviews?"* + +The Mayor is always running — it's your primary interface for managing the town conversationally. + +{% browserFrame url="app.kilo.ai/gastown/town" caption="The Mayor — your conversational interface to the town" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town overview with Mayor chat" /%} +{% /browserFrame %} + +## What's Next? + +- [Concepts](/docs/code-with-ai/gastown/concepts) — Understand the building blocks in depth +- [Sling Work](/docs/code-with-ai/gastown/sling-work) — Learn about convoys and multi-step workflows +- [Settings](/docs/code-with-ai/gastown/settings) — Configure models, merge strategies, and agent behavior diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md new file mode 100644 index 00000000000..d95fa6bf147 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md @@ -0,0 +1,162 @@ +--- +title: "Settings" +description: "Configure models, merge strategies, and agent behavior" +--- + +# {% $markdoc.frontmatter.title %} + +Gas Town settings control how your agents behave — which models they use, how they review code, and how they interact with your repositories. + +Access settings from your town dashboard → **Settings**. + +## Models + +### Default Model + +The primary model used by all agents (polecats, refinery, mayor). This affects quality, speed, and cost. + +Popular choices: +- **Kilo Auto Frontier** — highest quality models, best results (recommended) +- **Kilo Auto Balanced** — good balance of quality and cost (minimum for Gas Town) + +### Role-Specific Models + +Override the default model for specific agent roles. By default, all roles use the town-level model. You can override per-role if you want to optimize cost vs quality for different tasks (e.g., a faster model for the mayor, a stronger model for the refinery). + +### Small Model + +Used for lightweight tasks (classification, routing, summarization). Usually a smaller, cheaper model. + +## Git & Authentication + +### GitHub Personal Access Token + +{% callout type="tip" title="Strongly Recommended" %} +Adding a GitHub PAT ensures that all commits, branches, and PRs created by your agents appear as **you** in git history. Without it, activity shows up under the Kilo GitHub App bot account. +{% /callout %} + +**To add a PAT:** +1. Go to **Settings** → **Git & Authentication** +2. Generate a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) scoped to the connected repository +3. Required permissions: **Contents** (read/write), **Pull requests** (read/write), **Metadata** (read) +4. Optional: add **Actions** (read/write) if your repo uses GitHub Actions workflows +5. Paste the token and save + +{% callout type="info" %} +Use a fine-grained token limited to only the repository your town is connected to. Agents act autonomously on your behalf, so limiting scope is a best practice. +{% /callout %} + +**What the PAT enables:** +- Commits and PRs appear as you (your avatar, your username) +- Agents can use `gh` CLI commands on your behalf +- Access to the specific repository you scoped the token to +- Ability to trigger CI workflows (if Actions permission is granted) + +**Without a PAT:** +- The GitHub App installation token is used (functional but less personal) +- PRs show as created by the Kilo bot +- Some `gh` CLI operations may not work + +### GitHub App Installation + +The [Kilo GitHub App](https://github.com/apps/kilo-code) provides base-level repository access. It's installed per-organization or per-repository and gives agents read/write access to code, PRs, and issues. + +The GitHub App is **required** — it's how Gastown gets installation tokens for cloning and pushing. The PAT is **optional but recommended** — it provides user-level attribution. + +## Merge Strategy + +Controls how reviewed code lands in your repository: + +| Strategy | Behavior | Best for | +|---|---|---| +| `direct` | Refinery merges directly (no PR) | Speed, trusted environments | +| `pr` | Refinery creates a PR | Audit trail, team visibility, CI integration | + +For `pr` mode, you can also configure: +- **Auto-merge** — PRs merge automatically after refinery approval +- **Require human approval** — PRs wait for a human reviewer + +## Refinery Configuration + +### Review Mode + +| Mode | Behavior | +|---|---| +| `always` | Every completed bead goes through review (default, recommended) | +| `never` | Skip review entirely — merge on polecat completion | +| `pr_only` | Only review work that generates a PR | + +### Review Gates + +Strictness level from 1 (lenient) to 5 (strict). Higher levels mean the refinery will reject more frequently but produce higher quality output. + +### Max Review Cycles + +How many write → review → revise cycles before a bead fails and escalates. Default: 3. + +## Agent Limits + +### Max Polecats Per Rig + +How many coding agents can work simultaneously on a single rig. Default: 2. + +Higher values mean more parallel work but also more resource consumption. Consider: +- **1-2** — conservative, good for small repos or limited budgets +- **3-4** — moderate parallelism, good for medium projects +- **5+** — aggressive, best for large repos with lots of independent work + +### Alarm Intervals + +Controls how frequently the reconciler checks for work: +- **Active interval** — when agents are working (default: 5s) +- **Idle interval** — when no work is pending (default: 60s) + +Lower active intervals mean faster response to events but more compute usage. + +## Environment Variables + +Add environment variables that are available to all agents in the container. Useful for: +- API keys for external services agents might test against +- Database connection strings for test environments +- Feature flags or configuration values + +{% callout type="warning" %} +Environment variables are visible to all agents in the town. Do not store production secrets here — use test/development credentials only. +{% /callout %} + +## Custom Instructions + +Free-form text injected into every agent's system prompt. Use this to communicate: + +- Project conventions: *"Always use TypeScript strict mode"* +- Architecture decisions: *"The auth module uses JWT with RS256. Never use HS256."* +- Style preferences: *"Use functional components with hooks, never class components"* +- Constraints: *"Do not modify files in the `vendor/` directory"* +- Testing requirements: *"Every new function must have a corresponding unit test"* + +Custom instructions are powerful — they let you encode institutional knowledge that agents follow consistently. + +## Convoy Settings + +### Staged Convoys Default + +When `true`, new convoys are created as staged (paused until you un-stage). Default: `true`. + +### Convoy Merge Mode + +| Mode | Behavior | +|---|---| +| `review-then-land` | Each bead merges to a feature branch, then a landing review merges to main (default) | +| `review-and-merge` | Each bead goes through review and merges directly to main (no feature branch) | + +`review-then-land` provides the strongest quality guarantees but takes longer. Use `review-and-merge` for simpler tasks or when you want immediate feedback. + +## Per-Rig Overrides + +Any town-level setting can be overridden at the rig level. This lets you: +- Use a more powerful model for a complex repository +- Set stricter review gates for a production codebase +- Allow more polecats on a repo with many independent modules +- Disable review for a documentation-only repo + +Access per-rig settings from the rig detail page → **Settings**. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/sling-work.md b/packages/kilo-docs/pages/code-with-ai/gastown/sling-work.md new file mode 100644 index 00000000000..53c7086877f --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/sling-work.md @@ -0,0 +1,158 @@ +--- +title: "Sling Work" +description: "Creating tasks and convoys for agents to work on" +--- + +# {% $markdoc.frontmatter.title %} + +"Slinging work" is how you give agents something to do. You can sling a single task (a bead) or a structured multi-step plan (a convoy). + +## Single Tasks + +The simplest way to use Gas Town — describe what needs to be done, and an agent picks it up. + +Ask the Mayor: + +> *"Fix the 404 error on the /settings page — the route is missing from the router config"* + +Or use the **Sling Work** action in the town header. Either way, the reconciler assigns the bead to an available polecat. The agent reads the relevant code, makes the fix, runs any tests, and pushes a branch. + +### Writing Good Task Descriptions + +The quality of agent output directly correlates with the clarity of your description: + +| Approach | Example | +|---|---| +| **Vague** (avoid) | "Fix the auth" | +| **Specific** (better) | "Fix the JWT token expiration — tokens should last 24h, not 1h. The constant is in `src/auth/config.ts`" | +| **Contextual** (best) | "Fix #142: users are getting logged out after 1 hour. The issue is the JWT expiration in `src/auth/config.ts` is set to 3600 (1h) but should be 86400 (24h). Add a test to verify." | + +Include: +- **What** needs to change +- **Where** in the codebase (file paths if you know them) +- **Why** it's needed (link to issues, error messages) +- **How to verify** (tests to run, behavior to check) + +## Convoys + +Convoys are where Gas Town really shines. Instead of one agent doing everything in one pass, convoys break complex work into stages where each builds on the last — with adversarial review at every step. + +### Why Convoys? + +Single-pass agent output has a quality ceiling. The longer an agent works on one task, the more likely it is to accumulate compounding errors. Convoys solve this by: + +1. **Decomposing** complex work into focused, reviewable chunks +2. **Sequencing** so later steps build on reviewed, merged code +3. **Reviewing** each chunk independently before it becomes the foundation for the next step +4. **Containing failures** — if step 3 fails, steps 1 and 2 are already safely merged + +{% flowDiagram name="convoy-execution" height="200px" /%} + +### Creating a Convoy + +**Via the Mayor:** +> *"Create a convoy to migrate the database from PostgreSQL to MySQL. Steps: 1) audit current schema and queries, 2) design the new schema with migration plan, 3) implement the migration scripts, 4) update the application layer, 5) add integration tests"* + +The Mayor converts this into a convoy with proper dependencies. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="A staged convoy — review the task breakdown before agents begin" %} +{% image src="/docs/img/gastown/gt-rig-page-staged-convoy-detail.png" alt="Gas Town staged convoy detail showing task dependencies" /%} +{% /browserFrame %} + +### Convoy Execution + +Once started, the reconciler manages the convoy: + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Convoy in progress — review bead detail showing the refinery at work" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-review-bead-detail.png" alt="Gas Town convoy review bead detail" /%} +{% /browserFrame %} + +Key behaviors: +- Each polecat starts from the **convoy feature branch**, which accumulates all previously merged work +- Beads only dispatch when their **dependencies are satisfied** (upstream beads closed) +- The refinery reviews each sub-PR against the convoy branch +- Once all beads close, a **landing review** checks the full combined diff before merging to main + +### The Adversarial Advantage + +The convoy pattern creates **layered adversarial review**: + +1. **Per-bead review** — refinery critiques each individual contribution +2. **Context accumulation** — each agent builds on verified, reviewed code +3. **Landing review** — the complete feature is reviewed holistically +4. **Combined with Kilo Code Review** — if configured, human reviewers see the final PR too + +This means code goes through **3-4 review passes** before landing in your main branch. Bugs get caught at the smallest possible scope where they're cheapest to fix. + +### Staged Convoys + +By default, convoys are created **staged** — the plan exists but agents don't start until you un-stage it. This lets you: + +- Review the task breakdown before execution +- Adjust descriptions, add context, reorder +- Ensure the plan makes sense before burning compute + +Un-stage via the convoy detail page or ask the Mayor: *"Start the database migration convoy"* + +## Assigning Priority + +Beads have priority levels: `low`, `medium` (default), `high`, `critical`. + +Higher priority beads are dispatched first when multiple beads are waiting for agents. Set priority: +- In the Sling Work dialog +- Via the Mayor: *"Make the auth fix high priority"* +- By editing the bead after creation + +## Watching Progress + +### Rig Page — Convoy Tracker + +The best place to observe your town in action is the **rig page**. At the top, active convoys show their progress as a visual tracker — each bead in the convoy displayed with its current status and dependency relationships. You can see exactly where in the DAG execution has reached and which beads are blocking downstream work. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Convoy tracker — see exactly where execution has reached" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-in-progress.png" alt="Gas Town rig page convoy tracker with beads in various states" /%} +{% /browserFrame %} + +### Rig Page — Kanban Board + +Below the convoy tracker, a kanban board shows beads organized by status — open, in progress, in review, and closed — updating in real-time as agents move work through the pipeline. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Kanban board — beads flow through columns as agents work" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-bead-in-review.png" alt="Gas Town rig page kanban board with a bead in review" /%} +{% /browserFrame %} + +You can see at a glance: +- What's queued up (open column) +- What agents are actively working on (in progress) +- What's awaiting review (in review) +- What's shipped (closed) + +Beads move through columns autonomously as the reconciler dispatches agents and work progresses. + +### Beads Page + +For a more detailed, filterable view across all rigs, the beads page shows every bead in your town. Filter by: +- Status (open, in progress, in review, closed, failed) +- Type (issue, merge_request, convoy) +- Rig (if you have multiple repos) + +{% browserFrame url="app.kilo.ai/gastown/town/beads" caption="Beads page — filterable list of all work items" %} +{% image src="/docs/img/gastown/gt-beads-page.png" alt="Gas Town beads page" /%} +{% /browserFrame %} + +Click any bead to see its full detail — description, event history, agent activity, and review feedback: + +{% browserFrame url="app.kilo.ai/gastown/town/beads/detail" caption="Bead detail — full history and status" %} +{% image src="/docs/img/gastown/gt-beads-page-detail.png" alt="Gas Town bead detail view" /%} +{% /browserFrame %} + +### Town Overview + +The town overview shows a high-level summary: +- Active agents and what they're working on +- Recent completions +- Pending work queue + +### Real-Time Events + +The event timeline shows every state transition as it happens — bead dispatched, review submitted, merge completed. Useful for understanding the flow when you want to see exactly what's happening under the hood. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md new file mode 100644 index 00000000000..62067e340f1 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md @@ -0,0 +1,136 @@ +--- +title: "Troubleshooting" +description: "Common issues and how to resolve them" +--- + +# {% $markdoc.frontmatter.title %} + +Gas Town is a complex system with multiple agents, containers, and external integrations. When things go wrong, this guide helps you diagnose and fix common issues. + +## Agents Not Picking Up Work + +**Symptom:** Beads stay in `open` status. No agents transition to `working`. + +**Common causes:** + +| Cause | Fix | +|---|---| +| All polecats at max dispatch attempts | Ask the Mayor: *"Reset agent dispatch attempts"* | +| Reconciler paused (draining) | Wait for drain to complete | +| No available polecats | Check `max_polecats_per_rig` — you may need to increase it | + +**Quick fix:** Ask the Mayor: *"Why aren't beads getting picked up?"* — it can diagnose and often resolve the issue. + +## Container Not Starting + +**Symptom:** The container status shows errors or stays in "starting" indefinitely. + +**Common causes:** +- Git clone failure (bad credentials, repo not accessible) +- GitHub App not installed on the repository +- Environment variables causing startup crash + +**Fix:** +1. Check that the [Kilo GitHub App](https://github.com/apps/kilo-code) is installed on your repository +2. Verify your GitHub PAT is valid (if configured) +3. Remove any environment variables that might cause issues during container init +4. Try a container restart from town settings + +## Git Authentication Failures + +**Symptom:** Agents can't clone, push, or fetch. Errors mention "authentication failed" or "permission denied". + +**Common causes:** + +| Cause | Fix | +|---|---| +| GitHub App uninstalled | Reinstall at [github.com/apps/kilo-code](https://github.com/apps/kilo-code) | +| PAT expired or revoked | Generate a new token and update in Settings → Git & Authentication | +| Repository visibility changed | Ensure the GitHub App has access to the repo | +| Org SSO not authorized | Authorize the token for your organization's SSO | + +**Diagnosis:** The Mayor may report *"git credential refresh failed: no_installation_found"* — this definitively means the GitHub App needs to be reinstalled. + +## Review Loop / Stuck in Review + +**Symptom:** A bead cycles between `in_review` and `in_progress` repeatedly, or MR beads keep failing. + +**Common causes:** +- The refinery finds issues the polecat can't fix (e.g., fundamental architecture problems) +- Missing PR URL on merge request beads +- Convoy feature branch deleted from remote + +**Fix:** +1. Check the bead's event history for review feedback +2. If the feedback is a dead end, ask the Mayor to close the bead: *"Close bead [id] — the approach isn't working"* +3. For stuck convoys, the Mayor can force-close: *"Force close convoy [name]"* + +{% callout type="info" %} +Beads automatically escalate after 3 failed review cycles. If a bead is genuinely stuck in a loop, it will eventually fail and notify you rather than running forever. +{% /callout %} + +## The Mayor is Unresponsive + +**Symptom:** Messages to the Mayor don't get responses, or the Mayor says it's "unauthenticated". + +**Common causes:** +- Container sleeping (wakes up after ~30 seconds) +- KILOCODE_TOKEN expired +- Gateway authentication failure + +**Fix:** +1. Wait 30 seconds — the container may be waking from sleep +2. If persistent: go to Settings → refresh the container token +3. If still failing: go to settings, force container shutdown + +## Convoy Stuck / Never Completes + +**Symptom:** A convoy shows open but no beads are being dispatched, or it never reaches "landed". + +**Common causes:** +- Upstream dependency bead failed (blocks downstream) +- Convoy feature branch doesn't exist on remote +- Landing MR repeatedly failing + +**Fix:** +1. Check convoy progress: which beads are closed? which are open/failed? +2. For failed dependencies: fix or close the blocking bead, then downstream beads will dispatch +3. Ask the Mayor: *"What's blocking convoy [name]?"* +4. If truly stuck: force-close the convoy and re-create it + +## Agent Permanently Stuck + +**Symptom:** An agent shows as `working` but hasn't produced output for 20+ minutes. + +**Common causes:** +- Container process crashed but heartbeat continues +- Agent waiting on an external resource (network, API) +- Infinite loop in agent execution + +**Fix:** +1. Ask the Mayor: *"Reset agent [name]"* +2. This clears the hook, resets the agent to idle, and returns the bead to `open` +3. The reconciler will re-dispatch the bead to a fresh agent + +## High Failure Rate + +**Symptom:** Many beads ending in `failed` status. + +**Common causes:** +- Task descriptions are too vague (agents can't figure out what to do) +- The codebase has issues that prevent agents from working (broken build, missing dependencies) +- Model quality is too low for the complexity of the work + +**Fix:** +1. Review failed bead descriptions — make them more specific +2. Ensure the repo builds cleanly (agents struggle with pre-existing broken builds) +3. Consider upgrading the model (Auto Balanced → Auto Frontier for complex work) +4. Add custom instructions to guide agents: test commands, build steps, conventions + +## Getting Help + +If you can't resolve an issue: + +1. **Ask the Mayor** — it can diagnose most problems +2. **Check the event timeline** — see exactly what happened and when +3. **Contact support** — reach out at [kilo.ai/discord](https://kilo.ai/discord) with your town ID diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md new file mode 100644 index 00000000000..edafc39180b --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md @@ -0,0 +1,334 @@ +--- +title: "Wasteland Administration" +description: "Running a Wasteland: posting wanted items, reviewing submissions, stamping, merging, and managing members" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Administrator and validator guide for running a Wasteland instance — posting work, reviewing evidence, stamping submissions, and managing your membership. + +## Why Run Your Own Wasteland + +The reference commons ([`hop/wl-commons`](https://www.dolthub.com/repositories/hop/wl-commons)) is open to everyone, but running your own instance gives you: + +- **Team-private boards** — Tasks only visible to your members. No external rigs browsing or claiming your internal work. +- **Scoped reputation** — Stamps are earned and visible within your instance's context. Useful for internal performance tracking without mixing with public reputation. +- **Controlled validator set** — You decide who can review and stamp work. On the public commons, any registered rig can participate. On a private instance, only members you invite can validate. +- **Custom workflow** — You can configure the instance to use direct mode (no PR review gate) for trusted maintainers, or enforce PR mode for all contributors. + +Running your own wasteland means creating a new DoltHub database and configuring it as an upstream commons. Your team's rigs fork from and sync with this private commons. + +## Spinning Up a Wasteland in Gas Town + +### From the dashboard + +1. Navigate to your town's **Settings** → **Wasteland** tab +2. Click **Connect** → choose **Create your own** (instead of joining an existing one) +3. Enter your wasteland name and upstream (e.g., `my-org/wl-internal`) +4. Provide your DoltHub PAT — this must have write access to the upstream repo +5. Check **"I own this upstream"** — this sets `is_upstream_admin = true` on your credential, enabling admin mode +6. Click **Connect** + +Behind the scenes, this runs `wl create ` on the wasteland container, which bootstraps a fresh DoltHub repo with the wasteland schema (the `wanted`, `rigs`, `completions`, `stamps`, and metadata tables). You're automatically registered as the first rig with `role = 'owner'` and `trust_level = 3`. + + + +### Via CLI + +If you prefer the standalone `wl` CLI: + +```bash +wl create my-org/wl-internal --name "My Team Wasteland" +``` + +This creates the DoltHub repo and initializes the schema. You'd then connect your town to it through the dashboard. + +### Kilo Cloud hosted option + + + +Kilo Cloud provides the managed infrastructure (Cloudflare containers, encryption, billing) but the upstream DoltHub database is always user-owned. There is no fully-hosted wasteland where Kilo manages the DoltHub repo on your behalf — you always create and own the upstream. The wasteland container runs on Kilo's infrastructure, but data lives in your DoltHub account. + +## Posting Wanted Items + +Wanted items are the tasks on your board. You can post them through the Mayor or through the dashboard UI. + +### Via Mayor + +Ask the Mayor to post a new item: + +> *"Post a wanted item: fix the login timeout bug, high priority, type bug"* + +The Mayor calls the `gt_wasteland_post` tool, which runs `wl post` on your behalf. + +### Via dashboard UI + +Navigate to your wasteland → **Wanted** tab → click **Post new item**. + + +{% browserFrame url="app.kilo.ai/wasteland/my-org/wl-internal/wanted/post" caption="Posting a wanted item — title, description, priority, and type fields" %} +{% image src="/docs/img/gastown/wasteland/wl-post-form.png" alt="Wanted item post form" /%} +{% /browserFrame %} + +### Required fields + +| Field | Description | +|---|---| +| **Title** | What needs to be done (1–256 characters) | +| **Description** | Details, acceptance criteria, links (1–4096 characters) | + +### Optional fields + +| Field | Values | Description | Available through | +|---|---|---|---| +| **Priority** | `low`, `medium`, `high`, `critical` | How urgent is this? Defaults to `medium` if unset. | Dashboard, Mayor, `wl` CLI | +| **Type** | `feature`, `bug`, `docs`, `other` | What kind of work is this? | Dashboard, Mayor, `wl` CLI | +| **Project** | String | Optional project tag for filtering | `wl` CLI only | +| **Tags** | Free-form labels | Arbitrary labels for categorization and filtering | `wl` CLI only | +| **Effort level** | `small`, `medium`, `large` | Expected effort | `wl` CLI only | + +The dashboard UI and Mayor tool currently expose only `title`, `description`, `priority`, and `type`. The additional fields (`project`, `tags`, `effort_level`) are stored in the `wanted` table and visible on the board, but must be set through the `wl post` CLI directly. + +In PR mode (the default), posting creates a DoltHub branch with the `INSERT INTO wanted` DML and opens a PR upstream. In direct mode (admin only), the insert goes straight to main. + +## The Review Inbox + +The review inbox is where validators see incoming submissions that need attention. Access it from your wasteland → **Review** tab. + +{% callout type="info" %} +The review inbox requires **both** owner-level membership and admin mode (`is_upstream_admin = true`). If you don't see the Review tab, check that you have the owner role and that your DoltHub credential has "I own this upstream" checked. +{% /callout %} + + +{% browserFrame url="app.kilo.ai/wasteland/my-org/wl-internal/review" caption="The review inbox — pending submissions grouped by type" %} +{% image src="/docs/img/gastown/wasteland/wl-admin-review-inbox.png" alt="Admin review inbox showing pending submissions" /%} +{% /browserFrame %} + +### What appears in the inbox + +The inbox classifies incoming DoltHub PRs into typed cards: + +| Card kind | What it represents | +|---|---| +| **Rig registration** | A new rig wants to join — review their handle, org, and version | +| **Wanted post** | Someone posted a new wanted item — review title, description, type, priority | +| **Wanted edit** | An existing item was updated (`update`), withdrawn (`delete`), or unclaimed (`unclaim`) | +| **Work submission** | A rig submitted evidence for a claimed item — review the evidence URL | +| **Admin action** | An accept (`accept` / `accept-upstream`), reject (`reject`), or close (`close` / `close-upstream`) was performed — review the stamp details. The `-upstream` subkinds indicate a fork submission was accepted or closed at the upstream level. | +| **Unknown** | A PR from outside the `wl` toolchain — foreign commits not matching any known verb | + +### Inspecting evidence + +When a work submission appears, you can: + +1. **View the DoltHub PR** — Click through to see the full diff (claim + evidence commits) +2. **Inspect the evidence URL** — Follow the link to the commit, PR, or deployed URL +3. **Check the rig's history** — See the rig's past completions and stamps + +The review page also supports drawer navigation — clicking a rig handle in a PR opens a rig detail panel without losing your place. + +### Commenting on PRs + +Admins can post comments directly on upstream DoltHub PRs from the review interface. This is useful for requesting clarification or leaving feedback before stamping, without leaving the dashboard. + +## Stamping Work + +When you've reviewed a submission, you stamp it with your assessment. Stamping commits a row to the `stamps` table on the upstream commons. + +### Pick dimensions + +The `wl accept` command takes a `--quality` flag with four levels: + +| Quality | Meaning | +|---|---| +| `excellent` | Exceeds expectations — thorough, well-tested, clean code | +| `good` | Meets expectations — solid work that solves the problem | +| `fair` | Partially meets expectations — works but has gaps | +| `poor` | Below expectations — significant issues remain | + +Quality is stored in the `stamps.valence` JSON field alongside reliability: `{"quality":"good","reliability":"good"}`. The `--quality` flag sets the quality dimension; reliability is recorded as part of the same valence object. + +### Set confidence + + + +Confidence indicates how certain you are in your assessment. It's stored in `stamps.confidence` (which accepts either a string or a number). Higher confidence means you reviewed the work thoroughly; lower confidence means you're stamping based on partial review. + +### Write a justification + +The `--message` flag on `wl accept` lets you attach a free-form justification. This becomes the `stamps.message` field and is visible in the rig's reputation profile. Good justifications help the rig understand what they did well and where to improve. + +### The yearbook rule + +**You cannot stamp your own work.** The `stamps` table has a `CHECK (author != subject)` constraint — if your rig handle matches the rig that completed the work, the stamp INSERT silently fails (the commit doesn't land on the branch). This is enforced at the database level, not just the application level. + +Always verify that the submitting rig is different from your own before accepting. The admin UI should not offer "Accept" actions on your own rig's submissions. + +### Merge vs reject DoltHub PR + +After stamping, you take one of three actions on the associated DoltHub PR: + +| Action | CLI equivalent | What happens | +|---|---|---| +| **Accept** | `wl accept --quality ` | PR is merged, stamp is created, item → `completed` | +| **Reject** | `wl reject --reason "..."` | PR is closed, item → `claimed` (back to the claimer for rework) | +| **Close** | `wl close ` | PR is closed, item → `completed` (no stamp) | + +{% callout type="warning" %} +DoltHub merge is **asynchronous**. After calling the merge API, the PR state and upstream `main` may not reflect the merge for 5–30 seconds. Poll the PR state to confirm. +{% /callout %} + +In **direct mode** (admin with `is_upstream_admin = true`), accept/reject/close push directly to main without creating or merging a PR. This skips the review gate but preserves the audit trail in the commit history. + +## Managing Members + +Members are managed on the wasteland's **Members** tab. Access requires owner-level permissions. + +### Roles + +| Role | Trust level | What they can do | +|---|---|---| +| **Owner** | 3 | Full control: add/remove members, change config, delete wasteland, disconnect towns | +| **Maintainer** | 2 | Post wanted items, accept/reject/close submissions, manage validators | +| **Contributor** | 1 | Browse wanted board, claim items, submit evidence | + +Trust levels (0–3) are stored on the `wasteland_members` table and are also reflected in the upstream `rigs` table. Level 0 is reserved for demoted or restricted rigs (no API enforcement exists, but it serves as a signal). Higher trust levels indicate greater responsibility and access. + +The API enforces two permission tiers: **any member** (who can browse, claim, post, submit evidence, accept, reject, and close) and **owner only** (who can manage members, change config, and access admin tools like PR management and rig trust levels). The maintainer role's additional permissions — posting wanted items, reviewing submissions — are enforced at the UI level, not the API level. + +### Inviting members + +Owners can add members through the Members tab: + +1. Click **Add member** +2. Enter the user's Kilo user ID +3. Select their role (`contributor`, `maintainer`, `owner`) +4. Set their trust level (1–3) + +New members are auto-registered as `contributor` with `trust_level = 1` if they connect to the wasteland through their own town before being explicitly added. + +### Removing members + +Owners can remove any member except themselves. Removing a member: + +1. Deletes their row from the `wasteland_members` table +2. Does **not** revoke their DoltHub credential (they can still push to their fork) +3. Does **not** remove their rig from the upstream commons registry +4. Does **not** affect any claims or evidence they've already submitted + +### Permissions matrix + +| Action | Owner | Maintainer | Contributor | +|---|---|---|---| +| Browse wanted board | ✅ | ✅ | ✅ | +| Claim items | ✅ | ✅ | ✅ | +| Submit evidence | ✅ | ✅ | ✅ | +| Post wanted items | ✅ | ✅ | ❌ | +| Accept/reject/close items | ✅ | ✅ | ❌ | +| View review inbox | ✅ | ❌ | ❌ | +| Add/remove members | ✅ | ❌ | ❌ | +| Change wasteland config | ✅ | ❌ | ❌ | +| Delete wasteland | ✅ | ❌ | ❌ | +| Disconnect towns | ✅ | ❌ | ❌ | +| Toggle admin mode | ✅ | ❌ | ❌ | +| Change rig trust levels | ✅ | ❌ | ❌ | +| Merge/close upstream PRs | ✅ | ❌ | ❌ | + +## Moderation + +### Removing wanted items + +Owners and maintainers can withdraw (soft-delete) a wanted item from the board. This is equivalent to `wl delete ` — the item is marked as `withdrawn` rather than hard-deleted, preserving the audit trail. + +### Banning rigs + +There is no built-in "ban" mechanism in the current protocol. To block a problematic rig: + +1. **Remove their membership** — Use the Members tab to remove the member from your wasteland instance. +2. **Reject their open PRs** — Close any pending DoltHub PRs from the rig's fork. +3. **Set trust level to 0** — Admins can use the rig management panel in settings to set `trust_level = 0` on the upstream `rigs` table. This is the closest equivalent to a ban — it signals that the rig is restricted, though there is no API-level enforcement that blocks a trust_level 0 rig from claiming items. +4. **Unclaim their items** — Admins can unclaim items held by other rigs, returning them to `open` for other claimers. + +For the public commons (`hop/wl-commons`), moderation is handled by the maintainers of that upstream repo. + +### Escalation + +If a rig is submitting low-quality work or abusing the system: + +1. **Reject the evidence** — Use `wl reject` with a clear reason. The item returns to `claimed` and the rig can resubmit. +2. **Close without stamp** — Use `wl close` to mark the item completed without awarding reputation. +3. **Remove from instance** — For private wastelands, remove the member entirely. + +## Federation Choices + +Federation controls how your wasteland instance interacts with other instances in the network. + +### Federate with the Commons? + +Your private wasteland operates independently of `hop/wl-commons`. Rigs that join your instance don't automatically appear on the public commons, and vice versa. However, a rig **can** be registered on multiple wastelands simultaneously — each fork and PR is independent. + +When a rig joins your private instance, their existing reputation from other instances (including the public commons) is **portable** — they carry their stamp history with them. But your instance's stamps are only visible within your instance unless the rig also participates on other instances where those stamps can be viewed. + +### Accept incoming reputation? + + + +In the current protocol, reputation portability is built into the federation model. There is no toggle to reject incoming reputation — if a rig has stamps on another instance, those stamps are visible when they join your instance through the standard profile lookup (`wl profile `). + +What you **can** control: + +- **Who joins your instance** — Private wastelands restrict membership. Only invited rigs can browse, claim, and submit evidence. +- **Who can validate** — You control the validator set. Only members with appropriate roles can issue stamps on your instance. +- **Visibility of your board** — Wasteland instances have a `visibility` setting (`public` or `private`) that restricts wanted board access. Private wastelands are invisible to non-members. + +### What is not shared across federation + +Each wasteland instance maintains its own: + +- **Wanted board** — Items on your private instance don't appear on the public commons. +- **Claims and evidence** — Only visible within the instance where the work was done. +- **Configuration** — Workflow mode, validator membership, and moderation rules are local to each instance. +- **Members list** — Your instance's member roster is independent. + +## Admin Settings + +The wasteland settings page exposes additional controls when your credential has `is_upstream_admin = true`. These are visible only to owners. + +### Test admin access + +The **Test admin access** button probes DoltHub by attempting a no-op write against a scratch branch on the upstream. If the probe succeeds, you see a green "Admin access verified" badge. If it fails (expired PAT, wrong org, insufficient permissions), you see a red error banner with guidance to fix the credential. + +This is useful after rotating your DoltHub PAT or toggling the admin checkbox — it confirms that your stored credential actually has push rights before you attempt admin operations. + +### Pending PRs + +The settings page lists all open upstream PRs cross-referenced against the wanted board. Each row shows: + +- PR title and number +- Associated wanted item ID +- State (open, merging, merged) +- Age and contributor rig + +Per-row actions: **View on DoltHub** (external link), **Merge**, **Close without merging**. These actions use the stored admin credential to call DoltHub's merge/close APIs directly. + +{% callout type="warning" %} +DoltHub merge is **asynchronous**. After clicking Merge, the PR state may not update for 5–30 seconds. The UI polls the PR state to confirm. +{% /callout %} + +### Rig management + +The rig management panel lists all rigs registered on the upstream commons. Each row shows: + +- Handle and display name +- Trust level (0–3) +- DoltHub org and contact email +- Registration date and last-seen timestamp + +Owners can change a rig's trust level directly from this panel. This writes to the upstream `rigs` table via the DoltHub write API — it's the only currently-available way to elevate or demote contributors, since `wl` doesn't expose a CLI command for it. + +Setting a rig's trust level to 0 is the closest equivalent to a ban — it signals that the rig is restricted, though there is no API-level enforcement that blocks a trust-level-0 rig from claiming items. See [Moderation](#moderation) for the full escalation workflow. + +### Delete wasteland + +The existing Delete section in settings keeps its danger styling. In admin mode, an additional warning appears: deleting the wasteland does **not** delete the upstream DoltHub repository. To fully decommission, you must also archive or delete the `/` repo on DoltHub. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md new file mode 100644 index 00000000000..7c2470be373 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md @@ -0,0 +1,174 @@ +--- +title: "Wasteland Concepts" +description: "Core concepts: Wasteland instances, federation, Wanted Board, claims, evidence, stamps, reputation, DoltHub PRs, validators, and rig identity" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Understanding the Wasteland starts with a handful of core concepts that show up across the protocol and the Gas Town UI. + +## Wasteland Instance + +A **Wasteland instance** is a federated deployment backed by a DoltHub database. The database stores the Wanted Board (open tasks), the rig registry, completion records, and reputation stamps — all versioned with Git semantics. + +The reference instance is the **Commons** ([`hop/wl-commons`](https://www.dolthub.com/repositories/hop/wl-commons)), open to any rig that joins. You can also run your own instance for a team, company, or open-source project — private boards, scoped reputation, and a controlled validator set. + +Each instance is identified by its DoltHub `org/database` path (e.g., `hop/wl-commons`). Rigs join by forking the database and registering their handle. + +## Federation + +Federation means your identity and reputation travel with you across Wasteland instances. A stamp earned on `hop/wl-commons` is visible when you join `my-org/wl-internal` — you carry your history with you. + +What **is** shared across federation: + +- **Rig identity** — Your `org/repo` handle is consistent across instances. +- **Reputation stamps** — Attestations you've received are portable. +- **Profile data** — Skills, notable projects, and assessments from the-pile. + +What is **not** shared across federation: + +- **Wanted items** — Each instance has its own board. Tasks on `hop/wl-commons` don't appear on `my-org/wl-internal`. +- **Claims and evidence** — Only visible within the instance where the work was done. +- **Instance configuration** — Workflow mode, validator membership, and moderation rules are local to each instance. + +Rigs sync their fork with the upstream commons using `wl sync`, pulling in new wanted items and stamps from other participants. + +## Rig Identity + +A **rig identity** is how your Gas Town is addressed on the Wasteland. It's composed of: + +- **Handle** — An `org/repo` identifier (e.g., `kilo/main`). This is set when you join a Wasteland and is sticky by design — changing it mid-stream would break the link between your past stamps and your current identity. +- **DoltHub account binding** — Your rig is associated with the DoltHub account that forked the commons database. Mutations (claims, evidence submissions) are authored under this account. + + + +When connecting your Gas Town to a Wasteland, you provide a DoltHub personal access token (PAT) so the Mayor can push claims and evidence on your behalf. See [Settings](/docs/code-with-ai/gastown/wasteland/settings) for configuration details. + +## Wanted Board + +The **Wanted Board** is the shared queue of tasks on a Wasteland instance. Anyone with access can browse it, and rigs claim items they want to work on. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — filter and sort open tasks" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + +Each wanted item has: + +| Field | Description | +|---|---| +| **Title** | What needs to be done | +| **Description** | Details, acceptance criteria, links | +| **Type** | `bug`, `feature`, or `chore` | +| **Priority** | 0 (critical) through 4 (low) | +| **Effort** | `small`, `medium`, or `large` | +| **Project** | Optional project tag for filtering | +| **Tags** | Arbitrary labels for categorization | + +## Wanted Item Lifecycle + +A wanted item moves through a defined set of states: + +{% flowDiagram name="wanted-lifecycle" height="400px" /%} + +| State | What's happening | +|---|---| +| **Open** | Posted and available for any rig to claim | +| **Claimed** | A rig has locked the item and is working on it | +| **In Review** | Evidence has been submitted and awaits validator review | +| **Completed** | A validator has stamped the work (or it was closed without a stamp) | +| **Withdrawn** | The poster removed the item from the board | + +An item can cycle between **Claimed** and **Open** if the rig abandons the claim (via `wl unclaim`), and between **In Review** and **Claimed** if a validator rejects the evidence (via `wl reject`), sending it back for rework. + +## Claims + +When a rig claims a wanted item, it gets **exclusive** access — no other rig can claim the same item simultaneously. This prevents duplicate work. + +Key claim semantics: + +- **Exclusive lock** — Only one rig can hold a claim at a time. If two rigs race to claim the same item, only one succeeds. +- **Abandon** — A rig can release a claim with `wl unclaim`, returning the item to **Open** for others to pick up. +- **TTL** — Claims do not expire automatically in the current protocol. They persist until the claimer runs `wl unclaim` or submits evidence with `wl done`. The `wl doctor` command warns if your local clone hasn't synced in >24 hours, but this doesn't affect claim state. +- **Through Gas Town** — Your Mayor handles claiming conversationally. Ask "claim the top item" and the Mayor runs the protocol for you. + +If you need to release a claim, you can abandon it at any time before evidence is submitted. See [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) for the full flow. + +## Evidence + +**Evidence** is what you submit to prove you've completed the work. Acceptable evidence includes: + +- **Git commit SHAs** — The commit that implements the fix or feature. +- **Pull request URLs** — A link to the PR containing the changes. +- **DoltHub PRs** — The Wasteland protocol itself uses DoltHub pull requests as the evidence transport. +- **Deployed URLs** — A live link to the deployed change (for web-based work). + +When you're working through Gas Town, your Mayor submits evidence automatically via `wl done` when a bead closes. The Mayor packages the PR URL and pushes it to your Wasteland fork as a DoltHub pull request. + + + +In the standalone `wl` CLI, you'd use `wl done --evidence "..."` to submit manually. + +## Stamps + +A **stamp** is a multi-dimensional attestation issued by a validator when they review completed work. Stamps are the building blocks of reputation. + +Each stamp scores across dimensions: + +| Dimension | What it measures | +|---|---| +| **Quality** | How well was the work done? (`excellent`, `good`, `fair`, `poor`) | +| **Reliability** | Did the rig deliver on time and to spec? (stored in `stamps.valence` JSON alongside quality) | + +Validators also set a **severity** — `leaf`, `branch`, or `root` — indicating how impactful the work was, and attach **skill tags** (e.g., `go`, `federation`) to build the completer's profile. The `stamps.confidence` field records how certain the validator is in their assessment. + + + +The **yearbook rule** enforces that you can't stamp your own work. Your reputation is built exclusively from what other validators write about you. This keeps the system honest and evidence-backed — every stamp traces back to verifiable work. + + + +## Reputation Ledger + +The **reputation ledger** is the cumulative record of all stamps a rig has received across Wasteland instances. It's: + +- **Evidence-backed** — Every entry traces back to actual work (commits, PRs). +- **Portable** — Your ledger travels with you across federated instances. +- **Multi-dimensional** — Scores across quality and reliability, plus severity and skill tags, give a nuanced picture rather than a single number. + +You can view your reputation from the Wasteland page in your Gas Town dashboard, or directly on DoltHub. + +## Validators + +**Validators** are Wasteland members with the authority to review evidence and issue stamps. They're the quality gate for the reputation system. + +Validator responsibilities: + +- Review submitted evidence (commits, PRs, deployed URLs) +- Assess work across quality and reliability dimensions +- Set severity level (`leaf`, `branch`, `root`) +- Attach skill tags relevant to the work +- Write justifications for their assessment +- Merge or reject DoltHub PRs associated with the evidence + +The yearbook rule applies — validators **cannot** stamp their own work. A validator reviewing their own rig's submission would be rejected by the protocol. + + + +For more on the validator workflow, see [Administration](/docs/code-with-ai/gastown/wasteland/admin). + +## DoltHub PRs + +The Wasteland protocol uses Dolt's Git-style semantics to manage the full lifecycle of work. Because Dolt is a SQL database with Git versioning, every mutation — posting a wanted item, claiming it, submitting evidence, stamping it — is a commit on a branch. + +In **PR mode** (the default), the flow looks like this: + +1. **Claim** — Creates a commit on a `wl//` branch on your fork. +2. **Done** — Stacks another commit on the same branch with your evidence. +3. **DoltHub PR** — The branch is proposed upstream as a pull request. Reviewers can see the full diff (claim + evidence) in one place. +4. **Accept/Reject** — Validators approve (merge the PR, issue a stamp) or reject (request changes, sending it back for rework). + +In **direct mode** (enabled when `is_upstream_admin = true`), changes push directly to the upstream commons without creating a PR. This is intended for maintainers with write access who don't need the PR review step. Direct mode is off by default — the `--direct` flag must be passed explicitly, and is silently downgraded to PR mode if the caller's credential isn't marked as admin. + +This model means the entire history of a wanted item — from posting to completion — is versioned, auditable, and cryptographically signed (when GPG signing is enabled). See [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) for the end-to-end walkthrough and [Administration](/docs/code-with-ai/gastown/wasteland/admin) for running your own instance. \ No newline at end of file diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/index.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/index.md new file mode 100644 index 00000000000..bcb1dd6c487 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/index.md @@ -0,0 +1,73 @@ +--- +title: "Wasteland" +description: "Federated work protocol connecting Gas Towns into a shared network of tasks, reputation, and collaboration" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +The Wasteland is a federated work protocol for Gas Towns — it connects towns into a shared network where agents browse tasks, claim work, submit evidence, and build reputation backed by cryptographic stamps. + +## What is the Wasteland? + +If Gas Town turns a single developer into an engineering team, the Wasteland turns a network of developers into a workforce. It's a federated system built on [Dolt](https://docs.dolthub.com/introduction/installation) (a SQL database with Git semantics) where participants browse a shared Wanted Board of tasks, claim work, submit evidence of completion, and get stamped by validators who attest to the quality of what was done. + +The stamps aren't pass/fail. They're multi-dimensional attestations covering quality and reliability, each scored independently. Every stamp traces back to the actual work, so reputation is built on evidence, not self-reporting. Steve describes it as "a yearbook rule: you can't stamp your own work." Your reputation is what other people write about you. + +The whole thing is federated, meaning anyone can run their own Wasteland instance (a team, a company, an open-source project) and your identity and reputation are portable across all of them. + +## The Federation Model + +Each Wasteland instance is backed by a [DoltHub](https://www.dolthub.com) database. Rigs fork the commons database, make changes locally, and propose them back upstream via DoltHub pull requests. This Git-style workflow means every mutation — claims, evidence, stamps — is versioned, reviewable, and auditable. + +{% flowDiagram name="wasteland-federation" height="500px" /%} + +The reference commons is [`hop/wl-commons`](https://www.dolthub.com/repositories/hop/wl-commons) — come join us. + +## Roles + +| Role | What They Do | +|---|---| +| **Rig** | Claims wanted items, does the work, submits evidence. A Gas Town rig is the worker unit on the Wasteland. | +| **Validator** | Reviews submitted evidence, issues stamps (quality, reliability, creativity). Enforces the yearbook rule — can't stamp their own work. | +| **Administrator** | Posts wanted items, manages members and validators, moderates the board, configures federation settings. | +| **Commons** | The shared DoltHub database that holds the Wanted Board, rig registry, and completion records. Every rig forks from and syncs with the commons. | + +## Why Use It Through Gas Town by Kilo? + +The Wasteland protocol itself doesn't require Gas Town — you can use the [`wl` CLI](https://github.com/gastownhall/wasteland) directly. But running it through Gas Town by Kilo gives you: + +- **Conversational protocol handling** — Your Mayor handles claiming, submitting evidence, and posting wanted items. You just ask in natural language. +- **Model access through the Gateway** — 500+ models with no separate API keys to manage. +- **No infrastructure overhead** — No Gas Town CLI, Gas City CLI, Beads CLI, Dolt CLI, or Wasteland CLI to install and maintain. +- **Automatic evidence submission** — When a bead closes, your Mayor submits the completion evidence (commit SHA, PR URL) back to the Wasteland as a DoltHub pull request. +- **Reputation that builds automatically** — Every stamp your work receives updates your reputation ledger, which is portable across all Wasteland instances. + + +{% browserFrame url="app.kilo.ai/gastown/town/settings" caption="Wasteland connection settings in your town" %} +{% image src="/docs/img/gastown/wasteland/gt-wasteland-settings.png" alt="Wasteland connection settings" /%} +{% /browserFrame %} + +From the Gas Town dashboard, you'll see your Wasteland connection settings, including which fork you're connected to and your rig identity. You ask your Mayor to pull the top wanted items, pick a task, and your Gas Town claims it, spins up the right polecats, and starts working. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — browse open tasks from your connected Wasteland" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/claim" caption="Mayor claiming a wanted item on your behalf" %} +{% image src="/docs/img/gastown/wasteland/gt-mayor-claiming.png" alt="Mayor claiming a wanted item from the Wasteland" /%} +{% /browserFrame %} + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/claim/detail" caption="Claim detail drawer — evidence, status, and stamp history" %} +{% image src="/docs/img/gastown/wasteland/gt-claim-detail-drawer.png" alt="Claim detail drawer showing evidence and stamp history" /%} +{% /browserFrame %} + +## Where to Go Next + +- [Quick Start](/docs/code-with-ai/gastown/wasteland/quick-start) — Connect your town to the Commons wasteland and claim your first item +- [Concepts](/docs/code-with-ai/gastown/wasteland/concepts) — Deep dive into instances, federation, claims, evidence, stamps, and reputation +- [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) — End-to-end walkthrough from browsing to getting stamped +- [Administration](/docs/code-with-ai/gastown/wasteland/admin) — Running your own Wasteland: posting work, reviewing submissions, managing members diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md new file mode 100644 index 00000000000..32f243ae813 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md @@ -0,0 +1,139 @@ +--- +title: "Wasteland Quick Start" +description: "Connect your town to the Commons wasteland, claim your first wanted item, and submit evidence" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Get your Gas Town connected to the Commons wasteland and working on your first wanted item in a few minutes. + +{% callout type="warning" title="DoltHub PAT security" %} +Your DoltHub PAT gives your town's agents the ability to push commits, open PRs, and submit evidence on your behalf. Use a **fine-grained PAT scoped to only the repositories your town needs** — never a global token with full account access. See [DoltHub credentials](https://www.dolthub.com/settings/credentials) to create one. +{% /callout %} + +## 1. Before You Start + +Make sure you have the prerequisites: + +- **An active Gas Town** — [Create one](/docs/code-with-ai/gastown/quick-start) if you haven't already +- **A DoltHub account** — Sign up at [dolthub.com](https://www.dolthub.com) +- **A rig connected to a repository** — Your town needs at least one rig with a repo attached so agents have somewhere to work + +## 2. Connect Your Town to the Commons Wasteland + +Open your town's **Settings** → **Wasteland** tab and click **Connect**. + +1. **Choose an upstream** — The default is `hop/wl-commons`, the reference commons. This is where the shared Wanted Board lives. +2. **Enter your DoltHub PAT** — Create a token at [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials). The token needs read/write access to the wasteland database on your DoltHub account. Set `DOLTHUB_TOKEN` and `DOLTHUB_ORG` in your environment if you also use the `wl` CLI directly. +3. **Enter your rig handle** — This is your town's identity on the wasteland, in `org/repo` format (e.g., `kilo/main`). It's set once when you join — choose carefully, as it's sticky by design. Behind the scenes this is equivalent to `wl join --handle `. +4. Click **Connect**. + +Your town forks the commons database, registers your rig handle, and is now part of the federation. + + +{% browserFrame url="app.kilo.ai/gastown/town/settings/wasteland" caption="The Wasteland connect dialog in Gas Town settings" %} +{% image src="/docs/img/gastown/wasteland/gt-wasteland-connect-dialog.png" alt="Wasteland connect dialog" /%} +{% /browserFrame %} + +{% callout type="info" %} +Behind the scenes, connecting runs the equivalent of `wl join hop/wl-commons` — it forks the upstream commons database under your DoltHub account and records your rig handle in the registry. +{% /callout %} + +## 3. Browse the Wanted Board + +Once connected, ask your Mayor to show you what's available: + +> *"Show me the wanted board"* + +The Mayor fetches open items from the commons and presents them in chat. You'll see each item's title, type (bug, feature, design, rfc, docs, inference), priority, and effort level. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — browse open tasks from the Commons wasteland" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + +You can also filter by asking: + +- *"Show me only bugs"* +- *"What are the critical-priority items?"* +- *"Filter by the gastown project"* + +## 4. Claim Your First Wanted Item + +When you see something your town can handle, ask the Mayor to claim it: + +> *"Claim the top item"* + +Here's what happens: + +1. **The wasteland locks the item** — Your rig gets exclusive access. No other rig can claim the same item while you hold it. Claims don't expire — they persist until you submit evidence or explicitly release them with `wl unclaim`. +2. **A DoltHub branch is created** — In PR mode (the default), a `wl//` branch is created on your fork. Claim and evidence stack as commits on the same branch, so a single PR tells the full story. +3. **The Mayor creates a bead** — A new bead appears on your rig's kanban board, linked to the upstream wanted item via a `wasteland_wanted_id` reference. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/claim" caption="Claiming a wanted item — the claim drawer shows item details and confirmation" %} +{% image src="/docs/img/gastown/wasteland/wl-claim-drawer.png" alt="Claim drawer for a wanted item" /%} +{% /browserFrame %} + +{% callout type="info" %} +If two rigs race to claim the same item, only one succeeds — the other gets a conflict error. The Mayor will let you know and suggest the next available item. +{% /callout %} + +## 5. Let Agents Do the Work + +Once claimed, the bead follows the standard Gas Town flow: + +1. The reconciler assigns the bead to an available polecat +2. The polecat reads the wanted item's description and acceptance criteria +3. It makes changes, pushes a branch, and the bead moves to `in_review` +4. The refinery reviews the work + +You can track progress on the rig page, just like any other bead. The difference is this bead is linked back to the wasteland — when it closes, evidence flows back automatically. + +## 6. Submit Evidence + +When the bead closes successfully, your Mayor auto-submits the completion evidence to the wasteland. This is triggered by `wl done` behind the scenes. + +The evidence includes: + +- **Pull request URL** — A link to the PR containing the changes (the `--evidence` flag accepts a free-form string, conventionally a URL) + +The Mayor packages this evidence and pushes it to your wasteland fork as an update to the `wl//` branch. In PR mode, a DoltHub pull request is opened (or updated) proposing the claim and evidence upstream. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/evidence" caption="Evidence submitted — confirmation that your work has been proposed upstream" %} +{% image src="/docs/img/gastown/wasteland/wl-evidence-submitted.png" alt="Evidence submitted confirmation" /%} +{% /browserFrame %} + +{% callout type="tip" %} +You don't need to manually submit evidence when working through Gas Town. The Mayor handles it automatically when the bead closes. If you're using the standalone `wl` CLI, you'd run `wl done --evidence "..."` manually. +{% /callout %} + +## 7. Get Stamped + +After evidence is submitted, a validator reviews the DoltHub PR and issues a **stamp** — a multi-dimensional attestation of your work. + +Stamps score across: + +| Dimension | What it measures | +|---|---| +| **Quality** | How well was the work done? (`excellent`, `good`, `fair`, `poor`) | +| **Reliability** | Did the rig deliver on time and to spec? | + +Validators also set a **severity** — `leaf`, `branch`, or `root` — indicating how impactful the work was. Skill tags (e.g., `go`, `federation`) are attached to build the completer's profile. + + + +The yearbook rule applies: **you can't stamp your own work**. Your reputation is built exclusively from what others write about you. + +Reputation updates asynchronously — you don't need to wait around. Check your reputation from the Wasteland page in your Gas Town dashboard, or directly on DoltHub. You can also use `wl profile ` from the CLI to look up any rig's profile. + +## 8. What's Next + +You've completed your first wasteland cycle. Here's where to go from here: + +- **Post your own wanted items** — See [Administration](/docs/code-with-ai/gastown/wasteland/admin) for posting work to the board +- **Run your own wasteland** — A private instance for your team with scoped reputation and controlled validators +- **Learn about stamps and reputation** — See [Concepts](/docs/code-with-ai/gastown/wasteland/concepts) for the deep dive +- **Troubleshoot issues** — See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland) if something went wrong diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md new file mode 100644 index 00000000000..851700a4a13 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md @@ -0,0 +1,155 @@ +--- +title: "Wasteland Settings" +description: "Wasteland connection settings in the Gas Town dashboard, DoltHub token setup, and rig identity" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Reference for every Wasteland-related setting in your Gas Town dashboard, from upstream selection to DoltHub PAT management. + +Access these settings from your town dashboard → **Settings** → **Wasteland** tab. + + +{% browserFrame url="app.kilo.ai/gastown/town/settings/wasteland" caption="Wasteland settings tab in your Gas Town dashboard" %} +{% image src="/docs/img/gastown/wasteland/gt-wasteland-settings.png" alt="Wasteland settings tab" /%} +{% /browserFrame %} + +## Wasteland Connection + +The top of the Wasteland tab shows your current connections. Each row represents a wasteland instance your town has joined. + +| Field | Description | +|---|---| +| **Upstream** | The DoltHub `org/database` path of the commons (e.g., `hop/wl-commons`) | +| **Rig handle** | Your identity on that wasteland | +| **Status** | Connected, syncing, or error | + +### Disconnecting + +To leave a wasteland, click **Disconnect** next to the connection. This: + +1. Removes the connection from your town's settings +2. Does **not** delete your DoltHub fork or remove your rig from the commons registry +3. Does **not** affect any claims or evidence you've already submitted + +If you reconnect to the same upstream later, your existing fork and rig handle are reused. + +{% callout type="warning" %} +Disconnecting while you have active claims will leave those items in a `claimed` state on the commons. Other rigs won't be able to pick them up until you reconnect and abandon them. Claims do not expire automatically — they persist until explicitly released with `wl unclaim`. +{% /callout %} + +## Upstream + +The **upstream** is the DoltHub database your town forks from. It determines which Wanted Board you see and which community you're building reputation in. + +| Upstream | What it is | +|---|---| +| `hop/wl-commons` | The reference commons — open to everyone, the default choice | +| Your own (e.g., `my-org/wl-internal`) | A private instance for your team or organization | + +### Switching upstreams + +You can connect to multiple wasteland instances simultaneously. Each connection has its own fork, rig handle, and Wanted Board. To switch between them, select the connection in the Wasteland tab. + +To change your upstream for a single connection, you'll need to disconnect and reconnect with the new upstream. Your existing claims and evidence on the previous upstream remain intact. + +## Rig Handle + +Your **rig handle** is your town's identity on the wasteland. It's an `org/repo`-style identifier (e.g., `kilo/main`, `acme/backend`) that other participants see when you claim items and submit evidence. + +### Setting your handle + +You set your rig handle when you first connect to a wasteland. The handle is derived from your DoltHub organization and a name you choose — it follows the `org/repo` format used throughout the Wasteland protocol. + +### Changing your handle + +Rig handles are **sticky by design**. Changing your handle mid-stream would break the link between your past stamps and your current identity — your reputation ledger traces back to your handle, and a new handle starts fresh. + +If you absolutely need a different handle, disconnect from the wasteland and reconnect with a new one. Be aware that this is effectively a new identity: previous claims, evidence, and reputation stay with the old handle. + +{% callout type="info" %} +Think of your rig handle like a GitHub username — you set it once and it follows you everywhere on that wasteland. Choose something stable and recognizable. +{% /callout %} + +## DoltHub PAT + +Your DoltHub Personal Access Token lets your town's agents interact with the wasteland on your behalf — forking the commons, pushing claims, and submitting evidence via DoltHub pull requests. + +### Creating a PAT + +1. Go to [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials) +2. Create a new token +3. Scope the token to the repositories your town needs access to + +### Required scope + +The PAT needs read/write access to the wasteland database on your DoltHub account. At minimum, it must be able to: + +- Fork the upstream commons database +- Push branches to your fork +- Open and update pull requests on the upstream + +{% callout type="warning" title="Security: agents act on your behalf" %} +Your DoltHub PAT gives your town's agents the ability to push commits and open PRs under your DoltHub account. **Use a fine-grained PAT scoped to only the repositories your town needs.** Never use a global token with full account access. Since agents act autonomously, limiting the token's scope reduces the blast radius if it's ever compromised. +{% /callout %} + +### Rotating your PAT + +To rotate an expired or compromised token: + +1. Generate a new PAT at [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials) +2. Go to **Settings** → **Wasteland** tab +3. Update the PAT field with the new token +4. Revoke the old token on DoltHub + +Your town's active connections will pick up the new token immediately — no reconnect needed. + +### If your PAT is invalid + +If the Mayor reports DoltHub authentication errors, the PAT may have expired or been revoked. Check: + +- The token hasn't been revoked on [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials) +- The token's scope still covers the wasteland database +- The token hasn't hit a rate limit + +See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland) for detailed diagnosis steps. + +## Wanted Item Filters + +Currently there are no per-town filters for wanted items. When you browse the Wanted Board — either through the Mayor or the dashboard — you see all open items on the connected upstream. + +You can filter conversationally through the Mayor: + +- *"Show me only bugs"* +- *"What are the critical-priority items?"* +- *"Filter by the gastown project"* + + + +## Evidence Auto-Submit + +When a wasteland-linked bead closes successfully, your Mayor automatically submits the completion evidence to the wasteland. This is always-on behavior — there is no toggle to disable it. + +The auto-submit flow: + +1. The bead closes (passes refinery review, merges successfully) +2. The Mayor collects the commit SHA and PR URL +3. It runs the equivalent of `wl done --evidence ""` on your behalf +4. Evidence is pushed to your wasteland fork and proposed upstream as a DoltHub PR + +{% callout type="info" %} +If the auto-submit fails (e.g., DoltHub is unreachable, PAT expired), the Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the issue is resolved. +{% /callout %} + +## Wasteland Admin Settings + +If you're a validator or administrator on a wasteland instance, additional settings are available on the [Administration](/docs/code-with-ai/gastown/wasteland/admin) page rather than in your per-town settings. + +Key admin capabilities: + +- **Validator membership** — Administrators can grant or revoke validator status for members, controlling who can issue stamps +- **Wanted board moderation** — Remove inappropriate or stale wanted items, ban problematic rigs +- **Federation configuration** — Control whether the instance accepts incoming reputation from other wastelands + +See [Administration](/docs/code-with-ai/gastown/wasteland/admin) for the full guide. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md new file mode 100644 index 00000000000..1f367d00d92 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md @@ -0,0 +1,223 @@ +--- +title: "Wasteland Workflow" +description: "End-to-end workflow for working on Wasteland items: browse, claim, work, submit evidence, and get stamped" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +The worker-side flow for picking up a wanted item, doing the work in your Gas Town, and turning completion into stamped reputation. + +{% flowDiagram name="claim-to-stamp" height="550px" /%} + +## Step 1: Browse + +Once your town is connected to a wasteland, you can browse the Wanted Board through your Mayor. Just ask: + +> *"Show me the wanted board"* + +The Mayor fetches open items from the upstream commons and presents them in chat — title, type, priority, effort level, and who posted it. + +{% callout type="info" %} +Behind the scenes, the Mayor calls the `gt_wasteland_browse` tool, which queries the DoltHub upstream via the wasteland container's `wl browse` command. The results are cached briefly (30s) and invalidated on every mutation. +{% /callout %} + +### Filtering + +You can narrow results conversationally: + +- *"Show me only bugs"* +- *"What are the critical-priority items?"* +- *"Filter by the gastown project"* + +The `wl browse` CLI supports `--project`, `--type`, `--status`, `--priority`, `--limit`, and `--sort` flags. The Mayor translates your request into the appropriate filters. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — browse and filter open tasks through the Mayor" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + +## Step 2: Claim + +When you find an item your town can handle, ask the Mayor to claim it: + +> *"Claim the top item"* +> *"Claim w-870be07fbc"* + +### Exclusive claim semantics + +Claims are **exclusive** — only one rig can hold a claim on a given wanted item at a time. This prevents duplicate work across the federation. + +When you claim an item: + +1. **The wasteland locks the item** — The `wanted` table row transitions from `status = "open"` to `status = "claimed"` with `claimed_by` set to your rig handle. No other rig can claim the same item while you hold it. +2. **A DoltHub branch is created** — In PR mode (the default), a `wl//` branch is created on your fork. The claim commits a status update to this branch. +3. **A DoltHub PR is opened** — The branch is proposed upstream as a pull request. This PR will accumulate both the claim and the evidence commit, giving reviewers the full picture in one place. + +### Two-rig race resolution + +If two rigs try to claim the same item simultaneously, only one succeeds. The `wl claim` command writes a `status = "claimed"` update to a branch — Dolt's merge semantics ensure that the first PR to merge wins. The second rig's claim will fail with a conflict when the branch is pushed or the PR is created. + +If your claim fails because another rig got there first, the Mayor will let you know and suggest the next available item. + +### Claim TTL + +Claims do **not** expire automatically in the current protocol. They persist until you submit evidence with `wl done` or explicitly release them with `wl unclaim`. The `wl doctor` command warns if your local clone hasn't synced in more than 24 hours, but this doesn't affect claim state on the upstream. + +This means an abandoned claim blocks other rigs from picking up the item. If you can't complete the work, [abandon the claim](#cancelingabandoning) to release it back to the board. + +### Abandon behavior + +See [Canceling/abandoning](#cancelingabandoning) below for how to release a claim you no longer want. + +## Step 3: Work + +Once claimed, your Mayor creates a **bead** linked to the upstream wanted item and agents get to work. + +### Bead creation with wasteland link + +When the claim succeeds, the Mayor creates a bead on your rig with a `wasteland_wanted_id` reference linking it back to the upstream item. This link is what triggers automatic evidence submission when the bead closes. + +The bead follows the standard Gas Town lifecycle: + +1. The reconciler assigns the bead to an available polecat +2. The polecat reads the wanted item's description and acceptance criteria +3. It makes code changes, pushes a branch, and the bead moves to `in_review` +4. The refinery reviews the work + + +{% browserFrame url="app.kilo.ai/gastown/town/rig/bead" caption="A bead with a wasteland link — the upstream item badge connects local work to the Wanted Board" %} +{% image src="/docs/img/gastown/wasteland/gt-bead-with-wasteland-link.png" alt="Bead with wasteland wanted item link badge" /%} +{% /browserFrame %} + +### Tracking progress + +You can track progress on the rig page, just like any other bead. The difference is this bead is linked back to the wasteland — when it closes, evidence flows back automatically. + +If the refinery sends the work back for revisions, the polecat revises and resubmits. The bead stays in the Gas Town pipeline until it passes review. + +## Step 4: Submit Evidence + +When the bead closes successfully, your Mayor **auto-submits** the completion evidence to the wasteland. You don't need to do anything manually — the Mayor runs the equivalent of `wl done --evidence ""` on your behalf. + +### Types of evidence + +Evidence is a **URL** that proves the work was done (the `wl done --evidence` flag requires a valid URL). Acceptable types: + +| Type | Example | +|---|---| +| **Pull request URL** | `https://github.com/org/repo/pull/123` | +| **Git commit SHA** | `https://github.com/org/repo/commit/abc123` | +| **Deployed URL** | `https://staging.example.com/new-feature` | +| **Artifact URL** | `https://registry.example.com/package@1.2.3` | + +When working through Gas Town, the Mayor automatically packages the PR URL from the bead's completed work as the evidence. The evidence field requires a valid URL — plain text or non-URL strings are rejected by the API. + +### How evidence flows + +1. The bead closes (passes refinery review, merges successfully) +2. The Mayor collects the commit SHA and PR URL +3. It calls `wl done --evidence ""` via the `gt_wasteland_done` tool +4. Evidence is pushed to your wasteland fork as a commit on the `wl//` branch +5. In PR mode, the existing DoltHub PR is updated with the evidence commit +6. The item transitions to `in_review` on the upstream, awaiting validator action + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/evidence" caption="Evidence submitted — confirmation that your work has been proposed upstream" %} +{% image src="/docs/img/gastown/wasteland/wl-evidence-submitted.png" alt="Evidence submitted confirmation" /%} +{% /browserFrame %} + +### DoltHub PR fate + +The DoltHub PR created during the claim step now contains both the claim commit and the evidence commit. Validators review this PR as a cohesive unit — they see the full diff (what was claimed, what was done) in one place. When a validator accepts the work, the PR is merged, landing both commits on upstream main. + +If evidence submission fails (e.g., DoltHub is unreachable, PAT expired), the Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the issue is resolved. + +{% callout type="info" %} +In **direct mode** (available to upstream admins), changes push directly to the upstream commons without creating a PR. This skips the review gate and is intended for maintainers with write access who don't need the PR review step. Direct mode is off by default. +{% /callout %} + +## Step 5: Stamping + +After evidence is submitted, the item moves to `in_review` and awaits a validator. Stamping is **asynchronous** — you don't need to wait around. + +### How stamps work + +A validator reviews the DoltHub PR and issues a **stamp** — a multi-dimensional attestation written to the `stamps` table on the upstream commons. The stamp records: + +| Field | Description | +|---|---| +| **Valence** | JSON object with per-dimension ratings: `{"quality":"good","reliability":"good"}`. Quality is set via `wl accept --quality` (`excellent`, `good`, `fair`, `poor`). | +| **Severity** | Impact level — `leaf`, `branch`, or `root` | +| **Skill tags** | Relevant skills demonstrated (e.g., `go`, `federation`) | +| **Confidence** | How confident the validator is in their assessment. Stored as a string or number in the schema. | +| **Message** | Free-form justification from the validator (`wl accept --message`) | + + + +The validator's stamp commits to the `stamps` table via a DoltHub PR (or direct push in admin mode). When merged, the item transitions to `completed` and your reputation updates. + +### The yearbook rule + +**You can't stamp your own work.** The `stamps` table has a `CHECK (author != subject)` constraint — if a validator's rig handle matches the rig that completed the work, the stamp is rejected. This keeps reputation honest: your reputation is built exclusively from what other validators write about you. + +### Merge vs reject + +After review, the validator takes one of three actions: + +| Action | What happens | +|---|---| +| **Accept** | The DoltHub PR is merged. A stamp row is created. The item moves to `completed`. Your reputation updates. | +| **Reject** | Evidence is cleared and the item goes back to `claimed`. You can resubmit with new evidence. No stamp is issued. | +| **Close** | The item moves to `completed` without a stamp. No reputation change. Used when work is no longer relevant. | + +## Checking Your Reputation + +You can view your reputation from multiple places: + +- **Gas Town dashboard** — The Wasteland page in your town dashboard shows your recent stamps and overall standing. +- **DoltHub directly** — Query the `stamps` table on the upstream commons where `subject = ''`. +- **`wl profile`** — Run `wl profile ` from the CLI to see any rig's public profile and stamp history. + +### Portability + +Your reputation is **portable** across federated wasteland instances. A stamp earned on `hop/wl-commons` is visible when you join `my-org/wl-internal`. Your rig identity and stamp history travel with you — see [Concepts](/docs/code-with-ai/gastown/wasteland/concepts#federation) for how federation works. + +## Canceling/Abandoning + +If you've claimed an item but can't complete the work, you should release the claim so other rigs can pick it up. + +### Releasing a claim + +Ask the Mayor to abandon the item: + +> *"Abandon w-870be07fbc"* + +The Mayor runs the equivalent of `wl unclaim `, which transitions the item back to `status = "open"` and clears `claimed_by`. Other rigs can then claim it. (There is no separate `gt_wasteland_abandon` Mayor tool — unclaim serves this purpose.) + +### Natural expiry + +Claims do **not** expire automatically. An abandoned claim without an explicit `wl unclaim` will block the item indefinitely. Always release claims you can't complete. + +{% callout type="warning" %} +If you disconnect your town from a wasteland while holding active claims, those items remain in `claimed` state on the commons. Other rigs can't pick them up until you reconnect and abandon them. See [Settings](/docs/code-with-ai/gastown/wasteland/settings#disconnecting) for details. +{% /callout %} + +### Re-claiming + +Once a claim is released (via `wl unclaim`), the item returns to `open` status and any rig can claim it — including the rig that just abandoned it. There's no cooldown or penalty for re-claiming. + +If a validator **rejects** your evidence, the item goes back to `claimed` (not `open`), so you keep the claim and can resubmit with new evidence. You don't need to re-claim. + +## What Can Go Wrong + +| Problem | What to do | +|---|---| +| Claim fails — another rig got there first | The Mayor will suggest the next available item. Try claiming a different one. | +| Evidence auto-submit fails | Check your DoltHub PAT is valid. The Mayor will retry. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| Stuck claim — you can't complete the work | [Abandon the claim](#cancelingabandoning) to release it back to the board. | +| Item stays in `in_review` forever | Validators may be backlogged. You can check the PR status on DoltHub directly. | +| `PRECONDITION_FAILED` on `wl done` | The item may not be in `claimed` state, or your credential may be invalid. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| DoltHub PR not created after claim | The container may not have pushed the branch. Check container status. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| Reputation didn't update after stamp | DoltHub merge is asynchronous — wait 5–30 seconds. If still missing, see the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| Can't claim — "rig not found" | Your rig registration PR may not be merged yet. Ask a validator to merge it. | 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 6c39aa9a1b4..9488f7a8fcd 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 @@ -154,6 +154,7 @@ Options: --fork fork the session when continuing (use with --continue or --session) [boolean] --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] ``` ## kilo run @@ -171,6 +172,7 @@ Options: -c, --continue continue the last session [boolean] -s, --session session id to continue [string] --fork fork the session before continuing (requires --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] --share share the session [boolean] -m, --model model to use in the format of provider/model [string] --agent agent to use [string] @@ -179,12 +181,13 @@ Options: --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] -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] - --auto auto-approve all permissions (for autonomous/pipeline usage) [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] ``` ## kilo debug @@ -202,6 +205,7 @@ Commands: kilo debug snapshot snapshot debugging utilities kilo debug startup print startup timing kilo debug agent show agent configuration details + kilo debug info show debug information kilo debug paths show global paths (data, config, cache, state) kilo debug wait wait indefinitely (for debugging) @@ -503,6 +507,16 @@ Options: --params Tool params as JSON or a JS object literal [string] ``` +### kilo debug info + +``` +show debug information + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + ### kilo debug paths ``` @@ -529,7 +543,7 @@ Options: manage AI providers and credentials Commands: - kilo auth list list providers [aliases: ls] + kilo auth list list providers and credentials [aliases: ls] kilo auth login [url] log in to a provider kilo auth logout log out from a configured provider @@ -541,7 +555,7 @@ Options: ### kilo auth list ``` -list providers +list providers and credentials Options: --help Show help [boolean] @@ -598,7 +612,7 @@ Options: --path directory path to generate the agent file [string] --description what the agent should do [string] --mode agent mode [string] [choices: "all", "primary", "subagent"] - --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, codesearch, lsp, skill" [string] + --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, websearch, lsp, skill" [string] -m, --model model to use in the format of provider/model [string] ``` @@ -670,6 +684,25 @@ Options: --refresh refresh the models cache from models.dev [boolean] ``` +## kilo roll-call + +``` +batch-test text models matching a filter for connectivity and latency + +Positionals: + filter regex to filter models by provider/modelID (required) [string] + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --prompt Prompt to send to each model [string] [default: "Hello"] + --timeout Timeout for each model call in milliseconds [number] [default: 25000] + --parallel Number of parallel model calls [number] [default: 5] + --verbose Show verbose output [boolean] [default: false] + --quiet Suppress progress and decoration [boolean] [default: false] + --output Output format (table, json, or md) [string] [choices: "table", "json", "md"] [default: "table"] +``` + ## kilo stats ``` 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 4990f11ee06..7a647f2e3e2 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -1,5 +1,5 @@ --- -title: "Kilo CLI" +title: "Kilo Code CLI: Run the AI Coding Agent from Your Terminal" description: "Using Kilo Code from the command line" platform: new --- @@ -8,7 +8,7 @@ platform: new This documentation applies only to Kilo version 1.0 and later. Users running versions below 1.0 should upgrade before proceeding. {% /callout %} -# Kilo CLI +# Kilo Code CLI: AI Coding Agent in Your Terminal Orchestrate agents from your terminal. Plan, debug, and code fast with keyboard-first navigation on the command line. @@ -346,6 +346,43 @@ Both keys also accept object configuration for specific tools or language server The TUI gives `Ctrl+Z` to input undo on Windows because native Windows terminals do not support POSIX terminal suspend. On Windows, `input_undo` defaults to `ctrl+z,ctrl+-,super+z` and `terminal_suspend` is disabled. On macOS and Linux, `terminal_suspend` defaults to `ctrl+z`. +#### Enabling Shift+Enter in Windows Terminal + +Some terminals don't send modifier keys with Enter by default. Windows Terminal requires a one-time configuration to forward `Shift+Enter` as an escape sequence that Kilo can read. + +Open your `settings.json` at: + +``` +%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json +``` + +Add this entry to the root-level `actions` array: + +```json +"actions": [ + { + "command": { + "action": "sendInput", + "input": "\u001b[13;2u" + }, + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Add this entry to the root-level `keybindings` array: + +```json +"keybindings": [ + { + "keys": "shift+enter", + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Save the file and restart Windows Terminal or open a new tab. `Shift+Enter` will now insert a newline in the Kilo prompt instead of submitting the message. + ### OpenTelemetry Export Kilo telemetry is enabled by default and can be disabled with `experimental.openTelemetry = false`: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/github.md b/packages/kilo-docs/pages/code-with-ai/platforms/github.md new file mode 100644 index 00000000000..d275c81d02c --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/github.md @@ -0,0 +1,133 @@ +--- +title: "GitHub" +description: "Using Kilo Code in GitHub issues, pull requests, and review comments" +--- + +# Kilo for GitHub + +**Kilo for GitHub** lets you interact with Kilo directly from your GitHub issues, pull requests, and review comments. Mention the bot and it will: + +- Read the surrounding context +- Access the repository +- Respond with answers, code reviews, or full implementations + +--- + +## What You Can Do + +### Ask questions on review comments + +When you're reviewing a pull request and want a second opinion on a piece of code: + +``` +@kilocode-bot is this true? +``` + +The bot reads the review comment, the surrounding diff, and the relevant code in the repository to give you an informed answer. + +{% image src="/docs/img/connect/github/github-review.png" alt="Asking @kilocode-bot a question on a GitHub pull request review comment" width="800" /%} + +### Fix issues directly from GitHub + +Tag the bot on any issue and ask it to handle the fix: + +``` +@kilocode-bot please fix +``` + +The bot will: + +- Read the issue description +- Analyze the relevant code +- Create a branch with the implementation +- Open a pull request + +{% image src="/docs/img/connect/github/github-issue.png" alt="Asking @kilocode-bot to fix a GitHub issue" width="800" /%} + +### Diagnose bug reports + +When a bug report comes in and you want to understand what's going on before diving in: + +``` +@kilocode-bot what could be the cause of this issue? +``` + +The bot examines the bug report, searches the codebase for related code paths, and shares its analysis directly in the issue thread. + +{% image src="/docs/img/connect/github/github-bug.png" alt="Asking @kilocode-bot to diagnose a bug report on a GitHub issue" width="800" /%} + +--- + +## How It Works + +1. **Mention `@kilocode-bot`** on an issue, pull request, or review comment +2. **Kilo reads the context** — the issue description, PR diff, review thread, and connected repository code +3. **A Cloud Agent spins up** to process the request +4. **Kilo responds** with an answer, analysis, or a pull request containing the implementation + +For implementation requests, the bot creates a new branch, commits the changes, and opens a PR back to the repository. You'll see the PR linked directly in the thread. + +--- + +## Prerequisites + +- A Kilo Code account with available credits +- Your GitHub integration configured via the Integrations tab at [app.kilo.ai](https://app.kilo.ai) +- The Kilo Code Bot GitHub App installed on the relevant repositories + +--- + +## Setup + +1. Go to [app.kilo.ai](https://app.kilo.ai) and navigate to the **Integrations** tab +2. Connect your GitHub account through the **KiloConnect** GitHub App if you haven't already +3. Install the **Kilo Code Bot** GitHub App on your repositories at [github.com/apps/kilo-code-bot](https://github.com/apps/kilo-code-bot/) + +Once installed, `@kilocode-bot` is available on any issue or PR in the repositories you've authorized. + +--- + +## Use Cases + +### Code Review Assistance + +During a PR review, mention the bot on any comment or line of code to get a quick analysis. The bot can: + +- Verify logic +- Suggest improvements +- Explain unfamiliar patterns + +### Issue Triage and Fixes + +When new issues come in, the bot can diagnose probable causes and, if you ask, implement a fix directly. This works best for straightforward bugs where the issue description gives enough context to act. + +### Bulk or Cross-Repo Changes + +Need to bump a dependency or apply a config change across your org? Mention the repos in a single comment and the bot handles each one. + +--- + +## Cost + +Kilo Code credits are consumed the same way as any other Kilo interface. Credit usage depends on the model selected and the complexity of the task. + +--- + +## Tips for Best Results + +- **Be specific about what you want.** "Please fix" works best on well-described issues. For vague issues, ask the bot to diagnose first. +- **Mention specific repositories** if the fix spans multiple repos. +- **Use on review comments** for quick, contextual questions during code review. + +--- + +## Troubleshooting + +**The bot isn't responding to mentions.** +Make sure the Kilo Code Bot GitHub App is installed on the repository and that you're mentioning `@kilocode-bot` (not `@kilo`). + +**The bot can't access the repository.** +Verify that both the KiloConnect App and the Kilo Code Bot App are installed and have access to the repository in question. + +**The implementation doesn't match what I expected.** +Try providing more context in the issue description or comment. The bot works best when the problem and desired outcome are clearly described. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md b/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md index e6abe34661f..a94b3cfc49f 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md @@ -1,9 +1,9 @@ --- -title: "JetBrains Extension" +title: "Kilo Code for JetBrains: Free Open-Source AI Coding Plugin" description: "Using Kilo Code in JetBrains IDEs" --- -# JetBrains Extension +# Kilo Code for JetBrains: Free AI Coding Plugin ## Installation 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 new file mode 100644 index 00000000000..afb8928db5b --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md @@ -0,0 +1,28 @@ +--- +title: "Kilo Connect" +description: "Use Kilo Code directly from Slack, GitHub, and Linear" +--- + +# 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. + +--- + +## Supported Integrations + +| Integration | Trigger | 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 | + +--- + +## How to Set Up + +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 diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/linear.md b/packages/kilo-docs/pages/code-with-ai/platforms/linear.md new file mode 100644 index 00000000000..52d3b15a2ed --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/linear.md @@ -0,0 +1,125 @@ +--- +title: "Linear" +description: "Using Kilo Code in Linear" +--- + +# Kilo for Linear + +**Kilo for Linear** connects Kilo Code to your Linear workspace, so you can trigger implementations, debug issues, and investigate bugs directly from your project management tool. Mention `@kilo` on any issue and the bot gets to work. + +--- + +## What You Can Do + +### Fix issues from Linear + +Tag the bot on any Linear issue and tell it to implement the fix: + +``` +@kilo please fix +``` + +The bot will: + +- Read the issue title, description, and comments +- Spin up a Cloud Agent to implement the solution +- Show a thinking/processing animation in Linear while it works +- Link the resulting pull request back to the issue + +{% image src="/docs/img/connect/linear/linear-fix-issue.png" alt="Asking @kilo to fix an issue in Linear" width="800" /%} + +### Apply changes across multiple repositories + +If a fix or upgrade needs to land in several repos at once: + +``` +@kilo please fix this in the cloud, landing, and handbook repos +``` + +The bot handles each repository independently, creating separate branches and pull requests for each. + +{% image src="/docs/img/connect/linear/linear-multi-repo.png" alt="Asking @kilo to apply changes across multiple repositories from Linear" width="800" /%} + +### Get help understanding an issue + +Before jumping into a fix, ask the bot to analyze the problem: + +``` +@kilo what could be the cause of this issue? +``` + +The bot examines the issue context and searches the connected codebase to surface likely causes. + +{% image src="/docs/img/connect/linear/linear-understand-issue.png" alt="Asking @kilo to analyze the cause of a Linear issue" width="800" /%} + +--- + +## How It Works + +1. **Mention `@kilo`** in a comment on a Linear issue +2. **Kilo reads the issue context** — title, description, labels, and comment thread +3. **A Cloud Agent spins up** to process the request (you'll see a thinking animation in Linear while this happens) +4. **Kilo responds** with an answer or opens a pull request with the implementation + +When Kilo creates a pull request, it links back to the Linear issue so everything stays connected. + +--- + +## Prerequisites + +- A Kilo Code account with available credits +- Your GitHub or GitLab integration configured via the Integrations tab at [app.kilo.ai](https://app.kilo.ai) +- Access to a Linear workspace where you can install integrations + +--- + +## Setup + +1. Go to [app.kilo.ai](https://app.kilo.ai) and navigate to the **Integrations** tab +2. Set up the **Linear** integration +3. Authorize Kilo to access your Linear workspace + +Once connected, `@kilo` is available as a mention in any issue across your Linear workspace. + +--- + +## Use Cases + +### Sprint Cleanup + +Have a backlog of well-described bugs? Tag the bot on each one and let it work through them while you focus on higher-priority work. + +### Cross-Repo Upgrades + +Need to upgrade a framework version or apply a config change across several services? Create a single Linear issue describing the change, then tell the bot which repos to update. + +### Issue Investigation + +When a bug report is unclear or the root cause isn't obvious, ask the bot to analyze the issue before committing to a fix. It can search the codebase and surface likely problem areas. + +--- + +## Cost + +Kilo Code credits are consumed the same way as any other Kilo interface. Credit usage depends on the model selected and the complexity of the task. + +--- + +## Tips for Best Results + +- **Write clear issue descriptions.** The bot works best when the issue title and description give enough context to understand the problem and the expected outcome. +- **Mention specific repositories** if the change needs to land in more than one. +- **Start with diagnosis for ambiguous issues.** Ask the bot to analyze the issue before asking it to fix. + +--- + +## Troubleshooting + +**The bot isn't responding to mentions.** +Confirm that the Linear integration is set up in the Integrations tab at [app.kilo.ai](https://app.kilo.ai) and that the bot has access to your workspace. + +**The bot can't access the repository.** +Make sure your GitHub or GitLab integration is configured and that the relevant repositories are authorized. + +**The pull request doesn't match what I expected.** +Provide more detail in the issue description, or comment with additional context before asking the bot to fix. 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 76abc8decb2..ec23078ad6e 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md @@ -5,20 +5,44 @@ description: "Using Kilo Code on iOS and Android" # Mobile Apps -Kilo Code is coming to mobile! Soon you'll be able to use Kilo Code's powerful AI coding capabilities directly from your iOS or Android device. +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="Coming Soon" %} -Mobile apps for iOS and Android are currently in development. Sign up to be notified when they launch! +{% callout type="info" title="Android app available now" %} +Install Kilo Code for Android from [Google Play](https://kilocode.onelink.me/ZzZZ/r10ni4zy). {% /callout %} -## iOS App +## What you can do -The Kilo Code iOS app will bring AI-powered coding assistance to your iPhone and iPad. +The mobile app lets you: -[Learn more about the iOS app →](https://kilo.ai/features/ios-app) +- View and manage Kilo Code sessions, including remote CLI and extension sessions running on your local machine. +- Chat with KiloClaw from your phone. +- Spawn Cloud Agents and code directly from the app. +- Monitor and view all non-remote sessions in one place. +- Create, onboard, and manage KiloClaw instances. + +{% 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." /%} + +{% image src="/docs/img/mobile-apps/new-session.webp" alt="Kilo Code mobile new session screen with coding mode selector" caption="Create a new Cloud Agent session and choose the right mode for the task." /%} + +{% image src="/docs/img/mobile-apps/session-chat.webp" alt="Kilo Code mobile session chat with an active coding task" caption="Review progress and continue coding conversations from the mobile app." /%} +{% /imageGallery %} + +{% imageGallery columns="2" width="220px" %} +{% image src="/docs/img/mobile-apps/kiloclaw-chat.webp" alt="KiloClaw chat in the Kilo Code mobile app" caption="Chat with KiloClaw on mobile." /%} + +{% image src="/docs/img/mobile-apps/session-filters.webp" alt="Kilo Code mobile session filter panel for Cloud Extension CLI Slack and other platforms" caption="Filter sessions by platform and project, including Cloud, Extension, CLI, Slack, and other sessions." /%} +{% /imageGallery %} ## Android App -The Kilo Code Android app will let you code with AI assistance on your Android phone or tablet. +The Android app is available now on Google Play. -[Learn more about the Android app →](https://kilo.ai/features/android-app) +[Install the Android app →](https://kilocode.onelink.me/ZzZZ/r10ni4zy) + +## iOS App + +The iOS app is in review with the App Store team and will be available soon. You can already sign up for the iOS waitlist to be notified when it launches. + +[Join the iOS app waitlist →](https://kilo.ai/features/ios-app) diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md index 401b7751202..20691d8c381 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md @@ -5,50 +5,79 @@ description: "Using Kilo Code in Slack" # Kilo for Slack -Kilo for Slack brings the power of Kilo Code directly into your Slack workspace. Ask questions about your repositories, request code implementations, or get help with issues—all without leaving Slack. +**Kilo for Slack** brings Kilo Code directly into your Slack workspace. Ask questions about your repositories, request code implementations, debug issues, and collaborate with your team — all without leaving Slack. + +When you mention `@Kilo` in a thread, the bot: + +- Reads the full conversation +- Accesses your connected repositories +- Answers your question or spins up a Cloud Agent to implement the change +- Creates branches, pushes commits, and opens pull requests on your behalf --- -## What You Can Do With Kilo for Slack +## What You Can Do -- **Ask questions about your repositories** — Get explanations about code, architecture, or implementation details -- **Request code implementations** — Tell the bot to implement fixes or features suggested in Slack threads -- **Get help with debugging** — Share error messages or issues and get AI-powered assistance -- **Collaborate with your team** — Mention the bot in any channel to get help in context +### Ask questions about your codebase + +``` +@Kilo what does the UserService class do in our main backend repo? +``` + +``` +@Kilo how is error handling implemented in the payment processing module? +``` + +{% image src="/docs/img/connect/slack/slackbot-ask-questions.webp" alt="Asking Kilo a question about the codebase in Slack" width="800" /%} + +### Implement fixes and features from Slack discussions + +When your team identifies a bug or improvement in a thread, ask the bot to handle it: + +``` +@Kilo based on this thread, can you implement the fix for the null pointer exception in the order processing service? +``` + +The bot will: + +- Read the thread context +- Understand the proposed solution +- Create a branch with the implementation +- Push a pull request to your repository + +{% image src="/docs/img/connect/slack/slackbot-turn-discussions-into-PRs.webp" alt="Kilo turning a Slack thread discussion into a pull request" width="800" /%} + +### Implement changes across multiple repositories + +If the same change needs to land in several repos, just tell the bot: + +``` +@Kilo please fix this in the cloud, landing, and handbook repos +``` + +{% image src="/docs/img/connect/slack/slackbot-coding.webp" alt="Kilo implementing changes across multiple repositories from Slack" width="800" /%} + +### Debug issues + +Paste an error message or stack trace and ask for help: + +``` +@Kilo I'm seeing this error in production: +[paste error message] +Can you help me understand what's causing it? +``` + +{% image src="/docs/img/connect/slack/slackbot-bugs.webp" alt="Kilo helping debug a production error in Slack" width="800" /%} --- -## Supported Platforms - -| Platform | Integration Type | Details | -|---|---|---| -| GitHub | GitHub App | [GitHub Setup Guide](/docs/automate/integrations#connecting-github) | -| GitLab | OAuth or PAT | [GitLab Setup Guide](/docs/automate/integrations#connecting-gitlab) | - ---- - -## Prerequisites - -Before using Kilo for Slack: - -- You must have a **Kilo Code account** with available credits -- Your **Git provider integration must be configured** via the [Integrations tab](https://app.kilo.ai/integrations) so Kilo can access your repositories - -To install Kilo for Slack, simply go to the integrations menu in the sidebar on https://app.kilo.ai and set up the Slack integration. - ---- - -## How to Interact with Kilo +## How to Interact ### Direct Messages -You can message Kilo directly through Slack DMs for private conversations: +You can DM Kilo directly for private conversations. Find Kilo in your workspace's app list and start a direct message. -1. Find **Kilo** in your Slack workspace's app list -2. Start a direct message conversation -3. Ask your question or describe what you need - -This is ideal for: +Good for: - Private questions about your code - Sensitive debugging sessions @@ -62,114 +91,80 @@ Mention the bot in any channel where it's been added: @Kilo can you explain how the authentication flow works in our backend? ``` -This is great for: +Good for: - Team discussions where AI assistance would help -- Collaborative debugging sessions +- Collaborative debugging - Getting quick answers during code reviews --- -## Use Cases +## Prerequisites -### Ask Questions About Your Repositories +Before using Kilo for Slack: -Get instant answers about your codebase without switching contexts: +- You need a Kilo Code account with available credits +- Your Git provider integration (GitHub or GitLab) must be configured via the Integrations tab at [app.kilo.ai](https://app.kilo.ai) so Kilo can access your repositories -``` -@Kilo what does the UserService class do in our main backend repo? -``` +--- -``` -@Kilo how is error handling implemented in the payment processing module? -``` +## Setup -### Implement Fixes from Slack Discussions +To install Kilo for Slack, go to the Integrations menu in the sidebar at [app.kilo.ai](https://app.kilo.ai) and set up the Slack integration. -When your team identifies an issue or improvement in a Slack thread, ask the bot to implement it: - -``` -@Kilo based on this thread, can you implement the fix for the null pointer exception in the order processing service? -``` - -The bot can: - -- Read the context from the thread -- Understand the proposed solution -- Create a branch with the implementation -- Push the changes to your repository - -### Debug Issues - -Share error messages or stack traces and get help: - -``` -@Kilo I'm seeing this error in production: -[paste error message] -Can you help me understand what's causing it? -``` +| Platform | Integration Type | Details | +|---|---|---| +| GitHub | GitHub App | [GitHub Setup Guide](/docs/automate/integrations#connecting-github) | +| GitLab | OAuth or PAT | [GitLab Setup Guide](/docs/automate/integrations#connecting-gitlab) | --- ## How It Works -1. **Message Kilo** — Either through DMs or by mentioning it in a channel -2. **Kilo processes your request** — Kilo uses your connected repositories to understand context -3. **AI generates a response** — Kilo Code's AI analyzes your request and provides helpful responses -4. **Code changes (if requested)** — For implementation requests, Kilo can create pull or merge requests - ---- - -## Cost - -- **Kilo Code credits are used** when Kilo performs work (model usage, operations, etc.) -- Credit usage is similar to using Kilo Code through other interfaces - ---- - -## Tips for Best Results - -- **Be specific** — The more context you provide, the better the response -- **Reference specific files or functions** — Help the bot understand exactly what you're asking about -- **Use threads** — Keep related conversations in threads for better context -- **Specify the repository** — If you have multiple repos connected, mention which one you're asking about - ---- - -## Limitations - -- Kilo can only access repositories you've connected through the [Integrations](https://app.kilo.ai/integrations) page -- Complex multi-step implementations may require follow-up messages -- Response times may vary based on the complexity of your request +1. **Message Kilo** through a DM or channel mention +2. **Kilo processes your request** using your connected repositories for context +3. **AI generates a response** analyzing your request and providing an answer +4. **Code changes (if requested)** — Kilo creates a branch, commits the changes, and opens a pull or merge request --- ## Changing the Model -You can customize which AI model Kilo uses for generating responses. The model affects the quality, speed, and capabilities of Kilo's responses. +You can customize which AI model Kilo uses for generating responses. -1. Go to your [Kilo Workspace](https://app.kilo.ai/) -2. Navigate to **Integrations** > **Slack** -3. Select your preferred model for Kilo for Slack +1. Go to your Kilo Workspace at [app.kilo.ai](https://app.kilo.ai) +2. Navigate to **Integrations > Slack** +3. Select your preferred model -Kilo will start using the new model immediately for subsequent requests. +Kilo for Slack supports 400+ models across different providers. The new model applies immediately to subsequent requests. -### Available Models +--- -Kilo for Slack supports over 400+ models across different providers. +## Cost + +Kilo Code credits are used when Kilo performs work (model usage, operations, etc.). Credit usage is the same as using Kilo through any other interface. + +--- + +## Tips for Best Results + +- **Be specific.** The more context you provide, the better the response. +- **Reference specific files or functions.** Help the bot understand exactly what you're asking about. +- **Use threads.** Keep related conversations in threads for better context. +- **Specify the repository.** If you have multiple repos connected, mention which one you're asking about. --- ## Troubleshooting -**"Kilo isn't responding."** -Ensure Kilo for Slack is installed in your workspace and has been added to the channel you're using. +**Kilo isn't responding.** +Make sure Kilo for Slack is installed in your workspace and has been added to the channel you're using. -**"Kilo can't access my repository."** -Verify your Git provider integration is configured correctly in the [Integrations tab](https://app.kilo.ai/integrations). +**Kilo can't access my repository.** +Verify your Git provider integration is configured correctly in the Integrations tab. -**"I'm getting incomplete responses."** +**I'm getting incomplete responses.** Try breaking your request into smaller, more specific questions. -**"Kilo doesn't understand my codebase."** -Make sure the repository you're asking about is connected and accessible through your Git provider integration. +**Kilo doesn't understand my codebase.** +Confirm that the repository you're asking about is connected and accessible through your Git provider integration. 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 e539edf26b4..9c3cfb5c07d 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 @@ -1,9 +1,9 @@ --- -title: "VS Code Extension" +title: "Kilo Code for VS Code: Free Open-Source AI Coding Extension" description: "Using Kilo Code in Visual Studio Code" --- -# VS Code Extension +# Kilo Code for VS Code Kilo Code is available as two VS Code extensions: the **VSCode (Legacy)** extension and the current **VSCode** version built on Kilo's shared agent runtime. @@ -33,6 +33,7 @@ Key features include: - **[Skills](/docs/customize/skills)** — Load specialized domain knowledge from SKILL.md files - **[Custom Subagents](/docs/customize/custom-subagents)** — Define specialized sub-agents for the `task` tool - **Open in Tab** — Pop the chat out into a full editor tab +- **Transcript export:** Save complete local session transcripts as Markdown files - **Sub-Agent Viewer** — Read-only panels for viewing child agent sessions - **Legacy Migration** — Automatic migration wizard for VSCode extension settings diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index e956eccaa3a..043f65a4b10 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -96,10 +96,9 @@ Auto: Small does not appear in the model picker. It is filtered out by the UI (s ### Defaults -- **Authenticated users**: Default to `kilo-auto/balanced` (defined in `packages/kilo-gateway/src/api/constants.ts`) -- **Unauthenticated users**: Default to `kilo-auto/free` +- **All new users**: Default to `kilo-auto/free` (defined in `packages/kilo-gateway/src/api/constants.ts`) -This means a brand-new user who hasn't signed in gets a working experience immediately — no model selection required. +This means a brand-new user gets a working experience immediately — no model selection or credits required. ### What users see @@ -167,7 +166,7 @@ The client-side chain works as follows: | 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 (Balanced for paid, Free for unpaid) so most users never need to actively choose. | +| 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 diff --git a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md index b638dba13d2..834906dc84e 100644 --- a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md +++ b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md @@ -24,7 +24,7 @@ Add a thumbs-up / thumbs-down widget next to the existing copy button on every a | Surface | Approach | |---|---| | VS Code extension | Thumbs buttons inline next to the copy button | -| TUI | Keybinds (`+` / `-`) on the last assistant message | +| TUI | Keybinds (`=` / `-`) on the last assistant message | ### Telemetry Payload diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index 9993b4bcca3..c5b173a3e09 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -36,10 +36,12 @@ 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 — and compares it to the model's context window. Compaction runs when the total fills the window minus a reserved buffer of headroom kept free for the next turn. +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. 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. +`compaction.threshold_percent` is optional. Set it from `1` to `100` to compact at that percentage of the model input or context window. + Custom models that do not declare a context window are not tracked, and auto-compaction does not run for them. ### Context Pruning @@ -59,10 +61,11 @@ You can trigger compaction at any time: | Setting | Default | Effect | |---|---|---| | `compaction.auto` | `true` | Automatically compact when the usable window is reached | +| `compaction.threshold_percent` | unset | Compact when token usage reaches this percentage of the model window | | `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | | `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | | `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | -| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | +| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn, and a safety trigger if reached before the threshold | ## Configuration @@ -72,6 +75,7 @@ Compaction is configured in your `kilo.jsonc` file: { "compaction": { "auto": true, // Enable or disable automatic compaction + "threshold_percent": 80, // Optional trigger at 80% of the model window "prune": true, // Enable pruning of old tool outputs beyond the recency window "tail_turns": 2, // Recent user turns to keep verbatim during compaction "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail @@ -83,6 +87,7 @@ Compaction is configured in your `kilo.jsonc` file: | Option | Type | Default | Description | |---|---|---|---| | `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | +| `compaction.threshold_percent` | number | unset | Optional percentage from 1 to 100. Auto-compaction runs when token usage reaches this share of the model input or context window, unless the reserved safety buffer triggers first. | | `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | | `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | | `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | @@ -131,10 +136,12 @@ 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 — and compares it to the model's context window. Compaction runs when the total fills the window minus a reserved buffer of headroom kept free for the next turn. +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. 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. +`compaction.threshold_percent` is optional. Set it from `1` to `100` to compact at that percentage of the model input or context window. + [Custom models](/docs/code-with-ai/agents/custom-models) that do not declare a context window are not tracked, and auto-compaction does not run for them. ### Context Pruning @@ -153,10 +160,11 @@ You can trigger compaction at any time: | Setting | Default | Effect | |---|---|---| | `compaction.auto` | `true` | Automatically compact when the usable window is reached | +| `compaction.threshold_percent` | unset | Compact when token usage reaches this percentage of the model window | | `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | | `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | | `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | -| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | +| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn, and a safety trigger if reached before the threshold | ## Configuration @@ -166,6 +174,7 @@ Compaction is configured in your `kilo.jsonc` file: { "compaction": { "auto": true, // Enable or disable automatic compaction + "threshold_percent": 80, // Optional trigger at 80% of the model window "prune": true, // Enable pruning of old tool outputs beyond the recency window "tail_turns": 2, // Recent user turns to keep verbatim during compaction "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail @@ -177,6 +186,7 @@ Compaction is configured in your `kilo.jsonc` file: | Option | Type | Default | Description | |---|---|---|---| | `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | +| `compaction.threshold_percent` | number | unset | Optional percentage from 1 to 100. Auto-compaction runs when token usage reaches this share of the model input or context window, unless the reserved safety buffer triggers first. | | `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | | `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | | `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | @@ -284,7 +294,11 @@ If the condensed summary doesn't capture important details: - **Before major transitions**: When switching to a different aspect of your project - **When approaching limits**: Run `/compact` manually before hitting the automatic trigger if you want control over _when_ the summary is produced -### Tuning `compaction.reserved` +### Tuning compaction triggers + +Use `compaction.threshold_percent` when you want compaction to happen at a predictable share of the model window, such as `80` for earlier summaries on long tasks. + +The reserved safety buffer still applies and can trigger compaction earlier than the percentage threshold. On models that advertise a separate input limit, the `reserved` value is a trade-off: diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index 18ae062d6b5..52c70ae77c7 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -24,6 +24,7 @@ Use your provider API key to route matching models through your account: - Anthropic - AWS Bedrock +- DeepSeek - Fireworks - Google AI Studio - Inception diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index a947eebab69..f9645eca76c 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -198,10 +198,21 @@ 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: + +```json +{ + "experimental": { + "speech_to_text": true + } +} +``` + Advanced options not exposed in the UI can be configured via the `experimental` key in `kilo.jsonc`: ```json diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index 61452757875..f95b39d715f 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react" +import Head from "next/head" import Link from "next/link" // Terminal content for each tab @@ -236,6 +237,9 @@ export default function HomePage() { return (
+ + Kilo Code Docs: Setup, Models, MCP, Custom Modes & CLI + {/* Dotted background pattern */}
diff --git a/packages/kilo-docs/pages/kiloclaw/dashboard.md b/packages/kilo-docs/pages/kiloclaw/dashboard.md index 0d6a388a5c7..6661aa069ba 100644 --- a/packages/kilo-docs/pages/kiloclaw/dashboard.md +++ b/packages/kilo-docs/pages/kiloclaw/dashboard.md @@ -189,10 +189,6 @@ Each instance runs on a dedicated machine — there is no shared infrastructure Your storage is region-pinned — once your instance is created in a region (e.g., DFW), it always runs there. OpenClaw config lives at `/root/.openclaw` and the workspace at `/root/clawd`. -{% callout type="info" %} -These are the beta specifications for machines and subject to change without notice. -{% /callout %} - ## Related - [KiloClaw Overview](/docs/kiloclaw/overview) diff --git a/packages/kilo-docs/pages/kiloclaw/faq/pricing.md b/packages/kilo-docs/pages/kiloclaw/faq/pricing.md index e63d9aa99b2..949aa62ffeb 100644 --- a/packages/kilo-docs/pages/kiloclaw/faq/pricing.md +++ b/packages/kilo-docs/pages/kiloclaw/faq/pricing.md @@ -9,10 +9,7 @@ KiloClaw uses Kilo Gateway credits by default — if you route requests through ## Instance Hosting -KiloClaw hosting is **free during the beta period**. Each user gets a dedicated machine (2 shared vCPUs, 3 GB RAM, 10 GB SSD) at no cost. - -> ℹ️ **Info** -> Beta pricing is subject to change. Paid hosting tiers may be introduced after the beta period ends. Any changes will be announced in advance. +Each user gets a dedicated machine (2 shared vCPUs, 3 GB RAM, 10 GB SSD). Visit [kilo.ai/pricing](https://kilo.ai/pricing) for current pricing and plans. ## Model Inference diff --git a/packages/kilo-docs/pages/kiloclaw/overview.md b/packages/kilo-docs/pages/kiloclaw/overview.md index 19d8b695913..7a1c518d10d 100644 --- a/packages/kilo-docs/pages/kiloclaw/overview.md +++ b/packages/kilo-docs/pages/kiloclaw/overview.md @@ -7,7 +7,7 @@ description: "One-click deployment of your personal AI agent with OpenClaw" 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 powered by KiloCode. The API key is platform-managed, so you never need to bring your own. KiloClaw is currently in **Beta**. +KiloClaw is powered by KiloCode. The API key is platform-managed, so you never need to bring your own. ## Why KiloClaw? diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index 44fe2ff0fdb..1d872b63f5d 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -865,4 +865,10 @@ module.exports = [ basePath: false, permanent: true, }, + { + source: "/docs/code-with-ai/gastown/wasteland/troubleshooting", + destination: "/docs/code-with-ai/gastown/wasteland", + basePath: false, + permanent: true, + }, ] diff --git a/packages/kilo-docs/public/img/connect/github/github-bug.png b/packages/kilo-docs/public/img/connect/github/github-bug.png new file mode 100644 index 00000000000..1095aa4ab4d Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-bug.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-issue.png b/packages/kilo-docs/public/img/connect/github/github-issue.png new file mode 100644 index 00000000000..092ce869d21 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-review.png b/packages/kilo-docs/public/img/connect/github/github-review.png new file mode 100644 index 00000000000..cea6ea000cb Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-review.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png b/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png new file mode 100644 index 00000000000..b4862a05703 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png b/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png new file mode 100644 index 00000000000..13555708901 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png b/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png new file mode 100644 index 00000000000..ec6743a2436 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-ask-questions.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-ask-questions.webp new file mode 100644 index 00000000000..74920beddab Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-ask-questions.webp differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-bugs.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-bugs.webp new file mode 100644 index 00000000000..9efc3e80496 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-bugs.webp differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-coding.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-coding.webp new file mode 100644 index 00000000000..cc2f4104a8a Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-coding.webp differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-turn-discussions-into-PRs.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-turn-discussions-into-PRs.webp new file mode 100644 index 00000000000..64670c4563b Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-turn-discussions-into-PRs.webp differ diff --git a/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png b/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png new file mode 100644 index 00000000000..01722c3f1fe Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-beads-page.png b/packages/kilo-docs/public/img/gastown/gt-beads-page.png new file mode 100644 index 00000000000..baed8ac1a15 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-beads-page.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png new file mode 100644 index 00000000000..349af417c87 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png new file mode 100644 index 00000000000..368f8ac3255 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-new-rig.png b/packages/kilo-docs/public/img/gastown/gt-new-rig.png new file mode 100644 index 00000000000..7cb3444e535 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-new-rig.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png b/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png new file mode 100644 index 00000000000..d1a3e6ac8f2 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png new file mode 100644 index 00000000000..210e5aaa674 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png new file mode 100644 index 00000000000..8880690dc1e Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png new file mode 100644 index 00000000000..43dae48328f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png new file mode 100644 index 00000000000..ec185145d02 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png new file mode 100644 index 00000000000..fdb9f9251fe Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-town-overview.png b/packages/kilo-docs/public/img/gastown/gt-town-overview.png new file mode 100644 index 00000000000..12306817e5a Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-town-overview.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png new file mode 100644 index 00000000000..f3f25d6fb05 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png new file mode 100644 index 00000000000..d74355c16bb Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png new file mode 100644 index 00000000000..0f4f4b90fb0 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png new file mode 100644 index 00000000000..1d2a1008d8f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png new file mode 100644 index 00000000000..76b44beef80 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png new file mode 100644 index 00000000000..12306817e5a Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png new file mode 100644 index 00000000000..394655e9515 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png new file mode 100644 index 00000000000..0d25c696244 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png new file mode 100644 index 00000000000..2112b4c3279 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png new file mode 100644 index 00000000000..60218619367 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png differ diff --git a/packages/kilo-docs/public/img/mobile-apps/home.webp b/packages/kilo-docs/public/img/mobile-apps/home.webp new file mode 100644 index 00000000000..8df94c62ca5 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/home.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp b/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp new file mode 100644 index 00000000000..1df99e23e20 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/new-session.webp b/packages/kilo-docs/public/img/mobile-apps/new-session.webp new file mode 100644 index 00000000000..e8ca7dda341 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/new-session.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/session-chat.webp b/packages/kilo-docs/public/img/mobile-apps/session-chat.webp new file mode 100644 index 00000000000..26a9b82adf6 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/session-chat.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/session-filters.webp b/packages/kilo-docs/public/img/mobile-apps/session-filters.webp new file mode 100644 index 00000000000..a0f684b9cd6 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/session-filters.webp differ diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-collapse-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-collapse-all-button-chromium-linux.png new file mode 100644 index 00000000000..38e00a5a776 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-collapse-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85c08e55bbd0b2c4bd77ed868fde4d6dac03ffcbb41c6103a8a48fd724875e79 +size 3251 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-expand-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-expand-all-button-chromium-linux.png new file mode 100644 index 00000000000..46f4de3628a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-expand-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:200794b3b5539cd5ef4977f315214644d4e038766b48f5fdf0a86f735a276bcd +size 3076 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-collapse-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-collapse-all-button-chromium-linux.png new file mode 100644 index 00000000000..ec8047255e6 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-collapse-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd92f927ee977559db9f2a9cafd19f7f8ec88542c2d5e46ce5ecb2bd11ab6ad4 +size 1682 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-expand-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-expand-all-button-chromium-linux.png new file mode 100644 index 00000000000..17b16021774 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-expand-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4f037c58f94e856a1093e914380efe29b5f1f59aebbc8247df4a434575bd4ab +size 1714 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 bc4bc545585..6ab79b4689a 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:ee8d8e04b09b58973a1ff66d5a3d8648ba590f88312cf1f37412c39ccdac0480 -size 3973 +oid sha256:b7ec87f1e046cf1a44688b952c0d994899119c6663bf627026688bf337b56127 +size 2411 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 999c6b2f9e9..905e28d6b85 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:f0e9ea1be8fbb44f05bed7960d1ba13f0d4c5e904e4025e4ab4743c4991e93c8 -size 4696 +oid sha256:7ee0856c65e445a230cf3e94bfe592c98306b7502e44a2602b5fac2f6d69937a +size 4601 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 0760abd4b86..83fda13fbb4 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:66a7ecedac685e17c442bb036d7392320e0642ef43da77ed8dc23be29bc7988e -size 2935 +oid sha256:f1ee2d880a055b03664d594598a6e8764ab27d443a578572fd01b37451cb0193 +size 3193 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 ff195b4d918..edc40570888 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:c3f37193389ad7937c7b2103ad2fe3dd63123a0d4f60a0fbfd65b31d3982c092 -size 12105 +oid sha256:dbaab3a1ff10eac8f710b0ed8ddc99d96188c06a09c227e634ca712bcee12130 +size 11543 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png index f1e6bc8a35b..da291495f58 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d34153690f8aa4ef7e06e12385a184c151843f3ae75a9955619a6d7e88dc8c8b -size 7981 +oid sha256:22cfec1cfdfe2deb4299f62bd677eeee34b522c581504297fd356efbf26e9b44 +size 6351 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 4d612a7ad03..ddcba78b314 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:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9 -size 17677 +oid sha256:7cabfff215cef485ec913b9a082aaac844275e506e88ba0fab112d95ff6de329 +size 18200 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 04959213872..d341a7a2c72 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:0d29bb3a9356cf8bec6bb0a9d6e4d0b59d147c1fa8adee7140c81a061eea407d -size 6930 +oid sha256:067a5c757dfa32d27955dd634b8dea52edef2141111a5eecee0161df36c0ed8b +size 7061 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 d7d21c928f4..889daa6e724 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:483c04840175e7678f0cfd1eba377ec199239404acd567fd7778367f4d2cb46c -size 21219 +oid sha256:a1ca383c2a5fa9d6f477430d7c63c7330e13e2667bf717906d3c29c215f163c5 +size 21564 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 2283986b267..18c542093c0 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:f2130db1ea14a5e8fbf879e96529198592a785b8460d29e12746175e102d50d5 -size 53617 +oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856 +size 57421 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 new file mode 100644 index 00000000000..e395b8b062d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdcac95e7969d021ebce19001f37f86b8ab670bd79e39086385c9419617294df +size 27177 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6a04ffae85b..bf6b1ebceef 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -30,6 +30,8 @@ - +- + - - @@ -42,8 +44,6 @@ - -- - - - @@ -62,8 +62,13 @@ - +- + +- + - + - - @@ -79,18 +84,20 @@ - + - + - - +- + - -- - - - @@ -109,7 +116,6 @@ - - - - @@ -177,3 +183,9 @@ - +- + +- + +- + diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index c75789b1dc0..9fa8ca24e9f 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.2.40", + "version": "7.3.3", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 75b518f1b24..8c556af15f0 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -12,6 +12,24 @@ export const DEFAULT_KILO_API_URL = "https://api.kilo.ai" /** Base URL for Kilo API - can be overridden by KILO_API_URL env var */ export const KILO_API_BASE = process.env[ENV_KILO_API_URL] || DEFAULT_KILO_API_URL +/** Environment variable for custom Kilo Chat URL */ +export const KILO_CHAT_URL_ENV = "KILO_CHAT_URL" + +/** Default Kilo Chat URL (REST endpoint for messages, conversations, etc.) */ +export const KILO_DEFAULT_CHAT_URL = "https://chat.kiloapps.io" + +/** Base URL for Kilo Chat - can be overridden by KILO_CHAT_URL env var */ +export const KILO_CHAT_URL = process.env[KILO_CHAT_URL_ENV] || KILO_DEFAULT_CHAT_URL + +/** Environment variable for custom Event Service URL */ +export const KILO_EVENT_SERVICE_URL_ENV = "EVENT_SERVICE_URL" + +/** Default Event Service URL (WebSocket endpoint for kilo-chat events) */ +export const KILO_DEFAULT_EVENT_SERVICE_URL = "wss://events.kiloapps.io" + +/** Base URL for Event Service - can be overridden by EVENT_SERVICE_URL env var */ +export const KILO_EVENT_SERVICE_URL = process.env[KILO_EVENT_SERVICE_URL_ENV] || KILO_DEFAULT_EVENT_SERVICE_URL + /** Default base URL for OpenRouter-compatible endpoint */ export const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter` @@ -19,7 +37,7 @@ export const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter` export const POLL_INTERVAL_MS = 3000 /** Default model for authenticated users */ -export const DEFAULT_MODEL = "kilo-auto/balanced" +export const DEFAULT_MODEL = "kilo-auto/free" /** Default model for anonymous/free usage */ export const DEFAULT_FREE_MODEL = "kilo-auto/free" @@ -47,6 +65,7 @@ export const MODELS_FETCH_TIMEOUT_MS = 10 * 1000 */ export const HEADER_ORGANIZATIONID = "X-KILOCODE-ORGANIZATIONID" export const HEADER_TASKID = "X-KILOCODE-TASKID" +export const HEADER_PARENT_TASKID = "X-KILOCODE-PARENT-TASKID" export const HEADER_PROJECTID = "X-KILOCODE-PROJECTID" export const HEADER_TESTER = "X-KILOCODE-TESTER" export const HEADER_EDITORNAME = "X-KILOCODE-EDITORNAME" diff --git a/packages/kilo-gateway/src/api/embedding-models.ts b/packages/kilo-gateway/src/api/embedding-models.ts new file mode 100644 index 00000000000..475d8d25249 --- /dev/null +++ b/packages/kilo-gateway/src/api/embedding-models.ts @@ -0,0 +1,63 @@ +import { z } from "zod" +import { resolveKiloGatewayBaseUrl } from "./url.js" + +export type KiloEmbeddingModel = { + id: string + name: string + dimension: number + scoreThreshold: number + note?: string +} + +export type KiloEmbeddingModelCatalog = { + defaultModel: string + models: KiloEmbeddingModel[] + aliases: Record +} + +export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = { + defaultModel: "", + models: [], + aliases: {}, +} + +const model = z.object({ + id: z.string(), + name: z.string(), + dimension: z.number().int().positive(), + scoreThreshold: z.number(), + note: z.string().optional(), +}) + +const catalog = z.object({ + defaultModel: z.string(), + models: z.array(model), + aliases: z.record(z.string(), z.string()), +}) + +type Options = { + baseURL?: string + token?: string + signal?: AbortSignal +} + +export async function fetchKiloEmbeddingModelCatalog(options: Options = {}): Promise { + const url = new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: options.baseURL, token: options.token })) + + 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 + } + 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 + } +} diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8712f3d47bd..a251eb47d5c 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -3,6 +3,11 @@ import { getKiloUrlFromToken } from "../auth/token.js" import { getDefaultHeaders, buildKiloHeaders } from "../headers.js" import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js" +export type KiloModelsResult = { + models: Record + error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } +} + /** * OpenRouter model schema */ @@ -63,13 +68,13 @@ function parseApiPrice(price: string | null | undefined): number | undefined { * Fetch models from Kilo API (OpenRouter-compatible endpoint) * * @param options - Configuration options - * @returns Record of models in ModelsDev.Model format + * @returns Typed result with models and optional error info */ export async function fetchKiloModels(options?: { kilocodeToken?: string kilocodeOrganizationId?: string baseURL?: string -}): Promise> { +}): Promise { const token = options?.kilocodeToken const organizationId = options?.kilocodeOrganizationId @@ -84,54 +89,60 @@ export async function fetchKiloModels(options?: { // Construct models endpoint const modelsURL = `${finalBaseURL}/models` - try { - // Fetch models with timeout - const response = await fetch(modelsURL, { - headers: { - ...getDefaultHeaders(), - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), - }) + const response = await fetch(modelsURL, { + headers: { + ...getDefaultHeaders(), + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), + }).catch((err: unknown) => err as Error) - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`) - } - - const json = await response.json() - - // Validate response schema - const result = openRouterModelsResponseSchema.safeParse(json) - - if (!result.success) { - console.error("Kilo models response validation failed:", result.error.format()) - return {} - } - - // Transform models to ModelsDev.Model format - const models: Record = {} - - for (const model of result.data.data) { - // Skip image generation models - if (model.architecture?.output_modalities?.includes("image")) { - continue - } - - // Skip models that don't support tools — Kilo requires tool calling - if (!model.supported_parameters?.includes("tools")) { - continue - } - - const transformedModel = transformToModelDevFormat(model) - models[model.id] = transformedModel - } - - return models - } catch (error) { - console.error("Error fetching Kilo models:", error) - return {} + if (response instanceof Error) { + return { models: {}, error: { kind: "network" } } } + + if (!response.ok) { + // 401 with auth credentials: fall back to unauthenticated public endpoint + if (response.status === 401 && (token || organizationId)) { + return fetchKiloModels({}) + } + const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" + return { models: {}, error: { kind, status: response.status } } + } + + const json = await response.json().catch(() => null) + + if (json === null) { + return { models: {}, error: { kind: "schema" } } + } + + // Validate response schema + const result = openRouterModelsResponseSchema.safeParse(json) + + if (!result.success) { + return { models: {}, error: { kind: "schema" } } + } + + // Transform models to ModelsDev.Model format + const models: Record = {} + + for (const model of result.data.data) { + // Skip image generation models + if (model.architecture?.output_modalities?.includes("image")) { + continue + } + + // Skip models that don't support tools — Kilo requires tool calling + if (!model.supported_parameters?.includes("tools")) { + continue + } + + const transformedModel = transformToModelDevFormat(model) + models[model.id] = transformedModel + } + + return { models } } /** diff --git a/packages/kilo-gateway/src/api/modes.ts b/packages/kilo-gateway/src/api/modes.ts index 85493804c52..ac67d8332d1 100644 --- a/packages/kilo-gateway/src/api/modes.ts +++ b/packages/kilo-gateway/src/api/modes.ts @@ -77,7 +77,6 @@ export async function fetchOrganizationModes(token: string, organizationId: stri }) if (!response.ok) { - console.warn(`[Kilo Gateway] Failed to fetch organization modes: ${response.status}`) return [] } @@ -85,7 +84,6 @@ export async function fetchOrganizationModes(token: string, organizationId: stri const parsed = ResponseSchema.safeParse(json) if (!parsed.success) { - console.warn("[Kilo Gateway] Organization modes response validation failed:", parsed.error.format()) return [] } diff --git a/packages/kilo-gateway/src/api/url.ts b/packages/kilo-gateway/src/api/url.ts new file mode 100644 index 00000000000..9184f7d5ea9 --- /dev/null +++ b/packages/kilo-gateway/src/api/url.ts @@ -0,0 +1,30 @@ +import { KILO_API_BASE } from "./constants.js" +import { getKiloUrlFromToken } from "../auth/token.js" + +type UrlOptions = { + baseURL?: string + token?: string +} + +function route(raw: string, name: "gateway" | "openrouter"): string { + const url = new URL(raw) + const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean) + const api = parts.lastIndexOf("api") + const prefix = api >= 0 ? parts.slice(0, api) : parts + url.pathname = `/${[...prefix, "api", name].join("/")}/` + url.search = "" + url.hash = "" + return url.toString() +} + +function base(options: UrlOptions): string { + return getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, options.token ?? "") +} + +export function resolveKiloGatewayBaseUrl(options: UrlOptions = {}): string { + return route(base(options), "gateway") +} + +export function resolveKiloOpenRouterBaseUrl(options: UrlOptions = {}): string { + return route(base(options), "openrouter") +} diff --git a/packages/kilo-gateway/src/auth/token.ts b/packages/kilo-gateway/src/auth/token.ts index 0759a74cc53..375d15bafcc 100644 --- a/packages/kilo-gateway/src/auth/token.ts +++ b/packages/kilo-gateway/src/auth/token.ts @@ -4,20 +4,16 @@ */ export function getKiloUrlFromToken(defaultUrl: string, token: string): string { // If token contains URL information, extract it - // This is a simplified version - adjust based on actual token format if (!token) return defaultUrl - try { - // Check if token has URL prefix (format: "url:base64token") - const parts = token.split(":") - if (parts.length > 1 && parts[0].startsWith("http")) { - return parts[0] - } - } catch (e) { - // If parsing fails, return default - } + const match = token.match(/^(https?:\/\/[^:]+(?::\d+)?(?:\/[^:]*)?):/) + if (!match) return defaultUrl - return defaultUrl + try { + return new URL(match[1]).toString().replace(/\/+$/, "") + } catch { + return defaultUrl + } } /** diff --git a/packages/kilo-gateway/src/headers.ts b/packages/kilo-gateway/src/headers.ts index e2446ae2353..aaddc274cb0 100644 --- a/packages/kilo-gateway/src/headers.ts +++ b/packages/kilo-gateway/src/headers.ts @@ -1,6 +1,7 @@ import { HEADER_ORGANIZATIONID, HEADER_TASKID, + HEADER_PARENT_TASKID, HEADER_PROJECTID, HEADER_TESTER, HEADER_EDITORNAME, @@ -21,6 +22,7 @@ import { */ export const X_KILOCODE_ORGANIZATIONID = HEADER_ORGANIZATIONID export const X_KILOCODE_TASKID = HEADER_TASKID +export const X_KILOCODE_PARENT_TASKID = HEADER_PARENT_TASKID export const X_KILOCODE_PROJECTID = HEADER_PROJECTID export const X_KILOCODE_TESTER = HEADER_TESTER export const X_KILOCODE_EDITORNAME = HEADER_EDITORNAME diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 8538737e6ee..429575d093d 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -33,7 +33,14 @@ export { getKiloDefaultModel, promptOrganizationSelection, } from "./api/profile.js" -export { fetchKiloModels } from "./api/models.js" +export { fetchKiloModels, type KiloModelsResult } from "./api/models.js" +export { + EMPTY_KILO_EMBEDDING_MODEL_CATALOG, + fetchKiloEmbeddingModelCatalog, + type KiloEmbeddingModel, + type KiloEmbeddingModelCatalog, +} from "./api/embedding-models.js" +export { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "./api/url.js" export { fetchOrganizationModes, clearModesCache, @@ -41,11 +48,24 @@ export { type OrganizationModeConfig, } from "./api/modes.js" export { fetchKilocodeNotifications, type KilocodeNotification } from "./api/notifications.js" +export { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "./cloud-sessions.js" // ============================================================================ // Server Routes (optional - requires hono and OpenCode dependencies) // ============================================================================ export { createKiloRoutes } from "./server/routes.js" +export { + GatewayError, + UnauthorizedError, + getOrganizationId, + getClawChatCredentials, + getClawStatus, + getCloudSessions, + getNotifications, + getProfile, + getToken, + setOrganization, +} from "./server/handlers.js" // ============================================================================ // Note: TUI exports moved to separate entry point @@ -81,6 +101,8 @@ export { ENV_KILO_API_URL, DEFAULT_KILO_API_URL, KILO_API_BASE, + KILO_CHAT_URL, + KILO_EVENT_SERVICE_URL, KILO_OPENROUTER_BASE, POLL_INTERVAL_MS, DEFAULT_MODEL, @@ -93,6 +115,7 @@ export { MODELS_FETCH_TIMEOUT_MS, HEADER_ORGANIZATIONID, HEADER_TASKID, + HEADER_PARENT_TASKID, HEADER_PROJECTID, HEADER_TESTER, HEADER_EDITORNAME, diff --git a/packages/kilo-gateway/src/provider-debug.ts b/packages/kilo-gateway/src/provider-debug.ts index 71f7f2a2d9d..f97bcbb7cdc 100644 --- a/packages/kilo-gateway/src/provider-debug.ts +++ b/packages/kilo-gateway/src/provider-debug.ts @@ -1,9 +1,11 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider" import type { Provider as SDK } from "ai" import type { KiloProviderOptions } from "./types.js" -import { getKiloUrlFromToken, getApiKey } from "./auth/token.js" +import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" -import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js" +import { ANONYMOUS_API_KEY } from "./api/constants.js" +import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" +import { buildRequestHeaders } from "./provider.js" /** * Debug version of createKilo with extensive logging @@ -18,16 +20,7 @@ export function createKiloDebug(options: KiloProviderOptions = {}): SDK { console.log(" - Source:", options.kilocodeToken ? "kilocodeToken" : options.apiKey ? "apiKey" : "none") console.log(" - Value:", apiKey ? `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 8)}` : "MISSING!") - // Determine base URL - const baseApiUrl = getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, apiKey ?? "") - console.log("🌐 [KILO DEBUG] Base URL resolved:", baseApiUrl) - - // Build OpenRouter URL - only append /openrouter/ if not already present - const openRouterUrl = baseApiUrl.includes("/openrouter") - ? baseApiUrl - : baseApiUrl.endsWith("/") - ? `${baseApiUrl}openrouter/` - : `${baseApiUrl}/openrouter/` + const openRouterUrl = resolveKiloOpenRouterBaseUrl({ baseURL: options.baseURL, token: apiKey }) console.log("🔗 [KILO DEBUG] OpenRouter URL:", openRouterUrl) // Merge custom headers with defaults @@ -48,12 +41,7 @@ export function createKiloDebug(options: KiloProviderOptions = {}): SDK { console.log(" - URL:", String(input)) console.log(" - Method:", init?.method || "GET") - const headers = new Headers(init?.headers) - - // Add custom headers - Object.entries(customHeaders).forEach(([key, value]) => { - headers.set(key, value) - }) + const headers = buildRequestHeaders(customHeaders, init?.headers) // Add authorization if API key exists if (apiKey) { diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index 62bd6716d1e..c36551469e6 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -4,9 +4,19 @@ import { createAnthropic } from "@ai-sdk/anthropic" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import type { KiloProvider, KiloProviderOptions } from "./types.js" -import { getKiloUrlFromToken, getApiKey } from "./auth/token.js" +import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" -import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js" +import { ANONYMOUS_API_KEY } from "./api/constants.js" +import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" +import { sanitizeResponsesBody } from "./responses.js" + +export function buildRequestHeaders(defaultHeaders: Record, requestHeaders?: HeadersInit): Headers { + const headers = new Headers(defaultHeaders) + new Headers(requestHeaders).forEach((value, key) => { + headers.set(key, value) + }) + return headers +} /** * Create a KiloCode provider instance @@ -28,15 +38,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { // Get API key from options or environment const apiKey = getApiKey(options) - // Determine base URL - const baseApiUrl = getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, apiKey ?? "") - - // Build OpenRouter URL - only append /openrouter/ if not already present - const openRouterUrl = baseApiUrl.includes("/openrouter") - ? baseApiUrl - : baseApiUrl.endsWith("/") - ? `${baseApiUrl}openrouter/` - : `${baseApiUrl}/openrouter/` + const openRouterUrl = resolveKiloOpenRouterBaseUrl({ baseURL: options.baseURL, token: apiKey }) // Merge custom headers with defaults const customHeaders = { @@ -51,12 +53,8 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { // Create custom fetch wrapper to add dynamic headers const originalFetch = options.fetch ?? fetch const wrappedFetch = async (input: string | URL | Request, init?: RequestInit) => { - const headers = new Headers(init?.headers) - - // Add custom headers - Object.entries(customHeaders).forEach(([key, value]) => { - headers.set(key, value) - }) + const headers = buildRequestHeaders(customHeaders, init?.headers) + const body = sanitizeResponsesBody(input, init?.body) // Add authorization if API key exists if (apiKey) { @@ -66,6 +64,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { return originalFetch(input, { ...init, headers, + body, }) } diff --git a/packages/kilo-gateway/src/responses.ts b/packages/kilo-gateway/src/responses.ts new file mode 100644 index 00000000000..3697c5cf775 --- /dev/null +++ b/packages/kilo-gateway/src/responses.ts @@ -0,0 +1,49 @@ +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + +function endpoint(input: string | URL | Request) { + const raw = input instanceof Request ? input.url : input.toString() + const path = (() => { + try { + return new URL(raw).pathname + } catch { + return raw.split(/[?#]/, 1)[0] + } + })() + return path.endsWith("/responses") +} + +function strip(input: unknown[]) { + const kept = input.flatMap((item) => { + if (!record(item)) return [item] + if (item.type === "item_reference") return [] + if (!("id" in item)) return [item] + + const next = { ...item } + delete next.id + return [next] + }) + const changed = kept.length !== input.length || kept.some((item, index) => item !== input[index]) + return { kept, changed } +} + +export function sanitizeResponsesBody(input: string | URL | Request, body: BodyInit | null | undefined) { + if (!endpoint(input)) return body + if (typeof body !== "string") return body + + const data = (() => { + try { + return JSON.parse(body) as unknown + } catch { + return undefined + } + })() + 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 }) +} diff --git a/packages/kilo-gateway/src/server/handlers.ts b/packages/kilo-gateway/src/server/handlers.ts new file mode 100644 index 00000000000..4e3fca63762 --- /dev/null +++ b/packages/kilo-gateway/src/server/handlers.ts @@ -0,0 +1,190 @@ +import { fetchBalance, fetchProfile } from "../api/profile.js" +import { fetchKilocodeNotifications } from "../api/notifications.js" +import { clearModesCache } from "../api/modes.js" +import { HEADER_ORGANIZATIONID, KILO_API_BASE, KILO_CHAT_URL, KILO_EVENT_SERVICE_URL } from "../api/constants.js" +import type { KilocodeBalance, KilocodeProfile } from "../types.js" +import { buildKiloHeaders } from "../headers.js" + +export type KiloAuth = + | { type: "api"; key: string } + | { type: "oauth"; access: string; refresh: string; expires: number; accountId?: string } + | { type: "wellknown"; key: string; token: string } + +export interface KiloProfileResult { + profile: KilocodeProfile + balance: KilocodeBalance | null + currentOrgId: string | null +} + +export interface ClawChatCredentials { + token: string + expiresAt: string + kiloChatUrl: string + eventServiceUrl: string +} + +export interface AuthStore { + get(provider: string): Promise + set(provider: string, auth: Extract): Promise +} + +export interface OrganizationDeps { + auth: AuthStore + clear(): void + dispose(): Promise +} + +export interface CloudSessionsInput { + cursor?: string + limit?: number + gitUrl?: string +} + +export class UnauthorizedError extends Error {} + +export class GatewayError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + } +} + +export function getToken(auth: KiloAuth | undefined) { + if (auth?.type === "api") return auth.key + if (auth?.type === "oauth") return auth.access + return undefined +} + +export function getOrganizationId(auth: KiloAuth | undefined) { + if (auth?.type === "oauth") return auth.accountId + return undefined +} + +export async function getProfile(auth: AuthStore): Promise { + const info = await auth.get("kilo") + if (!info || info.type !== "oauth") throw new UnauthorizedError("Not authenticated with Kilo Gateway") + + const currentOrgId = info.accountId ?? null + const [profile, balance] = await Promise.all([ + fetchProfile(info.access), + fetchBalance(info.access, currentOrgId ?? undefined), + ]) + return { profile, balance, currentOrgId } +} + +export async function getNotifications(auth: AuthStore) { + const info = await auth.get("kilo") + const token = getToken(info) + if (!token) return [] + + return fetchKilocodeNotifications({ + kilocodeToken: token, + kilocodeOrganizationId: getOrganizationId(info), + }) +} + +export async function setOrganization(deps: OrganizationDeps, organizationId: string | null) { + const info = await deps.auth.get("kilo") + if (!info || info.type !== "oauth") throw new UnauthorizedError("Not authenticated with Kilo Gateway") + + await deps.auth.set("kilo", { + type: "oauth", + refresh: info.refresh, + access: info.access, + expires: info.expires, + ...(organizationId && { accountId: organizationId }), + }) + + deps.clear() + clearModesCache() + await deps.dispose() + return true +} + +export async function getClawStatus(auth: AuthStore) { + const info = await auth.get("kilo") + const token = getToken(info) + if (!token) throw new UnauthorizedError("No valid token found") + + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + const org = getOrganizationId(info) + if (org) headers[HEADER_ORGANIZATIONID] = org + + const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers }) + if (!response.ok) throw new GatewayError(await response.text(), response.status) + return response.json() +} + +export async function getClawChatCredentials(auth: AuthStore): Promise { + const info = await auth.get("kilo") + const token = getToken(info) + if (!token) throw new UnauthorizedError("No valid token found") + + const expires = info?.type === "oauth" ? info.expires : Date.now() + 365 * 24 * 60 * 60 * 1000 + return { + token, + expiresAt: new Date(expires).toISOString(), + kiloChatUrl: KILO_CHAT_URL, + eventServiceUrl: KILO_EVENT_SERVICE_URL, + } +} + +export async function getCloudSessions(token: string, input: CloudSessionsInput) { + const query: Record = {} + if (input.cursor) query.cursor = input.cursor + if (input.limit) query.limit = input.limit + if (input.gitUrl) query.gitUrl = input.gitUrl + + const params = new URLSearchParams({ + batch: "1", + input: JSON.stringify({ "0": query }), + }) + + const response = await fetch(`${KILO_API_BASE}/api/trpc/cliSessionsV2.list?${params.toString()}`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...buildKiloHeaders(), + }, + }) + + if (!response.ok) { + const text = await response.text() + console.error("[Kilo Gateway] cloud-sessions: tRPC request failed", { + status: response.status, + body: text.slice(0, 500), + }) + throw new GatewayError(`Cloud sessions fetch failed: ${response.status}`, response.status) + } + + const raw = await response.text() + const json = JSON.parse(raw) + const data = Array.isArray(json) ? json[0]?.result?.data : null + const result = data?.json ?? data + if (!result) return { cliSessions: [], nextCursor: null } + + const cliSessions = (result.cliSessions ?? []).map((item: any) => ({ + session_id: item.session_id, + title: item.title ?? null, + created_at: + typeof item.created_at === "string" + ? item.created_at + : item.created_at + ? new Date(item.created_at).toISOString() + : new Date().toISOString(), + updated_at: + typeof item.updated_at === "string" + ? item.updated_at + : item.updated_at + ? new Date(item.updated_at).toISOString() + : new Date().toISOString(), + version: item.version ?? 0, + })) + + return { cliSessions, nextCursor: result.nextCursor ?? null } +} diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 0282e2f94bc..78c2bfda6b5 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -5,13 +5,22 @@ * This factory function accepts OpenCode dependencies to create Kilo-specific routes */ -import { fetchProfile, fetchBalance } from "../api/profile.js" import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js" import { fetchOrganizationModes, clearModesCache } from "../api/modes.js" import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js" import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js" +import { + GatewayError, + UnauthorizedError, + getClawChatCredentials, + getClawStatus, + getCloudSessions, + getNotifications, + getProfile, + setOrganization, +} from "./handlers.js" // Type definitions for OpenCode dependencies (injected at runtime) type Hono = any @@ -32,7 +41,7 @@ interface KiloRoutesDeps extends ImportDeps { Auth: Auth ModelCache: ModelCache z: Z - Instance: ImportDeps["Instance"] & { disposeAll(): Promise } + Instances: { disposeAllInstances(): Promise } } const FIM_TIMEOUT_MS = 30_000 @@ -79,6 +88,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { SessionCreatedEvent, Identifier, ModelCache, + Instances, } = deps const Organization = z.object({ @@ -125,6 +135,21 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { cost: z.number().optional(), }) + const TranscriptionResponse = z.object({ + text: z.string(), + usage: z.unknown().optional(), + }) + + const getProxyAuth = async () => { + 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, + } + } + return new Hono() .get( "/profile", @@ -145,24 +170,12 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }, }), async (c: any) => { - // Get Kilo auth - const auth = await Auth.get("kilo") - - if (!auth || auth.type !== "oauth") { + try { + return c.json(await getProfile(Auth)) + } catch (err) { + if (!(err instanceof UnauthorizedError)) throw err return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) } - - const token = auth.access - const currentOrgId = auth.accountId ?? null - - // Fetch profile and balance in parallel - // Pass organizationId to fetchBalance to get team balance when in org context - const [profile, balance] = await Promise.all([ - fetchProfile(token), - fetchBalance(token, currentOrgId ?? undefined), - ]) - - return c.json({ profile, balance, currentOrgId }) }, ) .post( @@ -192,27 +205,21 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { async (c: any) => { const { organizationId } = c.req.valid("json") - // Get current Kilo auth - const auth = await Auth.get("kilo") - - if (!auth || auth.type !== "oauth") { + try { + return c.json( + await setOrganization( + { + auth: Auth, + clear: () => ModelCache.clear("kilo"), + dispose: () => Instances.disposeAllInstances(), + }, + organizationId, + ), + ) + } catch (err) { + if (!(err instanceof UnauthorizedError)) throw err return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) } - - // Update auth with new organization ID - await Auth.set("kilo", { - type: "oauth", - refresh: auth.refresh, - access: auth.access, - expires: auth.expires, - ...(organizationId && { accountId: organizationId }), - }) - - ModelCache.clear("kilo") - clearModesCache() - await Instance.disposeAll() - - return c.json(true) }, ) .get( @@ -317,19 +324,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }), ), async (c: any) => { - const auth = await Auth.get("kilo") + const proxy = await getProxyAuth() - if (!auth) { + if (!proxy.auth) { return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) } - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) { + if (!proxy.token) { return c.json({ error: "No valid token found" }, 401) } - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const { prefix, suffix, model, maxTokens, temperature } = c.req.valid("json") const fimModel = model ?? "mistralai/codestral-2501" const fimMaxTokens = maxTokens ?? 256 @@ -340,8 +344,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { const headers = { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + Authorization: `Bearer ${proxy.token}`, + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), [HEADER_FEATURE]: "autocomplete", } @@ -383,6 +387,67 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }) }, ) + .post( + "/audio/transcriptions", + describeRoute({ + summary: "Speech to text transcription", + description: "Proxy an audio transcription request to the Kilo Gateway", + operationId: "kilo.audio.transcriptions", + responses: { + 200: { + description: "Transcription response", + content: { + "application/json": { + schema: resolver(TranscriptionResponse), + }, + }, + }, + ...errors(400, 401), + }, + }), + validator( + "json", + z.object({ + model: z.string(), + input_audio: z.object({ + data: z.string(), + format: z.string(), + }), + language: z.string().optional(), + prompt: z.string().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 body = c.req.valid("json") + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${proxy.token}`, + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), + [HEADER_FEATURE]: "vscode-extension", + } + + const response = await fetch(`${KILO_API_BASE}/api/gateway/v1/audio/transcriptions`, { + method: "POST", + headers, + signal: c.req.raw.signal, + body: JSON.stringify(body), + }) + + const text = await response.text() + return new Response(text, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }) + }, + ) .get( "/notifications", describeRoute({ @@ -402,19 +467,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }, }), async (c: any) => { - const auth = await Auth.get("kilo") - if (!auth) return c.json([]) - - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) return c.json([]) - - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const notifications = await fetchKilocodeNotifications({ - kilocodeToken: token, - kilocodeOrganizationId: organizationId, - }) - - return c.json(notifications) + return c.json(await getNotifications(Auth)) }, ) .get( @@ -524,8 +577,24 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { "application/json": { schema: resolver( z.object({ + // `recovering` and `restoring` are transitional states the + // worker reports while it brings an instance back online + // after an unexpected stop or a snapshot restore — see + // cloud `services/kiloclaw/src/index.ts` and the + // `PlatformStatusResponse` type in + // cloud/apps/web/src/lib/kiloclaw/types.ts. Keeping them in + // the enum so the SDK types stay accurate. status: z - .enum(["provisioned", "starting", "restarting", "running", "stopped", "destroying"]) + .enum([ + "provisioned", + "starting", + "restarting", + "recovering", + "running", + "stopped", + "destroying", + "restoring", + ]) .nullable(), sandboxId: z.string().optional(), flyRegion: z.string().optional(), @@ -536,6 +605,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { channelCount: z.number().optional(), secretCount: z.number().optional(), userId: z.string().optional(), + botName: z.string().nullable().optional(), }), ), }, @@ -546,29 +616,11 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }), async (c: any) => { try { - const auth = await Auth.get("kilo") - if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) return c.json({ error: "No valid token found" }, 401) - - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const headers: Record = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - if (organizationId) { - headers[HEADER_ORGANIZATIONID] = organizationId - } - - const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers }) - - if (!response.ok) { - const text = await response.text() - return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any) - } - - return c.json(await response.json()) + return c.json(await getClawStatus(Auth)) } catch (err: any) { + if (err instanceof GatewayError) { + return c.json({ error: `KiloClaw request failed: ${err.status} ${err.message}` }, err.status as any) + } console.error("[Kilo Gateway] claw/status: error", err?.message ?? err) return c.json({ error: "Failed to reach KiloClaw" }, 502) } @@ -578,56 +630,38 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { "/claw/chat-credentials", describeRoute({ summary: "Get KiloClaw chat credentials", - description: "Fetch Stream Chat credentials for the user's KiloClaw instance", + description: + "Returns the bearer token and endpoint URLs the client uses to talk to the Kilo Chat worker " + + "and the Event Service. The bearer is the user's existing long-lived Kilo JWT — kilo-chat and " + + "event-service both verify it directly with NEXTAUTH_SECRET, so no separate token mint is needed.", operationId: "kilo.claw.chatCredentials", responses: { 200: { - description: "Stream Chat credentials or null", + description: "Kilo Chat credentials or null", content: { "application/json": { schema: resolver( z .object({ - apiKey: z.string(), - userId: z.string(), - userToken: z.string(), - channelId: z.string(), + token: z.string(), + expiresAt: z.string(), + kiloChatUrl: z.string(), + eventServiceUrl: z.string(), }) .nullable(), ), }, }, }, - ...errors(401, 502), + ...errors(401), }, }), async (c: any) => { try { - const auth = await Auth.get("kilo") - if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) return c.json({ error: "No valid token found" }, 401) - - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const headers: Record = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - if (organizationId) { - headers[HEADER_ORGANIZATIONID] = organizationId - } - - const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, { headers }) - - if (!response.ok) { - const text = await response.text() - return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any) - } - - return c.json(await response.json()) - } catch (err: any) { - console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err) - return c.json({ error: "Failed to reach KiloClaw" }, 502) + return c.json(await getClawChatCredentials(Auth)) + } catch (err) { + if (!(err instanceof UnauthorizedError)) throw err + return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) } }, ) @@ -678,63 +712,9 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined if (!token) return c.json({ error: "No valid token found" }, 401) - const { cursor, limit, gitUrl } = c.req.valid("query") - - const input: Record = {} - if (cursor) input.cursor = cursor - if (limit) input.limit = limit - if (gitUrl) input.gitUrl = gitUrl - - const params = new URLSearchParams({ - batch: "1", - input: JSON.stringify({ "0": input }), - }) - - const url = `${KILO_API_BASE}/api/trpc/cliSessionsV2.list?${params.toString()}` - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - ...buildKiloHeaders(), - }, - }) - - if (!response.ok) { - const text = await response.text() - console.error("[Kilo Gateway] cloud-sessions: tRPC request failed", { - status: response.status, - body: text.slice(0, 500), - }) - return c.json({ error: `Cloud sessions fetch failed: ${response.status}` }, response.status as any) - } - - const raw = await response.text() - const json = JSON.parse(raw) - const data = Array.isArray(json) ? json[0]?.result?.data : null - const result = data?.json ?? data - if (!result) return c.json({ cliSessions: [], nextCursor: null }) - - const sessions = (result.cliSessions ?? []).map((s: any) => ({ - session_id: s.session_id, - title: s.title ?? null, - created_at: - typeof s.created_at === "string" - ? s.created_at - : s.created_at - ? new Date(s.created_at).toISOString() - : new Date().toISOString(), - updated_at: - typeof s.updated_at === "string" - ? s.updated_at - : s.updated_at - ? new Date(s.updated_at).toISOString() - : new Date().toISOString(), - version: s.version ?? 0, - })) - - return c.json({ cliSessions: sessions, nextCursor: result.nextCursor ?? null }) + return c.json(await getCloudSessions(token, c.req.valid("query"))) } catch (err: any) { + if (err instanceof GatewayError) return c.json({ error: err.message }, err.status as any) console.error("[Kilo Gateway] cloud-sessions: unhandled error", err?.message ?? err) return c.json({ error: "Internal error" }, 500) } diff --git a/packages/kilo-gateway/test/api/embedding-models.test.ts b/packages/kilo-gateway/test/api/embedding-models.test.ts new file mode 100644 index 00000000000..f2e15a9e250 --- /dev/null +++ b/packages/kilo-gateway/test/api/embedding-models.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, mock, test } from "bun:test" +import { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog } from "../../src/api/embedding-models" + +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 + 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", + ) + } finally { + global.fetch = prev + } + }) + + test("falls back when the request fails", async () => { + const prev = global.fetch + 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, + ) + } finally { + global.fetch = prev + } + }) + + test("fallback catalog is empty so Cloud owns model metadata", () => { + expect(EMPTY_KILO_EMBEDDING_MODEL_CATALOG).toEqual({ + defaultModel: "", + models: [], + aliases: {}, + }) + }) +}) diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts new file mode 100644 index 00000000000..579f0621bc0 --- /dev/null +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -0,0 +1,126 @@ +// Verifies fetchKiloModels typed result and 401 fallback behaviour. + +import { test, expect } from "bun:test" +import { fetchKiloModels } from "../../src/api/models.js" + +const VALID_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"], + }, + ], +}) + +function stubFetch(fn: (input: string | URL | Request, init?: RequestInit) => Promise) { + ;(globalThis as any).fetch = fn +} + +test("returns empty models and error when both auth and public requests return 401", async () => { + const orig = globalThis.fetch + stubFetch(async () => new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })) + + const result = await fetchKiloModels({ kilocodeToken: "bad-token" }) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error).toBeDefined() +}) + +test("falls back to public endpoint on 401 and returns models", async () => { + const orig = globalThis.fetch + let callCount = 0 + + stubFetch(async () => { + callCount++ + if (callCount === 1) { + return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }) + } + return new Response(VALID_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) + + const result = await fetchKiloModels({ + kilocodeToken: "expired-token", + kilocodeOrganizationId: "org-123", + }) + + ;(globalThis as any).fetch = orig + + expect(callCount).toBe(2) + expect(result.error).toBeUndefined() + expect(Object.keys(result.models).length).toBeGreaterThan(0) +}) + +test("returns error with kind=network on fetch exception", async () => { + const orig = globalThis.fetch + stubFetch(async () => { + throw new Error("network error") + }) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("network") +}) + +test("returns error with kind=http on non-auth HTTP error (e.g. 500)", async () => { + const orig = globalThis.fetch + stubFetch(async () => new Response("Server Error", { status: 500, statusText: "Internal Server Error" })) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("http") + expect(result.error?.status).toBe(500) +}) + +test("returns models without error on success", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response(VALID_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(Object.keys(result.models).length).toBeGreaterThan(0) +}) + +test("returns error with kind=schema when response body is invalid JSON", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response("not valid json{{{{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("schema") +}) diff --git a/packages/kilo-gateway/test/api/url.test.ts b/packages/kilo-gateway/test/api/url.test.ts new file mode 100644 index 00000000000..e778182cab1 --- /dev/null +++ b/packages/kilo-gateway/test/api/url.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "../../src/api/url" + +describe("Kilo API URL resolvers", () => { + test("resolves production route bases", () => { + expect(resolveKiloGatewayBaseUrl()).toBe("https://api.kilo.ai/api/gateway/") + expect(resolveKiloOpenRouterBaseUrl()).toBe("https://api.kilo.ai/api/openrouter/") + }) + + test("normalizes root API base overrides", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test" })).toBe("https://example.test/api/gateway/") + expect(resolveKiloOpenRouterBaseUrl({ baseURL: "https://example.test/" })).toBe( + "https://example.test/api/openrouter/", + ) + }) + + test("replaces existing Kilo API route paths", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test/api/openrouter/" })).toBe( + "https://example.test/api/gateway/", + ) + expect(resolveKiloOpenRouterBaseUrl({ baseURL: "https://example.test/api/gateway/" })).toBe( + "https://example.test/api/openrouter/", + ) + }) + + test("preserves path prefixes before api", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test/dev/api/openrouter/" })).toBe( + "https://example.test/dev/api/gateway/", + ) + expect(resolveKiloOpenRouterBaseUrl({ baseURL: "https://example.test/dev" })).toBe( + "https://example.test/dev/api/openrouter/", + ) + }) + + test("strips search and hash components", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test/api/openrouter/?x=1#frag" })).toBe( + "https://example.test/api/gateway/", + ) + }) + + test("prefers token-derived URL when token contains one", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://fallback.test", token: "https://token.test:opaque" })).toBe( + "https://token.test/api/gateway/", + ) + }) + + test("resolves child endpoint URLs", () => { + expect(new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: "https://example.test" })).toString()).toBe( + "https://example.test/api/gateway/embedding-models", + ) + }) +}) diff --git a/packages/kilo-gateway/test/provider.test.ts b/packages/kilo-gateway/test/provider.test.ts new file mode 100644 index 00000000000..34f468a80b0 --- /dev/null +++ b/packages/kilo-gateway/test/provider.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { buildRequestHeaders } from "../src/provider" + +describe("Kilo provider request headers", () => { + test("request headers override provider defaults", () => { + const headers = buildRequestHeaders( + { + "content-type": "application/json", + "x-kilocode-feature": "vscode-extension", + "x-default-only": "kept", + }, + { + "x-kilocode-feature": "agent-manager", + "x-request-only": "kept-too", + }, + ) + + expect(headers.get("content-type")).toBe("application/json") + expect(headers.get("x-kilocode-feature")).toBe("agent-manager") + expect(headers.get("x-default-only")).toBe("kept") + expect(headers.get("x-request-only")).toBe("kept-too") + }) +}) diff --git a/packages/kilo-gateway/test/responses.test.ts b/packages/kilo-gateway/test/responses.test.ts new file mode 100644 index 00000000000..49520300f50 --- /dev/null +++ b/packages/kilo-gateway/test/responses.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import { sanitizeResponsesBody } from "../src/responses" + +describe("Responses request sanitization", () => { + test("strips item ids when storage is disabled", () => { + const body = JSON.stringify({ + store: false, + input: [ + { + type: "reasoning", + id: "rs_tmp_123", + encrypted_content: "encrypted", + summary: [{ type: "summary_text", text: "thinking" }], + }, + { + type: "message", + role: "assistant", + id: "msg_tmp_123", + content: [{ type: "output_text", text: "Hello" }], + }, + { + type: "item_reference", + id: "rs_tmp_456", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue" }], + }, + ], + }) + + const result = sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body) + const data = JSON.parse(result as string) + + expect(data.input).toHaveLength(3) + expect(data.input[0].id).toBeUndefined() + expect(data.input[0].encrypted_content).toBe("encrypted") + expect(data.input[0].summary[0].text).toBe("thinking") + expect(data.input[1].id).toBeUndefined() + expect(data.input[1].content[0].text).toBe("Hello") + expect(data.input.some((item: { type?: string }) => item.type === "item_reference")).toBe(false) + expect(data.input[2].content[0].text).toBe("Continue") + }) + + test("keeps item ids when storage is enabled", () => { + const body = JSON.stringify({ + store: true, + input: [ + { + type: "reasoning", + id: "rs_123", + encrypted_content: "encrypted", + summary: [], + }, + { + type: "item_reference", + id: "rs_456", + }, + ], + }) + + expect(sanitizeResponsesBody("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) + }) + + test("leaves invalid responses JSON unchanged", () => { + const body = "not json" + + expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + }) + + test("matches relative responses paths without a placeholder host", () => { + const body = JSON.stringify({ + input: [ + { + type: "message", + role: "assistant", + id: "msg_tmp_123", + content: [{ type: "output_text", text: "Hello" }], + }, + ], + }) + const result = sanitizeResponsesBody("/api/openrouter/responses?stream=true", body) + const data = JSON.parse(result as string) + + expect(data.input[0].id).toBeUndefined() + }) +}) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 688bbab96d6..1bd0834f158 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.2.40", + "version": "7.3.3", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", @@ -16,5 +16,7 @@ "@types/bun": "catalog:", "typescript": "catalog:", "@typescript/native-preview": "catalog:" - } + }, + "dependencies": {}, + "peerDependencies": {} } diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 857501d6d69..d7a778caa65 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", + "ui.messagePart.shell.command": "الأمر", + "ui.messagePart.shell.output": "المخرجات", + "ui.messagePart.openInEditor": "فتح في المحرر", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "كان هذا مفيدًا", + "ui.message.feedback.notHelpful": "لم يكن هذا مفيدًا", + "ui.message.feedback.clearRating": "مسح التقييم", } diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 8624128af45..bbf53cd2ff3 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", + "ui.messagePart.shell.command": "Comando", + "ui.messagePart.shell.output": "Saída", + "ui.messagePart.openInEditor": "Abrir no Editor", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Isso foi útil", + "ui.message.feedback.notHelpful": "Isso não foi útil", + "ui.message.feedback.clearRating": "Limpar avaliação", } diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 8773799ea6d..2e4983260dc 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -96,5 +96,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", + "ui.messagePart.shell.command": "Naredba", + "ui.messagePart.shell.output": "Izlaz", + "ui.messagePart.openInEditor": "Otvori u editoru", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Ovo je bilo korisno", + "ui.message.feedback.notHelpful": "Ovo nije bilo korisno", + "ui.message.feedback.clearRating": "Obriši ocjenu", } diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index bbed59f1519..e930733acbe 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Slå Kilos snapshots fra for dette projekt. Du mister fortryd/gentag for Kilo-ændringer, men git sporer stadig alt.", - "ui.messagePart.openInDiffViewer": "Åbn i diff-viser", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Åbn i Diff-visning", + "ui.messagePart.shell.command": "Kommando", + "ui.messagePart.shell.output": "Output", + "ui.messagePart.openInEditor": "Åbn i editor", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Dette var nyttigt", + "ui.message.feedback.notHelpful": "Dette var ikke nyttigt", + "ui.message.feedback.clearRating": "Ryd bedømmelse", } diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 51f7adefc87..ce73601069b 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Kilo-Snapshots für dieses Projekt ausschalten. Rückgängig/Wiederherstellen für Kilo-Änderungen ist nicht mehr möglich, aber git verfolgt weiterhin alles.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen", + "ui.messagePart.shell.command": "Befehl", + "ui.messagePart.shell.output": "Ausgabe", + "ui.messagePart.openInEditor": "Im Editor öffnen", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Das war hilfreich", + "ui.message.feedback.notHelpful": "Das war nicht hilfreich", + "ui.message.feedback.clearRating": "Bewertung löschen", } diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index e9007fb2ca1..7f908032995 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -99,4 +99,13 @@ export const dict = { // Edit-tool header: hover-revealed action opening the diff in a full tab. "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", + // Shell-tool section labels and actions. + "ui.messagePart.shell.command": "Command", + "ui.messagePart.shell.output": "Output", + "ui.messagePart.openInEditor": "Open in Editor", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "This was helpful", + "ui.message.feedback.notHelpful": "This wasn't helpful", + "ui.message.feedback.clearRating": "Clear rating", } diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 91d40b832a8..07af68a1a0f 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", + "ui.messagePart.shell.command": "Comando", + "ui.messagePart.shell.output": "Salida", + "ui.messagePart.openInEditor": "Abrir en el editor", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Esto fue útil", + "ui.message.feedback.notHelpful": "Esto no fue útil", + "ui.message.feedback.clearRating": "Borrar valoración", } diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index f44f61b1420..2faf61d345b 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Désactivez les instantanés Kilo pour ce projet. Vous perdez l'annulation/restauration des modifications faites par Kilo, mais git continue de tout suivre.", - "ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Ouvrir dans le visualiseur de différences", + "ui.messagePart.shell.command": "Commande", + "ui.messagePart.shell.output": "Sortie", + "ui.messagePart.openInEditor": "Ouvrir dans l'éditeur", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "C'était utile", + "ui.message.feedback.notHelpful": "Ce n'était pas utile", + "ui.message.feedback.clearRating": "Effacer la notation", } diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 604bbba38de..f30eae15557 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -89,5 +89,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。", - "ui.messagePart.openInDiffViewer": "差分ビューアで開く", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "差分ビューアーで開く", + "ui.messagePart.shell.command": "コマンド", + "ui.messagePart.shell.output": "出力", + "ui.messagePart.openInEditor": "エディタで開く", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "役に立ちました", + "ui.message.feedback.notHelpful": "役に立ちませんでした", + "ui.message.feedback.clearRating": "評価をクリア", } diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 678e80af0cb..8a9692001fe 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -89,5 +89,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.", - "ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "차이점 뷰어에서 열기", + "ui.messagePart.shell.command": "명령어", + "ui.messagePart.shell.output": "출력", + "ui.messagePart.openInEditor": "편집기에서 열기", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "도움이 됐어요", + "ui.message.feedback.notHelpful": "도움이 안 됐어요", + "ui.message.feedback.clearRating": "평가 지우기", } diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 4529ff8eaaa..142c0958286 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -93,5 +93,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Zet Kilo-snapshots uit voor dit project. Je verliest ongedaan maken/opnieuw doen van Kilo-wijzigingen, maar git blijft alles volgen.", - "ui.messagePart.openInDiffViewer": "Openen in diff-viewer", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Openen in Diff-weergave", + "ui.messagePart.shell.command": "Opdracht", + "ui.messagePart.shell.output": "Uitvoer", + "ui.messagePart.openInEditor": "Openen in editor", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Dit was nuttig", + "ui.message.feedback.notHelpful": "Dit was niet nuttig", + "ui.message.feedback.clearRating": "Beoordeling wissen", } diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index c49b1b48730..e7f223b923b 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Slå av Kilos snapshots for dette prosjektet. Du mister angre/gjør om for Kilo-endringer, men git fortsetter å spore alt.", - "ui.messagePart.openInDiffViewer": "Åpne i diff-viser", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Åpne i diff-visning", + "ui.messagePart.shell.command": "Kommando", + "ui.messagePart.shell.output": "Utdata", + "ui.messagePart.openInEditor": "Åpne i editor", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Dette var nyttig", + "ui.message.feedback.notHelpful": "Dette var ikke nyttig", + "ui.message.feedback.clearRating": "Fjern vurdering", } diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index a42adca11b4..8cfd61e3c2b 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", + "ui.messagePart.shell.command": "Polecenie", + "ui.messagePart.shell.output": "Wyjście", + "ui.messagePart.openInEditor": "Otwórz w edytorze", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "To było pomocne", + "ui.message.feedback.notHelpful": "To nie było pomocne", + "ui.message.feedback.clearRating": "Wyczyść ocenę", } diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 73e695f75b2..2a923436c22 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.", - "ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Открыть в просмотре различий", + "ui.messagePart.shell.command": "Команда", + "ui.messagePart.shell.output": "Вывод", + "ui.messagePart.openInEditor": "Открыть в редакторе", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Это было полезно", + "ui.message.feedback.notHelpful": "Это было бесполезно", + "ui.message.feedback.clearRating": "Очистить оценку", } diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 613b621ef0e..ae0c5a4d88c 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", + "ui.messagePart.shell.command": "คำสั่ง", + "ui.messagePart.shell.output": "ผลลัพธ์", + "ui.messagePart.openInEditor": "เปิดในตัวแก้ไข", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "สิ่งนี้มีประโยชน์", + "ui.message.feedback.notHelpful": "สิ่งนี้ไม่มีประโยชน์", + "ui.message.feedback.clearRating": "ล้างการให้คะแนน", } diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index ef8ec7f0b08..6297ed3295a 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Bu proje için Kilo anlık görüntülerini kapat. Kilo değişiklikleri için geri alma/yeniden yapmayı kaybedersiniz, ancak git her şeyi izlemeye devam eder.", - "ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Fark Görüntüleyicide Aç", + "ui.messagePart.shell.command": "Komut", + "ui.messagePart.shell.output": "Çıktı", + "ui.messagePart.openInEditor": "Düzenleyicide Aç", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Bu yardımcı oldu", + "ui.message.feedback.notHelpful": "Bu yardımcı olmadı", + "ui.message.feedback.clearRating": "Değerlendirmeyi temizle", } diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index e12d8bb9c5c..afc11ae14e1 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -91,5 +91,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.", - "ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Відкрити в переглядачі відмінностей", + "ui.messagePart.shell.command": "Команда", + "ui.messagePart.shell.output": "Вивід", + "ui.messagePart.openInEditor": "Відкрити в редакторі", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "Це було корисно", + "ui.message.feedback.notHelpful": "Це не було корисно", + "ui.message.feedback.clearRating": "Очистити оцінку", } diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index a891782e07e..37954ff59d2 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -86,5 +86,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "在差异查看器中打开", + "ui.messagePart.shell.command": "命令", + "ui.messagePart.shell.output": "输出", + "ui.messagePart.openInEditor": "在编辑器中打开", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "这有帮助", + "ui.message.feedback.notHelpful": "这没有帮助", + "ui.message.feedback.clearRating": "清除评分", } diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 519d0be6ccb..5561173b89d 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -86,5 +86,14 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", + "ui.messagePart.shell.command": "命令", + "ui.messagePart.shell.output": "輸出", + "ui.messagePart.openInEditor": "在編輯器中開啟", + + // Message feedback (thumbs up/down per assistant response) + "ui.message.feedback.helpful": "這有幫助", + "ui.message.feedback.notHelpful": "這沒有幫助", + "ui.message.feedback.clearRating": "清除評分", } diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 7fd81080420..3f4e34ebc5d 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.2.40", + "version": "7.3.3", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", @@ -17,6 +17,7 @@ ".": "./src/index.ts", "./config": "./src/config.ts", "./detect": "./src/detect.ts", + "./embedding-models": "./src/kilo-embedding-models.ts", "./engine": "./src/indexing/index.ts", "./server": "./src/server/routes.ts", "./status": "./src/status.ts" @@ -39,6 +40,7 @@ "@qdrant/js-client-rest": "1.17.0", "async-mutex": "0.5.0", "chokidar": "4.0.3", + "effect": "catalog:", "glob": "13.0.6", "hono": "catalog:", "hono-openapi": "catalog:", @@ -56,5 +58,6 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/kilo-indexing/src/config.ts b/packages/kilo-indexing/src/config.ts index e9811da4549..8eae226e0ca 100644 --- a/packages/kilo-indexing/src/config.ts +++ b/packages/kilo-indexing/src/config.ts @@ -1,8 +1,10 @@ +import { Schema } from "effect" import z from "zod" import type { IndexingConfigInput } from "./indexing/config-manager" import type { EmbedderProvider } from "./indexing/interfaces/manager" const providers = [ + "kilo", "openai", "ollama", "openai-compatible", @@ -13,6 +15,7 @@ const providers = [ "openrouter", "voyage", ] as const satisfies readonly EmbedderProvider[] +const stores = ["lancedb", "qdrant"] as const export const IndexingConfig = z .object({ @@ -25,7 +28,16 @@ export const IndexingConfig = z .positive() .optional() .describe("Override embedding vector dimension (auto-detected from model if omitted)"), - vectorStore: z.enum(["lancedb", "qdrant"]).optional().describe("Vector store backend (default: qdrant)"), + vectorStore: z.enum(stores).optional().describe("Vector store backend (default: qdrant)"), + kilo: z + .object({ + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + organizationId: z.string().optional(), + }) + .strict() + .optional() + .describe("Kilo-hosted embedding provider options"), openai: z .object({ apiKey: z.string().optional() }) .strict() @@ -118,6 +130,106 @@ export const IndexingConfig = z export type IndexingConfig = z.infer +const Provider = Schema.Literals(providers) +const Store = Schema.Literals(stores) +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) +const Score = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)) + +export const IndexingSchema = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable codebase indexing" }), + provider: Schema.optional(Provider).annotate({ + description: "Embedding provider to use for codebase indexing", + }), + model: Schema.optional(Schema.String).annotate({ + description: "Embedding model ID (uses provider default if omitted)", + }), + dimension: Schema.optional(PositiveInt).annotate({ + description: "Override embedding vector dimension (auto-detected from model if omitted)", + }), + vectorStore: Schema.optional(Store).annotate({ description: "Vector store backend (default: qdrant)" }), + kilo: Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), + organizationId: Schema.optional(Schema.String), + }), + ).annotate({ description: "Kilo-hosted embedding provider options" }), + openai: Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "OpenAI embedding provider options" }), + ollama: Schema.optional( + Schema.Struct({ + baseUrl: Schema.optional(Schema.String), + }), + ).annotate({ description: "Ollama embedding provider options" }), + "openai-compatible": Schema.optional( + Schema.Struct({ + baseUrl: Schema.optional(Schema.String), + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "OpenAI-compatible embedding provider options" }), + gemini: Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "Gemini embedding provider options" }), + mistral: Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "Mistral embedding provider options" }), + "vercel-ai-gateway": Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "Vercel AI Gateway embedding provider options" }), + bedrock: Schema.optional( + Schema.Struct({ + region: Schema.optional(Schema.String), + profile: Schema.optional(Schema.String), + }), + ).annotate({ description: "AWS Bedrock embedding provider options" }), + openrouter: Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + specificProvider: Schema.optional(Schema.String), + }), + ).annotate({ description: "OpenRouter embedding provider options" }), + voyage: Schema.optional( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "Voyage embedding provider options" }), + qdrant: Schema.optional( + Schema.Struct({ + url: Schema.optional(Schema.String), + apiKey: Schema.optional(Schema.String), + }), + ).annotate({ description: "Qdrant vector store connection options" }), + lancedb: Schema.optional( + Schema.Struct({ + directory: Schema.optional(Schema.String), + }), + ).annotate({ description: "LanceDB vector store options" }), + searchMinScore: Schema.optional(Score).annotate({ + description: "Minimum similarity score for search results (default: 0.4)", + }), + searchMaxResults: Schema.optional(PositiveInt).annotate({ + description: "Maximum number of search results (default: 50)", + }), + embeddingBatchSize: Schema.optional(PositiveInt).annotate({ + description: "Number of code segments per embedding batch (default: 60)", + }), + scannerMaxBatchRetries: Schema.optional(PositiveInt).annotate({ + description: "Maximum retry attempts for failed embedding batches (default: 3)", + }), +}).annotate({ + identifier: "IndexingConfig", + description: "Codebase indexing configuration", +}) + export function toIndexingConfigInput(cfg: IndexingConfig | undefined): IndexingConfigInput { const provider = cfg?.provider ?? "openai" @@ -134,6 +246,9 @@ export function toIndexingConfigInput(cfg: IndexingConfig | undefined): Indexing searchMaxResults: cfg?.searchMaxResults, embeddingBatchSize: cfg?.embeddingBatchSize, scannerMaxBatchRetries: cfg?.scannerMaxBatchRetries, + kiloApiKey: cfg?.kilo?.apiKey, + kiloBaseUrl: cfg?.kilo?.baseUrl, + kiloOrganizationId: cfg?.kilo?.organizationId, openAiKey: cfg?.openai?.apiKey, ollamaBaseUrl: cfg?.ollama?.baseUrl, openAiCompatibleBaseUrl: cfg?.["openai-compatible"]?.baseUrl, diff --git a/packages/kilo-indexing/src/indexing/config-manager.ts b/packages/kilo-indexing/src/indexing/config-manager.ts index 6653551bc6d..1c5124de2dd 100644 --- a/packages/kilo-indexing/src/indexing/config-manager.ts +++ b/packages/kilo-indexing/src/indexing/config-manager.ts @@ -22,6 +22,9 @@ export interface IndexingConfigInput { searchMaxResults?: number embeddingBatchSize?: number scannerMaxBatchRetries?: number + kiloApiKey?: string + kiloBaseUrl?: string + kiloOrganizationId?: string openAiKey?: string ollamaBaseUrl?: string openAiCompatibleBaseUrl?: string @@ -51,6 +54,7 @@ export class CodeIndexConfigManager { 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 } @@ -102,6 +106,9 @@ export class CodeIndexConfigManager { this.modelDimension = undefined } + this.kiloOptions = input.kiloApiKey + ? { apiKey: input.kiloApiKey, baseUrl: input.kiloBaseUrl, organizationId: input.kiloOrganizationId } + : undefined 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 @@ -130,6 +137,9 @@ export class CodeIndexConfigManager { lancedbVectorStoreDirectory: this.lancedbVectorStoreDirectory, modelId: this.modelId, modelDimension: this.modelDimension, + kiloApiKey: this.kiloOptions?.apiKey ?? "", + kiloBaseUrl: this.kiloOptions?.baseUrl ?? "", + kiloOrganizationId: this.kiloOptions?.organizationId ?? "", openAiKey: this.openAiOptions?.apiKey ?? "", ollamaBaseUrl: this.ollamaOptions?.baseUrl ?? "", openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "", @@ -154,6 +164,8 @@ export class CodeIndexConfigManager { // LanceDB doesn't need a qdrant URL; qdrant does const hasStore = isLancedb || !!qdrant + if (provider === "kilo") + 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") @@ -194,6 +206,9 @@ export class CodeIndexConfigManager { return true // Auth changes + if ((prev.kiloApiKey ?? "") !== (this.kiloOptions?.apiKey ?? "")) return true + if ((prev.kiloBaseUrl ?? "") !== (this.kiloOptions?.baseUrl ?? "")) return true + if ((prev.kiloOrganizationId ?? "") !== (this.kiloOptions?.organizationId ?? "")) return true if ((prev.openAiKey ?? "") !== (this.openAiOptions?.apiKey ?? "")) return true if ((prev.ollamaBaseUrl ?? "") !== (this.ollamaOptions?.baseUrl ?? "")) return true if ( @@ -247,6 +262,7 @@ export class CodeIndexConfigManager { lancedbVectorStoreDirectoryPlaceholder: this.lancedbVectorStoreDirectory, modelId: this.modelId, modelDimension: this.modelDimension, + kiloOptions: this.kiloOptions, openAiOptions: this.openAiOptions, ollamaOptions: this.ollamaOptions, openAiCompatibleOptions: this.openAiCompatibleOptions, diff --git a/packages/kilo-indexing/src/indexing/embedders/kilo.ts b/packages/kilo-indexing/src/indexing/embedders/kilo.ts new file mode 100644 index 00000000000..0627f2f1fd6 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/kilo.ts @@ -0,0 +1,68 @@ +import { resolveKiloGatewayBaseUrl } from "@kilocode/kilo-gateway" +import { HEADER_FEATURE, HEADER_ORGANIZATIONID } from "@kilocode/kilo-gateway" +import { MAX_ITEM_TOKENS } from "../constants" +import type { EmbedderInfo, EmbeddingResponse, IEmbedder } from "../interfaces/embedder" +import { Log } from "../../util/log" +import { OpenAICompatibleEmbedder } from "./openai-compatible" + +const log = Log.create({ service: "embedder-kilo" }) + +export const KILO_INDEXING_FEATURE = "managed-indexing" + +export class KiloEmbedder implements IEmbedder { + private readonly embedder: OpenAICompatibleEmbedder + private readonly model: string + + constructor(input: { + apiKey: string + baseUrl?: string + organizationId?: string + modelId?: string + dimensions?: number + }) { + if (!input.apiKey) throw new Error("Kilo API key is required for embedding.") + + if (!input.modelId) throw new Error("Kilo embedding model is required.") + this.model = input.modelId + const headers: Record = { + [HEADER_FEATURE]: KILO_INDEXING_FEATURE, + ...(input.organizationId ? { [HEADER_ORGANIZATIONID]: input.organizationId } : {}), + } + + this.embedder = new OpenAICompatibleEmbedder( + resolveKiloGatewayBaseUrl({ baseURL: input.baseUrl, token: input.apiKey }), + input.apiKey, + this.model, + MAX_ITEM_TOKENS, + { headers, dimensions: input.dimensions }, + ) + } + + async createEmbeddings(texts: string[], model?: string): Promise { + try { + return await this.embedder.createEmbeddings(texts, model || this.model) + } catch (err) { + log.error("Kilo embedder error", { + err: err instanceof Error ? err.message : String(err), + location: "KiloEmbedder:createEmbeddings", + }) + throw err + } + } + + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + try { + return await this.embedder.validateConfiguration() + } catch (err) { + log.error("Kilo embedder validation error", { + err: err instanceof Error ? err.message : String(err), + location: "KiloEmbedder:validateConfiguration", + }) + throw err + } + } + + get embedderInfo(): EmbedderInfo { + return { name: "kilo" } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts index 45ef6dc08d9..5eb7500a6ae 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -28,6 +28,11 @@ interface OpenAIEmbeddingResponse { } } +type OpenAICompatibleOptions = { + headers?: Record + dimensions?: number +} + /** * OpenAI Compatible implementation of the embedder interface with batching and rate limiting. * This embedder allows using any OpenAI-compatible API endpoint by specifying a custom baseURL. @@ -40,6 +45,8 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly apiKey: string private readonly isFullUrl: boolean private readonly maxItemTokens: number + private readonly headers: Record + private readonly dimensions?: number // Global rate limiting state shared across all instances private static globalRateLimitState = { @@ -58,7 +65,13 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @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, modelId?: string, maxItemTokens?: number) { + constructor( + baseUrl: string, + apiKey: string, + modelId?: string, + maxItemTokens?: number, + options: OpenAICompatibleOptions = {}, + ) { if (!baseUrl) { throw new Error("Base URL is required for OpenAI-compatible embedder") } @@ -73,6 +86,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { this.embeddingsClient = new OpenAI({ baseURL: baseUrl, apiKey: apiKey, + defaultHeaders: options.headers, }) } catch (error) { throw error instanceof Error ? error : new Error(String(error)) @@ -82,6 +96,8 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Cache the URL type check for performance this.isFullUrl = this.isFullEndpointUrl(baseUrl) this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + this.headers = options.headers ?? {} + this.dimensions = options.dimensions } /** @@ -196,6 +212,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { method: "POST", 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, @@ -205,6 +222,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { input: batchTexts, model: model, encoding_format: "base64", + ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), }), signal, }) @@ -268,6 +286,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // when processing numeric arrays, which breaks compatibility with models using larger dimensions. // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. encoding_format: "base64", + ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), })) as OpenAIEmbeddingResponse } @@ -363,6 +382,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { input: testTexts, model: modelToUse, encoding_format: "base64", + ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), }, { timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, @@ -371,6 +391,10 @@ export class OpenAICompatibleEmbedder implements IEmbedder { )) as OpenAIEmbeddingResponse } + const error = (response as { error?: string | { message?: string } }).error + const message = typeof error === "string" ? error : error?.message + if (message) return { valid: false, error: message } + // Check if we got a valid response if (!response?.data || response.data.length === 0) { return { diff --git a/packages/kilo-indexing/src/indexing/interfaces/config.ts b/packages/kilo-indexing/src/indexing/interfaces/config.ts index 96c370748c9..817ddac977a 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/config.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/config.ts @@ -14,6 +14,7 @@ export interface CodeIndexConfig { lancedbVectorStoreDirectoryPlaceholder?: string modelId?: string modelDimension?: number + kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string } openAiOptions?: { apiKey: string } ollamaOptions?: { baseUrl: string; modelId?: string } openAiCompatibleOptions?: { baseUrl: string; apiKey: string } @@ -39,6 +40,9 @@ export type PreviousConfigSnapshot = { lancedbVectorStoreDirectory?: string modelId?: string modelDimension?: number + kiloApiKey?: string + kiloBaseUrl?: string + kiloOrganizationId?: string openAiKey?: string ollamaBaseUrl?: string openAiCompatibleBaseUrl?: string diff --git a/packages/kilo-indexing/src/indexing/interfaces/embedder.ts b/packages/kilo-indexing/src/indexing/interfaces/embedder.ts index 8ff6de5be5d..598f8ccfb97 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/embedder.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/embedder.ts @@ -29,6 +29,7 @@ export interface EmbeddingResponse { } export type AvailableEmbedders = + | "kilo" | "openai" | "ollama" | "openai-compatible" diff --git a/packages/kilo-indexing/src/indexing/interfaces/manager.ts b/packages/kilo-indexing/src/indexing/interfaces/manager.ts index 5cb4c96f91c..5d1e0433f49 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/manager.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/manager.ts @@ -37,6 +37,7 @@ export interface ICodeIndexManager { export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" export type EmbedderProvider = + | "kilo" | "openai" | "ollama" | "openai-compatible" diff --git a/packages/kilo-indexing/src/indexing/model-registry.ts b/packages/kilo-indexing/src/indexing/model-registry.ts index b5378c5c47f..12a427d210b 100644 --- a/packages/kilo-indexing/src/indexing/model-registry.ts +++ b/packages/kilo-indexing/src/indexing/model-registry.ts @@ -1,11 +1,9 @@ /** * Indexing-local embedding model metadata registry. * - * RATIONALE: The legacy codebase imported model metadata from a shared module - * (`shared/embeddingModels`) that does not exist in this package. Rather than - * recreating the full legacy module, we keep a focused registry of the models - * the indexing engine needs to know about — primarily for dimension resolution, - * default model selection, and score thresholds. + * RATIONALE: This registry only contains provider-local defaults and static + * metadata. Kilo-hosted embedding catalog metadata is fetched from Cloud so the + * model list is defined in one place. */ import type { EmbedderProvider } from "./interfaces/manager" @@ -16,8 +14,6 @@ interface ModelProfile { queryPrefix?: string } -// ASSUMPTION: These dimensions and defaults match the provider documentation -// as of 2025-Q1. Update when providers ship new embedding models. const profiles: Record> = { openai: { "text-embedding-3-small": { dimension: 1536, scoreThreshold: 0.4 }, @@ -60,6 +56,7 @@ const profiles: Record> = { } const defaults: Record = { + kilo: "", openai: "text-embedding-3-small", ollama: "nomic-embed-text", gemini: "gemini-embedding-001", @@ -86,3 +83,12 @@ export function getModelScoreThreshold(provider: EmbedderProvider, modelId: stri export function getModelQueryPrefix(provider: EmbedderProvider, modelId: string): string | undefined { return profiles[provider]?.[modelId]?.queryPrefix } + +export function normalizeKiloModelId(modelId: string | undefined): string | undefined { + return modelId +} + +export function hasModelProfile(provider: EmbedderProvider, modelId: string | undefined): boolean { + if (!modelId) return false + return profiles[provider]?.[modelId] !== undefined +} diff --git a/packages/kilo-indexing/src/indexing/service-factory.ts b/packages/kilo-indexing/src/indexing/service-factory.ts index d0c523ec7e6..aa8f14eef7e 100644 --- a/packages/kilo-indexing/src/indexing/service-factory.ts +++ b/packages/kilo-indexing/src/indexing/service-factory.ts @@ -5,6 +5,7 @@ import { getDefaultModelId } from "./model-registry" import { resolveEmbeddingProfile } from "./embedding-profile" import { OpenAiEmbedder } from "./embedders/openai" +import { KiloEmbedder } from "./embedders/kilo" import { CodeIndexOllamaEmbedder } from "./embedders/ollama" import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible" import { GeminiEmbedder } from "./embedders/gemini" @@ -63,6 +64,17 @@ export class CodeIndexServiceFactory { const config = this.configManager.getConfig() const provider = config.embedderProvider + if (provider === "kilo") { + if (!config.kiloOptions?.apiKey) throw new Error("Kilo API key is required for embedding.") + if (!config.modelId) throw new Error("Kilo embedding model is required.") + return new KiloEmbedder({ + apiKey: config.kiloOptions.apiKey, + baseUrl: config.kiloOptions.baseUrl, + organizationId: config.kiloOptions.organizationId, + modelId: config.modelId, + dimensions: config.modelDimension, + }) + } if (provider === "openai") { if (!config.openAiOptions?.apiKey) throw new Error("OpenAI API key is required for embedding.") return new OpenAiEmbedder(config.openAiOptions.apiKey, config.modelId) diff --git a/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts b/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts index 1302fd0fd28..26d33aaadc4 100644 --- a/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts +++ b/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts @@ -78,6 +78,8 @@ export function getErrorMessageForStatus(status: number | undefined, embedderTyp : "Invalid endpoint URL. Please verify the endpoint." case 429: return "Service is temporarily unavailable due to rate limiting. Please try again later." + case 402: + return "Add credits to continue, or switch to a free embedding model." default: if (status && status >= 400 && status < 600) { return "Configuration error. Please verify your embedder settings." diff --git a/packages/kilo-indexing/src/kilo-embedding-models.ts b/packages/kilo-indexing/src/kilo-embedding-models.ts new file mode 100644 index 00000000000..0fe6c62f801 --- /dev/null +++ b/packages/kilo-indexing/src/kilo-embedding-models.ts @@ -0,0 +1,34 @@ +export type KiloEmbeddingModel = { + id: string + name: string + dimension: number + scoreThreshold: number + note?: string +} + +export type KiloEmbeddingModelCatalog = { + defaultModel: string + models: KiloEmbeddingModel[] + aliases: Record +} + +export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = { + defaultModel: "", + models: [], + aliases: {}, +} + +export function normalizeKiloEmbeddingModelId(model: string | undefined, catalog = EMPTY_KILO_EMBEDDING_MODEL_CATALOG) { + if (!model) return undefined + return catalog.aliases[model] ?? model +} + +export function getKiloEmbeddingModel(model: string | undefined, catalog = EMPTY_KILO_EMBEDDING_MODEL_CATALOG) { + const id = normalizeKiloEmbeddingModelId(model, catalog) + return catalog.models.find((item) => item.id === id) +} + +export function formatKiloEmbeddingModelLabel(model: KiloEmbeddingModel): string { + const note = model.note ? `${model.note}, ` : "" + return `${model.name} (${note}${model.dimension}d)` +} diff --git a/packages/kilo-indexing/src/tree-sitter/languageParser.ts b/packages/kilo-indexing/src/tree-sitter/languageParser.ts index e7ba0c3f388..dce528004b1 100644 --- a/packages/kilo-indexing/src/tree-sitter/languageParser.ts +++ b/packages/kilo-indexing/src/tree-sitter/languageParser.ts @@ -41,7 +41,7 @@ export interface LanguageParser { } } -function resolveModulePath(specifier: string): string | undefined { +export function resolveModulePath(specifier: string): string | undefined { try { return require.resolve(specifier) } catch { @@ -53,7 +53,7 @@ function uniquePaths(paths: Array): string[] { return [...new Set(paths.filter((item): item is string => !!item))] } -function wasmDirectories(sourceDirectory?: string): string[] { +export function wasmDirectories(sourceDirectory?: string): string[] { const baseDir = sourceDirectory || __dirname const execDir = path.dirname(process.execPath) const envDir = process.env.KILO_TREE_SITTER_WASM_DIR @@ -80,7 +80,7 @@ function resolveFromDirectories(file: string, dirs: string[]): string | undefine } } -function resolveCoreRuntimeWasmPath(sourceDirectory?: string): string | undefined { +export function resolveCoreRuntimeWasmPath(sourceDirectory?: string): string | undefined { const dirs = wasmDirectories(sourceDirectory) const localPath = resolveFromDirectories("tree-sitter.wasm", dirs) if (localPath) { @@ -89,7 +89,7 @@ function resolveCoreRuntimeWasmPath(sourceDirectory?: string): string | undefine return resolveModulePath("web-tree-sitter/tree-sitter.wasm") } -function resolveLanguageWasmPath(langName: string, sourceDirectory?: string) { +export function resolveLanguageWasmPath(langName: string, sourceDirectory?: string) { const dirs = wasmDirectories(sourceDirectory) const fileName = `tree-sitter-${langName}.wasm` const wasmPath = resolveFromDirectories(fileName, dirs) 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 1656cf75b1c..cc56020ab7f 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts @@ -31,6 +31,58 @@ describe("CodeIndexConfigManager", () => { expect(cfg.getConfig().vectorStoreProvider).toBe("qdrant") }) + test("configures Kilo with hosted auth options and explicit model metadata", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + kiloBaseUrl: "https://example.test/api/gateway/", + kiloOrganizationId: "org_123", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }), + ) + + expect(cfg.isFeatureConfigured).toBe(true) + expect(cfg.getConfig().kiloOptions).toEqual({ + apiKey: "kilo-token", + baseUrl: "https://example.test/api/gateway/", + organizationId: "org_123", + }) + expect(cfg.currentModelId).toBe("mistralai/mistral-embed-2312") + expect(cfg.currentModelDimension).toBe(1024) + }) + + test("requires Kilo model metadata from Cloud config", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + }), + ) + + expect(cfg.isFeatureConfigured).toBe(false) + expect(cfg.currentModelId).toBeUndefined() + expect(cfg.currentModelDimension).toBeUndefined() + }) + + test("uses configured dimension for Kilo models outside the fallback catalog", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + modelId: "custom/model", + modelDimension: 2048, + }), + ) + + expect(cfg.currentModelId).toBe("custom/model") + expect(cfg.currentModelDimension).toBe(2048) + }) + describe("loadConfiguration restart checks", () => { test("requires restart when model changes with same dimension", () => { const cfg = new CodeIndexConfigManager(createInput({ modelId: "text-embedding-3-small" })) @@ -62,5 +114,29 @@ describe("CodeIndexConfigManager", () => { expect(result.requiresRestart).toBe(true) }) + + test("requires restart when Kilo auth changes", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "old-token", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }), + ) + + const result = cfg.loadConfiguration( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "new-token", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }), + ) + + expect(result.requiresRestart).toBe(true) + }) }) }) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/kilo.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/kilo.test.ts new file mode 100644 index 00000000000..abcbc066bcb --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/kilo.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory, setOpenAIConstructorHook } from "./__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) + +import { KiloEmbedder, KILO_INDEXING_FEATURE } from "../../../../src/indexing/embedders/kilo" + +describe("KiloEmbedder", () => { + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + setOpenAIConstructorHook(undefined) + }) + + test("uses Kilo Gateway headers and configured embedding model", async () => { + const seen: unknown[] = [] + setOpenAIConstructorHook((cfg) => seen.push(cfg)) + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + const embedder = new KiloEmbedder({ + apiKey: "kilo-token", + organizationId: "org_123", + modelId: "mistralai/mistral-embed-2312", + }) + + await embedder.createEmbeddings(["hello"]) + + expect(seen[0]).toEqual({ + baseURL: "https://api.kilo.ai/api/gateway/", + apiKey: "kilo-token", + defaultHeaders: { + "X-KILOCODE-FEATURE": KILO_INDEXING_FEATURE, + "X-KILOCODE-ORGANIZATIONID": "org_123", + }, + }) + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["hello"], + model: "mistralai/mistral-embed-2312", + encoding_format: "base64", + }) + }) + + test("normalizes custom gateway base URLs", () => { + const seen: unknown[] = [] + setOpenAIConstructorHook((cfg) => seen.push(cfg)) + + new KiloEmbedder({ + apiKey: "kilo-token", + baseUrl: "https://example.test/api/openrouter/", + modelId: "mistralai/mistral-embed-2312", + }) + + expect((seen[0] as { baseURL: string }).baseURL).toBe("https://example.test/api/gateway/") + }) +}) 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 2b80ef331dc..67aff168c98 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, mock, beforeEach } from "bun:test" import path from "path" -import { mockEmbeddingsCreate, openAIMockFactory } from "./embedders/__helpers__/openai-mock" +import { mockEmbeddingsCreate, openAIMockFactory, setOpenAIConstructorHook } from "./embedders/__helpers__/openai-mock" mock.module("openai", openAIMockFactory) import { CodeIndexServiceFactory } from "../../../src/indexing/service-factory" @@ -26,6 +26,7 @@ function createFactory(input?: Partial { beforeEach(() => { mockEmbeddingsCreate.mockReset() + setOpenAIConstructorHook(undefined) }) test("uses default LanceDB directory when config is unset", () => { @@ -152,4 +153,30 @@ describe("CodeIndexServiceFactory", () => { dimensions: 1024, }) }) + + test("creates Kilo embedder with Cloud-provided model", async () => { + const factory = createFactory({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + kiloOrganizationId: "org_123", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }) + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + const embedder = factory.createEmbedder() + await embedder.createEmbeddings(["hello"]) + + expect(embedder.embedderInfo).toEqual({ name: "kilo" }) + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["hello"], + model: "mistralai/mistral-embed-2312", + encoding_format: "base64", + }) + }) }) diff --git a/packages/kilo-indexing/test/kilocode/tree-sitter/wasm-resolution.test.ts b/packages/kilo-indexing/test/kilocode/tree-sitter/wasm-resolution.test.ts new file mode 100644 index 00000000000..d2acef7ffd7 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/tree-sitter/wasm-resolution.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises" +import { join } from "path" +import { tmpdir } from "os" +import { resolveCoreRuntimeWasmPath, resolveLanguageWasmPath } from "../../../src/tree-sitter/languageParser" + +const env = "KILO_TREE_SITTER_WASM_DIR" +const prev = process.env[env] + +describe("tree-sitter WASM resolution", () => { + afterEach(() => { + if (prev === undefined) delete process.env[env] + if (prev !== undefined) process.env[env] = prev + }) + + test("prefers installed CLI tree-sitter resources over module resolution", async () => { + const root = await mkdtemp(join(tmpdir(), "kilo-tree-sitter-wasm-")) + try { + const dir = join(root, "bin", "tree-sitter") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "tree-sitter.wasm"), "runtime") + await writeFile(join(dir, "tree-sitter-typescript.wasm"), "language") + + process.env[env] = dir + + expect(resolveCoreRuntimeWasmPath()).toBe(join(dir, "tree-sitter.wasm")) + expect(resolveLanguageWasmPath("typescript").wasmPath).toBe(join(dir, "tree-sitter-typescript.wasm")) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/kilo-jetbrains/.kilo/skills/jetbrains-ui-style/SKILL.md b/packages/kilo-jetbrains/.kilo/skills/jetbrains-ui-style/SKILL.md deleted file mode 100644 index 81667fea96d..00000000000 --- a/packages/kilo-jetbrains/.kilo/skills/jetbrains-ui-style/SKILL.md +++ /dev/null @@ -1,1335 +0,0 @@ ---- -name: jetbrains-ui-style -description: Use when creating, modifying, or reviewing Kotlin/Swing UI code for the Kilo JetBrains plugin. Applies to dialogs, settings pages, tool windows, forms, panels, component layout, sizing, spacing, colors, borders, icons, lists, trees, popups, notifications, and IntelliJ platform UI components. ---- - -# JetBrains UI Style - -Use this skill whenever creating, modifying, or reviewing UI code in `packages/kilo-jetbrains`. - -## Scope - -This skill covers Kotlin/Swing UI code for the Kilo JetBrains plugin, including dialogs, settings pages, tool windows, forms, panels, component layout, sizing, spacing, colors, borders, icons, lists, trees, popups, notifications, and IntelliJ Platform UI components. - -This plugin is a split-mode JetBrains plugin. UI code belongs in `frontend` unless there is a specific split-mode reason to place it elsewhere. - -## IntelliJ Platform Source Lookup - -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. - -Use this priority order: - -1. Check whether `$INTELLIJ_REPO` is set and points to a readable IntelliJ Community checkout. - - If `$INTELLIJ_REPO` is set, search source files under that directory first. - - Prefer implementation source files from directories such as `platform/`, `plugins/`, `java/`, `xml/`, `json/`, `jvm/`, and related modules. - - Do not assume the IntelliJ checkout is a sibling of the current repo or worktree. -2. If `$INTELLIJ_REPO` is unset, empty, unreadable, or does not appear to contain an IntelliJ source checkout, tell the user to set it up. - - Suggested instruction: `Set INTELLIJ_REPO to the path of a local intellij-community checkout, for example: export INTELLIJ_REPO=/path/to/intellij-community` - - Do not invent or hardcode a machine-specific absolute path. -3. If a local checkout is unavailable, use the public IntelliJ Community repository as the fallback source: - - https://github.com/JetBrains/intellij-community -4. Only inspect Gradle caches, downloaded jars, generated parser jars, decompiled classes, or dependency internals as a last resort when neither a local IntelliJ source checkout nor the public GitHub repository provides the needed information. - -Avoid starting searches in: - -- `~/.gradle/caches` -- `.gradle/` -- downloaded dependency jars -- generated parser artifacts -- decompiled library sources - -## Primary Rules - -- Prefer Kotlin UI DSL v2 whenever the UI is a dialog, settings page, form, options panel, or structured component layout. -- Use manual Swing only for custom rendering, transcript/chat surfaces, highly dynamic panels, virtualized lists, custom painting, or layouts the DSL cannot express cleanly. -- Keep generated UI code minimal. -- Do not set default Swing properties explicitly. For example, avoid `isOpaque = false` unless the component default differs or there is a documented rendering reason. -- Avoid hardcoded dimensions. Prefer layout semantics, component defaults, DSL sizing helpers, and `JBUI` utilities. -- Do not add decorative helper functions, wrappers, or defensive UI code unless they materially improve clarity or correctness. -- Prefer IntelliJ platform components and theme-aware APIs. -- Do not use hardcoded runtime colors in generated UI examples or implementation code. -- Derive UI colors from semantic IntelliJ theme APIs such as `UIUtil`, `JBUI.CurrentTheme`, `NamedColorUtil`, `JBColor.namedColor`, or `JBColor.lazy`. -- Do not use hardcoded font sizes or font families in generated UI examples or implementation code. -- Use component defaults, `JBFont`, `RelativeFont`, or existing component fonts for typography. -- Put user-visible strings in `*.properties` files. - -## Decision Tree - -- Use Kotlin UI DSL v2 for dialogs, settings pages, forms, options panels, and structured component layouts. -- Use Kotlin UI DSL v2 inside tool windows when rendering form-like controls or structured subpanels. -- Use standard Swing with IntelliJ Platform component replacements for tool-window shells, action-driven UI, custom components, transcript surfaces, and custom renderers. -- Use the Action System for menus and toolbars. -- Do not use Kotlin Compose. -- Do not use JCEF. - -| Need | API | -|---|---| -| Dialogs, settings pages, forms, any layout with components | Preferred: Kotlin UI DSL v2 (`com.intellij.ui.dsl.builder`) | -| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements | -| Menus and toolbars | Action System | - -## Minimal Code Rule - -Avoid explicit defaults and manual layout when the DSL can express the UI: - -```kotlin -// Avoid -val panel = JPanel(BorderLayout()).apply { - isOpaque = false - preferredSize = Dimension(420, 260) - border = EmptyBorder(12, 12, 12, 12) -} -``` - -Prefer DSL and platform spacing: - -```kotlin -panel { - row(KiloBundle.message("settings.name")) { - textField() - .align(AlignX.FILL) - .resizableColumn() - } -} -``` - -Avoid explicit default properties: - -```kotlin -// Avoid unless there is a documented rendering reason -component.isOpaque = false -``` - -Prefer omitting the assignment and relying on the component/platform default. - -Avoid raw fixed sizes: - -```kotlin -// Avoid -component.preferredSize = Dimension(300, 40) -``` - -Prefer semantic sizing: - -```kotlin -textField() - .columns(COLUMNS_MEDIUM) - .align(AlignX.FILL) -``` - -If a fixed size is genuinely required, use `JBUI.size(...)` or `JBUI.scale(...)` and keep the reason obvious from context. - -## Do Not Use Kotlin Compose - -Do not use Kotlin Compose or `intellij.platform.compose` in this plugin. The JetBrains modular template uses Compose for its demo tool window, but Kilo should use standard Swing with IntelliJ Platform components only. Keep all plugin UI in the existing Swing-based stack. - -## Do Not Use JCEF - -Do not use JCEF (`JBCefBrowser`) in this plugin. JCEF does not work in JetBrains remote development (split mode): the frontend process runs on the client machine but JCEF requires a display on the host, making it effectively unusable for remote users. Use standard Swing with IntelliJ Platform components for all UI. - -## Kotlin UI DSL v2 - -Use Kotlin UI DSL v2 as the default way to build UI for dialogs, settings pages, forms, and any layout composed of standard components. It produces correct spacing, label alignment, HiDPI scaling, and accessibility automatically. Only fall back to manual Swing layout when you need a fully custom component, such as a canvas, rich list renderer, transcript surface, or tool-window chrome that the DSL cannot express. - -The top-level builder is `panel { }` and returns `DialogPanel`. Structure is `panel -> row -> cells`. Cell factory methods such as `textField()`, `checkBox()`, and `label()` add components. The DSL lives in `com.intellij.ui.dsl.builder`. - -To explore DSL capabilities interactively: Tools -> Internal Actions -> UI -> Kotlin UI DSL -> UI DSL Showcase. This requires internal mode: `-Didea.is.internal=true`. - -### Basics: Panel, Row, Cell - -Rows occupy full width. The last cell in a row takes remaining space. Rows have a `layout` property. - -```kotlin -panel { - row("Row1 label:") { - textField() - label("Some text") - } - row("Row2:") { - label("This text is aligned with previous row") - } -} -``` - -### Row Layout - -Every row uses one of three layouts. Default is `LABEL_ALIGNED` when a label is provided for the row, `INDEPENDENT` otherwise. - -| Layout | Behavior | -|---|---| -| `LABEL_ALIGNED` | Label column and content columns, aligned across rows | -| `INDEPENDENT` | All cells are independent, no cross-row alignment | -| `PARENT_GRID` | Cells align with the parent grid columns across rows | - -```kotlin -panel { - row("PARENT_GRID:") { - label("Col 1") - label("Col 2") - }.layout(RowLayout.PARENT_GRID) - - row("PARENT_GRID:") { - textField() - textField() - }.layout(RowLayout.PARENT_GRID) - - row("LABEL_ALIGNED default with label:") { - textField() - } - - row { - label("INDEPENDENT default without label:") - textField() - } -} -``` - -### Components Reference - -All cell factory methods available inside `row { }`: - -| Method | Description | -|---|---| -| `checkBox("text")` | Checkbox | -| `threeStateCheckBox("text")` | Three-state checkbox | -| `radioButton("text", value)` | Radio button, must be inside `buttonsGroup {}` | -| `button("text") {}` | Push button | -| `actionButton(action)` | Icon button bound to an `AnAction` | -| `actionsButton(action1, action2, ...)` | Dropdown actions button | -| `segmentedButton(items) { text = it }` | Segmented control | -| `tabbedPaneHeader(items)` | Tab header strip | -| `label("text")` | Static label | -| `text("html")` | Rich text with links, icons, line-width control | -| `link("text") {}` | Focusable clickable link | -| `browserLink("text", "url")` | Opens URL in browser | -| `dropDownLink("default", listOf(...))` | Dropdown link selector | -| `icon(AllIcons.*)` | Icon display | -| `contextHelp("description", "title")` | Help icon with popup | -| `textField()` | Text input | -| `passwordField()` | Password input | -| `textFieldWithBrowseButton()` | Text field and browse dialog | -| `expandableTextField()` | Expandable multi-line text field | -| `extendableTextField()` | Text field with extension icons | -| `intTextField(range)` | Integer input with validation | -| `spinner(intRange)` / `spinner(doubleRange, step)` | Numeric spinner | -| `slider(min, max, minorTick, majorTick)` | Slider, use `.labelTable()` for tick labels | -| `textArea()` | Multi-line text, use `.rows(n)` and `.align(AlignX.FILL)` | -| `comboBox(items)` | Combo box / dropdown | -| `comment("text")` | Gray comment text, standalone | -| `cell(component)` | Wrap any arbitrary Swing component | -| `scrollCell(component)` | Wrap component in a scroll pane | -| `cell()` | Empty placeholder cell for grid alignment | - -Key component examples: - -```kotlin -panel { - var mode = "auto" - - buttonsGroup { - row("Mode:") { - radioButton("Automatic", "auto") - radioButton("Manual", "manual") - } - }.bind({ mode }, { mode = it }) - - row("Slider:") { - slider(0, 10, 1, 5) - .labelTable(mapOf( - 0 to JBLabel("0"), - 5 to JBLabel("5"), - 10 to JBLabel("10"), - )) - } - - row { - label("Text area:") - .align(AlignY.TOP) - .gap(RightGap.SMALL) - textArea() - .rows(5) - .align(AlignX.FILL) - }.layout(RowLayout.PARENT_GRID) -} -``` - -### Component Labels - -Labels for modifiable components must be connected via one of two methods. This ensures correct spacing, mnemonic support, and accessibility. - -- Row label: `row("&Label:") { textField() }`, mnemonic via `&`, label in left column -- Cell label: `textField().label("&Label:", LabelPosition.TOP)`, label attached to cell, optionally on top - -```kotlin -panel { - row("&Row label:") { - textField() - textField() - .label("Cell label at &left:") - } - row { - textField() - .label("Cell label at &top:", LabelPosition.TOP) - } -} -``` - -When a row contains a `checkBox` or `radioButton`, the DSL automatically increases space after the row label per IntelliJ UI Guidelines. - -### Comments - -Three types of comments, each with different placement and semantics: - -| Type | Method | Placement | -|---|---|---| -| Cell comment, bottom | `cell.comment("text")` | Below the cell | -| Cell comment, right | `cell.commentRight("text")` | Right of the cell | -| Cell context help | `cell.contextHelp("text", "title")` | Help icon with popup | -| Row comment | `row.rowComment("text")` | Below the entire row | -| Arbitrary comment | `comment("text")` | Standalone gray text | - -```kotlin -panel { - row { - textField() - .comment("Bottom comment") - textField() - .commentRight("Right comment") - textField() - .contextHelp("Help popup text") - } - - row("Label:") { - textField() - }.rowComment("This comment sits below the whole row") - - row { - comment("Standalone comment, supports links and  icons") - } -} -``` - -Comments support HTML with clickable links, bundled icons via ``, and line width control via `maxLineLength`. Use `MAX_LINE_LENGTH_NO_WRAP` to prevent wrapping. - -### Groups and Structure - -| Method | Grid | Description | -|---|---|---| -| `panel {}` | Own grid | Sub-panel occupying full width | -| `rowsRange {}` | Parent grid | Grouped rows sharing parent grid, useful with `enabledIf` | -| `group("Title") {}` | Own grid | Titled section with vertical spacing before/after | -| `groupRowsRange("Title") {}` | Parent grid | Titled section sharing parent grid alignment | -| `collapsibleGroup("Title") {}` | Own grid | Expandable section, Tab-focusable, supports mnemonics | -| `buttonsGroup("Title") {}` | None | Groups `radioButton` or `checkBox` under a title | -| `separator()` | None | Horizontal separator line | -| Row `panel {}` | Own grid | Sub-panel inside a cell | - -```kotlin -panel { - group("Settings") { - row("Name:") { textField() } - row("Path:") { textFieldWithBrowseButton() } - } - - collapsibleGroup("Advanced") { - row("Timeout:") { intTextField(0..1000) } - } - - var enabled = true - buttonsGroup("Mode:") { - row { radioButton("Automatic", true) } - row { radioButton("Manual", false) } - }.bind({ enabled }, { enabled = it }) - - separator() - - row { - label("Nested panels:") - panel { - row("Sub row 1:") { textField() } - row("Sub row 2:") { textField() } - } - } -} -``` - -### Gaps and Spacing - -- Horizontal gaps: `cell.gap(RightGap.SMALL)` between a label-like checkbox and its related field. Medium gap is the default between cells. -- Two-column layout: `twoColumnsRow({}, {})` or `gap(RightGap.COLUMNS)` with `.layout(RowLayout.PARENT_GRID)`. -- Left indent: `indent {}` for indented sub-content. -- Vertical gaps: `.topGap(TopGap.MEDIUM)` / `.bottomGap(BottomGap.MEDIUM)` on rows to separate unrelated groups. Attach gaps to the related row so hiding rows does not break layout. - -Common Kotlin UI DSL spacing values from `IntelliJSpacingConfiguration`: - -| Constant | Unscaled px | Usage | -|---|---|---| -| `RightGap.SMALL` | 6 | Related inline components, such as field to button | -| Default row/cell gap | 16 | Automatic gap between components in a row | -| `RightGap.COLUMNS` | 60 | Logical column separation | -| `TopGap.SMALL` / `BottomGap.SMALL` | 8 | Minor section separation | -| `TopGap.MEDIUM` / `BottomGap.MEDIUM` | 20 | Major section or group separation | -| `verticalComponentGap` | 6 | Automatic gap between rows | - -These are semantic IntelliJ spacing constants. They handle DPI scaling and the IntelliJ spacing model, but they are not theme keys and are not overridden by theme JSON. - -```kotlin -panel { - group("Horizontal Gaps") { - row { - val cb = checkBox("Use mail:") - .gap(RightGap.SMALL) - textField() - .enabledIf(cb.selected) - } - row("Width:") { - textField() - .gap(RightGap.SMALL) - label("pixels") - } - } - - group("Indent") { - row { label("Not indented") } - indent { - row { label("Indented row") } - } - } - - group("Two Columns") { - twoColumnsRow({ - checkBox("First column") - }, { - checkBox("Second column") - }) - } - - group("Vertical Gaps") { - row { checkBox("Option 1") } - row { checkBox("Option 2") } - row { checkBox("Unrelated option") } - .topGap(TopGap.MEDIUM) - } -} -``` - -### Enabled and Visible State - -Bind enabled/visible state to a checkbox or other observable. Works on rows, `indent {}` blocks, `rowsRange {}`, and individual cells. - -```kotlin -panel { - group("Enabled") { - lateinit var cb: Cell - row { cb = checkBox("Enable options") } - indent { - row { checkBox("Option 1") } - row { checkBox("Option 2") } - }.enabledIf(cb.selected) - } - - group("Visible") { - lateinit var cb: Cell - row { cb = checkBox("Show options") } - indent { - row { checkBox("Option 1") } - row { checkBox("Option 2") } - }.visibleIf(cb.selected) - } -} -``` - -### Binding - -Bind component values to model properties. Values are applied on `DialogPanel.apply()`, checked with `.isModified()`, and reverted with `.reset()`. - -| Method | Component | -|---|---| -| `bindSelected(model::prop)` | checkBox | -| `bindText(model::prop)` | textField | -| `bindIntText(model::prop)` | intTextField | -| `bindItem(model::prop.toNullableProperty())` | comboBox | -| `bindValue(model::prop)` | slider | -| `bindIntValue(model::prop)` | spinner | -| `buttonsGroup {}.bind(model::prop)` | radio group | - -```kotlin -enum class Theme { LIGHT, DARK } - -data class Settings( - var name: String = "", - var count: Int = 0, - var enabled: Boolean = false, - var theme: Theme = Theme.LIGHT, -) - -val model = Settings() - -val panel = panel { - row("Name:") { - textField().bindText(model::name) - } - row("Count:") { - intTextField(0..100).bindIntText(model::count) - } - row { - checkBox("Enabled").bindSelected(model::enabled) - } - buttonsGroup("Theme:") { - row { radioButton("Light", Theme.LIGHT) } - row { radioButton("Dark", Theme.DARK) } - }.bind(model::theme) -} - -panel.isModified() -panel.apply() -panel.reset() -``` - -### Validation - -Attach input validation rules to cells. Rules run continuously and display inline error/warning indicators. - -```kotlin -panel { - row("Username:") { - textField() - .columns(COLUMNS_MEDIUM) - .cellValidation { - addInputRule("Must not be empty") { - it.text.isBlank() - } - } - } - row("Port:") { - textField() - .columns(COLUMNS_MEDIUM) - .cellValidation { - addInputRule("Contains non-numeric characters", level = Level.WARNING) { - it.text.contains(Regex("[^0-9]")) - } - } - } -} -``` - -Activate validators by calling `dialogPanel.registerValidators(disposable)` after creating the panel. - -### Tips and Common Patterns - -| Pattern | Usage | -|---|---| -| `.bold()` | Bold text on any cell | -| `.columns(COLUMNS_MEDIUM)` | Set preferred width of textField / comboBox / textArea | -| `.text("initial")` | Set initial text on text components | -| `.resizableColumn()` | Column fills remaining horizontal space | -| `cell()` | Empty placeholder cell for grid alignment | -| `.widthGroup("name")` | Equalize widths across rows, cannot combine with `AlignX.FILL` | -| `.align(AlignX.FILL)` | Stretch component to fill available width | -| `.align(AlignY.TOP)` | Top-align component in its cell | -| `.applyToComponent { }` | Direct access to the underlying Swing component | -| `.selected(true)` | Default-select a radioButton when no bound value matches | -| `.gap(RightGap.COLUMNS)` | Column-level gap for multi-column layouts | - -```kotlin -panel { - row { label("Title").bold() } - - row("Name:") { - textField() - .columns(COLUMNS_MEDIUM) - .resizableColumn() - .align(AlignX.FILL) - } - - row("") { - textField() - }.rowComment("""Use row("") for an empty label column that aligns with labeled rows""") - - row { - text("Comment-colored text") - .applyToComponent { foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND } - } -} -``` - -## Manual Swing - -Use manual Swing only when Kotlin UI DSL cannot express the UI cleanly. - -Preferred manual Swing rules: - -- Use IntelliJ platform components instead of raw Swing components. -- Use layout managers and component defaults rather than fixed sizes. -- Use `JBUI.Borders.empty(...)`, `JBUI.insets(...)`, `JBUI.size(...)`, and `JBUI.scale(...)` instead of raw AWT/Swing sizing primitives. -- Do not set default properties explicitly. -- Keep custom components small and focused. -- For painting, use theme-aware colors and `JBUI.scale(...)` for pixel values. - -## Swing Component State - -Swing is retained-mode UI, not React-style declarative rendering. For dynamic Swing surfaces such as session cards, transcript parts, hover rows, and collapsible panels, build a stable component tree once and then mutate existing components in response to model or interaction changes. - -- Do not use a React-style `state -> render()` loop for Swing components. -- Avoid card-level `render()` methods that remove/recreate headers, text areas, markdown views, controls, or scroll panes after every click, hover, or model update. -- Give each renderer an `update(model)` method that applies model changes directly to existing UI components. -- In `update(model)`, compare before assigning when practical: label text, icons, foregrounds, fonts, body text, visibility, cursor, and containment. -- Do not duplicate state in booleans when Swing component state already answers the question. -- Derive expanded state from containment, such as `scroll.parent === root`, rather than maintaining an `open` boolean. -- Derive hover state from the current header background or other component property, rather than maintaining a `hover` boolean. -- Expand/collapse should attach or detach the existing body component and update controls only if attachment changed. -- Hover should update only the affected component, usually the header background, and repaint only that component when the effective color changed. -- Lazy-create expensive bodies such as `JBTextArea`, `JBScrollPane`, markdown panes, and HTML panes on first expansion or first direct access. -- Keep source/model text separately only when needed to initialize a lazy component before it exists. -- Store the current style snapshot so lazily-created children receive the latest `SessionStyle` when they are first created. -- Parent containers should refresh for add/remove/reorder operations, not automatically after every delegated child update or streaming delta. -- Child views should call `revalidate()`/`repaint()` only when they changed preferred size, visibility, containment, or paint output. -- Empty deltas, identical text, unchanged styles, repeated hover values, and no-op toggles should not repaint the whole card. -- Private helpers named `render()` in Swing views tend to invite full tree rebuilds. Prefer names that describe the mutation, such as `syncBody()`, `syncArrow()`, `syncHtml()`, or `applyModel()`. -- It is fine for markdown internals to call library APIs such as `renderer.render(...)`; the problem is UI-level rerendering that rebuilds Swing component trees. - -Avoid this React-style pattern: - -```kotlin -private var open = false - -fun toggle() { - open = !open - render() -} - -private fun render() { - root.removeAll() - header.removeAll() - header.add(title) - if (open) root.add(JBScrollPane(JBTextArea(text)), BorderLayout.CENTER) - revalidate() - repaint() -} -``` - -Prefer mutating stable Swing components and deriving state from the UI: - -```kotlin -fun isExpanded(): Boolean = scroll?.parent === root - -fun toggle() { - if (!canExpand()) return - val changed = if (isExpanded()) collapse() else expand() - if (!changed) return - syncArrow() - revalidate() - repaint() -} - -private fun expand(): Boolean { - if (isExpanded()) return false - root.add(scroll(), BorderLayout.CENTER) - return true -} - -private fun collapse(): Boolean { - val pane = scroll ?: return false - if (pane.parent !== root) return false - root.remove(pane) - return true -} - -private fun scroll(): JBScrollPane { - scroll?.let { return it } - val pane = JBScrollPane(body()) - scroll = pane - return pane -} -``` - -For tests around dynamic Swing components, assert retained component behavior instead of only final text: - -- Collapsed components start unattached, and expensive bodies are not created until first expansion. -- First expansion creates the body once, collapse detaches it, and re-expansion reuses the same instance. -- `isExpanded()` agrees with actual containment. -- `update(model)` changes existing labels/body text without duplicating components. -- Updates while collapsed do not eagerly create lazy bodies unless direct body access requires it. -- No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. - -## Platform Spacing and Insets - -Use semantic spacing APIs before inventing numbers. Do not copy fallback values from IntelliJ source into generated UI code; those values are defaults behind theme keys and may change by UI theme, New UI, OS, or IDE version. - -| Need | Preferred source | -|---|---| -| Dialogs, forms, settings | Kotlin UI DSL row/group/indent/gap APIs | -| Component gaps in Kotlin UI DSL | `RightGap.SMALL`, `RightGap.COLUMNS`, `TopGap.MEDIUM`, `BottomGap.MEDIUM`, `.customize(UnscaledGaps(...))` as a last resort | -| Manual Swing empty padding | `JBUI.Borders.empty(...)` | -| Manual Swing insets | `JBUI.insets(...)`, `JBUI.emptyInsets()`, `JBUI.insetsTop(...)`, `JBUI.insetsLeft(...)`, `JBUI.insetsBottom(...)`, `JBUI.insetsRight(...)` | -| Manual Swing dimensions | `JBUI.size(...)`, `JBDimension`, `JBUI.scale(...)` | -| Side separators | `JBUI.Borders.customLineTop(...)`, `customLineBottom(...)`, `customLineLeft(...)`, `customLineRight(...)` | -| Composed borders | `JBUI.Borders.compound(...)`, `JBUI.Borders.merge(...)` | -| Simple `BorderLayout` panels | `JBUI.Panels.simplePanel(...)`, `BorderLayoutPanel` | -| Simple vertical custom Swing groups | `VerticalLayout` | -| Fluent platform panels | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()`, `.withBackground(...)` | - -For themeable custom layouts, keep the `JBValue.UIInteger` object and call `.get()` during layout and size calculation. Do not cache the resolved `Int`, because that freezes the value from the current Look and Feel and scale context. - -```kotlin -private val gap = JBValue.UIInteger("KiloComponent.gap", 16) - -override fun doLayout() { - val value = gap.get() - // layout using value -} - -override fun getPreferredSize(): Dimension { - val value = gap.get() - // calculate using value -} -``` - -Avoid resolving the value once in a constructor or property initializer: - -```kotlin -private val gap = JBValue.UIInteger("KiloComponent.gap", 16).get() -``` - -When raw AWT layout constructors are unavoidable, legacy Swing defaults exist but must still be scaled before use: - -| Constant | Value | -|---|---| -| `UIUtil.DEFAULT_HGAP` | 10 | -| `UIUtil.DEFAULT_VGAP` | 4 | -| `UIUtil.LARGE_VGAP` | 12 | - -```kotlin -val panel = JPanel(BorderLayout(JBUI.scale(UIUtil.DEFAULT_HGAP), 0)) -``` - -Use `JBUI.CurrentTheme` for context-specific spacing and dimensions: - -| UI area | Preferred source | -|---|---| -| Action list rows | `JBUI.CurrentTheme.ActionsList.cellPadding()` | -| Action icon/text gaps | `JBUI.CurrentTheme.ActionsList.elementIconGap()` | -| Action mnemonic gaps | `JBUI.CurrentTheme.ActionsList.mnemonicIconGap()` / `mnemonicInsets()` | -| Popup selection interior | `JBUI.CurrentTheme.Popup.Selection.innerInsets()` | -| Popup separators | `JBUI.CurrentTheme.Popup.separatorInsets()` / `separatorLabelInsets()` | -| Popup header/search field | `JBUI.CurrentTheme.Popup.headerInsets()`, `searchFieldBorderInsets()`, `searchFieldInputInsets()` | -| Complex popup headers | `JBUI.CurrentTheme.ComplexPopup.headerInsets()` | -| Complex popup text fields | `JBUI.CurrentTheme.ComplexPopup.textFieldBorderInsets()` / `textFieldInputInsets()` | -| Search Everywhere / big popup header | `JBUI.CurrentTheme.BigPopup.headerBorder()` / `headerToolbarInsets()` / `tabInsets()` | -| Popup advertiser/footer | `JBUI.CurrentTheme.Advertiser.border()` | -| Toolbar buttons | `JBUI.CurrentTheme.Toolbar.toolbarButtonInsets()` / `mainToolbarButtonInsets()` | -| Toolbar containers | `JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets()` / `verticalToolbarInsets()` | -| Tool-window headers | `JBUI.CurrentTheme.ToolWindow.headerLabelLeftRightInsets()` / `headerToolbarLeftRightInsets()` / `headerTabLeftRightInsets()` | -| Lists | `JBUI.CurrentTheme.List.rowHeight()` | -| Trees | `JBUI.CurrentTheme.Tree.rowHeight()` | -| VCS log rows | `JBUI.CurrentTheme.VersionControl.Log.rowHeight()` / `verticalPadding()` | -| Help tooltips | `JBUI.CurrentTheme.HelpTooltip.defaultTextBorderInsets()` / `smallTextBorderInsets()` | -| Navigation bar items | `JBUI.CurrentTheme.NavBar.itemInsets()` | - -When using Kotlin UI DSL, prefer DSL semantics over manual padding: - -```kotlin -panel { - group(KiloBundle.message("settings.section")) { - row(KiloBundle.message("settings.name")) { - textField() - .align(AlignX.FILL) - .resizableColumn() - } - indent { - row { checkBox(KiloBundle.message("settings.enabled")) } - } - } -} -``` - -When manual Swing is necessary, get padding from platform utilities: - -```kotlin -val panel = JBUI.Panels.simplePanel(content) - .withBorder(JBUI.Borders.empty(JBUI.CurrentTheme.Popup.headerInsets())) -``` - -For row heights, prefer platform row height APIs over fixed heights: - -```kotlin -component.preferredSize = JBDimension(0, JBUI.CurrentTheme.Tree.rowHeight()) -``` - -## Generic Platform Utilities - -Use existing platform utility classes instead of hand-rolled labels, renderers, borders, colors, fonts, and validation behavior. - -| Need | Preferred API | -|---|---| -| Concise border layout | `BorderLayoutPanel`, `JBUI.Panels.simplePanel(...)` | -| Platform panel helpers | `JBPanel.withBorder(...)`, `.andTransparent()`, `.andOpaque()` | -| Platform label behavior | `JBLabel` | -| Context help | `ContextHelpLabel` | -| Links | `HyperlinkLabel`, `LinkLabel`, Kotlin UI DSL `link(...)` / `browserLink(...)` | -| Error/validation label in legacy UI | `ErrorLabel` only when an inline validation component is required and DSL validation is not available | -| High-performance rich fragments | `SimpleColoredComponent` | -| List renderers | `ColoredListCellRenderer`, Kotlin `listCellRenderer` / `textListCellRenderer` / `LcrRow` | -| Tree renderers | `ColoredTreeCellRenderer` | -| Renderer text styles | `SimpleTextAttributes` | -| Editable list toolbar | `ToolbarDecorator` | -| Platform list | `JBList` | -| Platform tree | `Tree` | - -For renderer text, prefer `SimpleTextAttributes` over direct color/font mutation: - -```kotlin -append(text, SimpleTextAttributes.ERROR_ATTRIBUTES) -append(detail, SimpleTextAttributes.GRAYED_ATTRIBUTES) -append(link, SimpleTextAttributes.LINK_ATTRIBUTES) -``` - -## Tool Windows - -- Register declaratively in module XML via `com.intellij.toolWindow` extension point. -- Implement `ToolWindowFactory.createToolWindowContent()`, called lazily on first click. -- Use `SimpleToolWindowPanel(vertical = true)` as a convenient base for toolbar and content layout. -- Add tabs via `ToolWindow.contentManager`: create content with `ContentFactory.getInstance().createContent(component, title, isLockable)`, then `contentManager.addContent()`. -- For conditional display, implement `ToolWindowFactory.isApplicableAsync(project)`. -- Always use `ToolWindowManager.invokeLater()` instead of `Application.invokeLater()` for tool-window-related EDT tasks. - -## Dialogs - -- Extend `DialogWrapper`. -- Call `init()` from the constructor. -- Override `createCenterPanel()` to return UI content. -- Prefer Kotlin UI DSL v2 for panel contents. -- Override `getPreferredFocusedComponent()` for initial focus. -- Override `getDimensionServiceKey()` for size persistence when useful. -- Show with `showAndGet()` for modal boolean result, or `show()` and then `getExitCode()`. -- For input validation, call `initValidation()` in the constructor and override `doValidate()` to return `null` if valid or `ValidationInfo(message, component)` if invalid. - -## Platform Components - -Always use IntelliJ platform components instead of raw Swing where an equivalent exists. - -| Instead of | Use | Package | -|---|---|---| -| `JLabel` | `JBLabel` | `com.intellij.ui.components` | -| `JTextField` | `JBTextField` | `com.intellij.ui.components` | -| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` | -| `JList` | `JBList` | `com.intellij.ui.components` | -| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` | -| `JTable` | `JBTable` | `com.intellij.ui.table` | -| `JTree` | `Tree` | `com.intellij.ui.treeStructure` | -| `JSplitPane` | `JBSplitter` | `com.intellij.ui` | -| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` | -| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` | -| Raw runtime colors | `UIUtil`, `JBUI.CurrentTheme`, `NamedColorUtil`, `JBColor.namedColor`, `JBColor.lazy` | `com.intellij.util.ui`, `com.intellij.ui` | -| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` | -| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` | - -Inspection `Plugin DevKit | Code | Undesirable class usage` highlights raw Swing usage where a platform replacement exists. - -## Multi-line and Rich Text - -| Need | Component | -|---|---| -| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) | -| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` | -| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` | -| High-performance colored text fragments in trees/lists/tables | `SimpleColoredComponent` | -| Plain-text newline splitting | `MultiLineLabel`, legacy, do not use in new code | - -- Build HTML programmatically with `HtmlChunk` / `HtmlBuilder` (`com.intellij.openapi.util.text.HtmlChunk`). Avoid raw HTML string concatenation because it risks injection and breaks localization. -- For simple wrapping/escaping, use `XmlStringUtil.wrapInHtml(content)`, `XmlStringUtil.wrapInHtmlLines(lines...)`, and `XmlStringUtil.escapeString(text)`. -- For selectable/copyable label text, use `JBLabel.setCopyable(true)`, which switches internally to `JEditorPane` while preserving label appearance. Use `setAllowAutoWrapping(true)` for auto-wrap. -- When creating a `JEditorPane` manually, always use `HTMLEditorKitBuilder` instead of constructing `HTMLEditorKit` directly: `editorPane.setEditorKit(HTMLEditorKitBuilder.simple())` or `.withWordWrapViewFactory().build()`. -- For single-line overflow/ellipsis, use `SwingTextTrimmer`. Do not manually truncate strings. -- Put all user-visible strings in `*.properties` files. HTML markup in values is acceptable. - -## Theme-Derived Colors - -Do not hardcode runtime colors. IntelliJ UI colors must come from the current theme, component state, editor color scheme, or a centralized semantic named color key. - -Prefer semantic helpers for common UI roles: - -| Need | Preferred API | -|---|---| -| Ordinary label text | `UIUtil.getLabelForeground()` | -| Secondary/help text | `UIUtil.getContextHelpForeground()` | -| Info label text | `UIUtil.getLabelInfoForeground()` | -| Success label text | `UIUtil.getLabelSuccessForeground()` | -| Error label text | `UIUtil.getErrorForeground()` | -| Warning label text | `JBUI.CurrentTheme.Label.warningForeground()` | -| Generic label foreground by state | `JBUI.CurrentTheme.Label.foreground(selected)` / `disabledForeground(selected)` | -| Inactive secondary text | `NamedColorUtil.getInactiveTextColor()` | -| Bounds and standard border color | `NamedColorUtil.getBoundsColor()` / `JBColor.border()` | -| Tooltips | `UIUtil.getToolTipForeground()` / `getToolTipBackground()` / `JBUI.CurrentTheme.Tooltip.*` | -| Links | `JBUI.CurrentTheme.Link.Foreground.ENABLED` / `HOVERED` / `PRESSED` / `VISITED` / `DISABLED` / `SECONDARY` | -| List renderer text/background | `UIUtil.getListForeground(selected, focused)` / `UIUtil.getListBackground(selected, focused)` | -| Tree renderer text/background | `UIUtil.getTreeForeground(selected, focused)` / `UIUtil.getTreeBackground(selected, focused)` | -| Non-selected tree content | `UIUtil.getTreeForeground()` / `UIUtil.getTreeBackground()` | - -Prefer `JBUI.CurrentTheme` for component-area colors and borders: - -| Need | Preferred API | -|---|---| -| Popup background | `JBUI.CurrentTheme.Popup.BACKGROUND` | -| Complex popup header | `JBUI.CurrentTheme.ComplexPopup.HEADER_BACKGROUND` | -| Separators | `JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()` | -| Advertiser/footer surfaces | `JBUI.CurrentTheme.Advertiser.foreground()` / `background()` / `border()` | -| Links | `JBUI.CurrentTheme.Link.Foreground.ENABLED` | -| Tree selection state | `JBUI.CurrentTheme.Tree.foreground(...)` and selection helpers | -| Validation errors | `JBUI.CurrentTheme.Validator.errorBorderColor()` / `errorBackgroundColor()` | -| Validation warnings | `JBUI.CurrentTheme.Validator.warningBorderColor()` / `warningBackgroundColor()` | -| Focus error/warning outlines | `JBUI.CurrentTheme.Focus.errorColor(active)` / `warningColor(active)` | -| Banner-like surfaces | `JBUI.CurrentTheme.Banner.*` | -| Notification tool-window surfaces | `JBUI.CurrentTheme.NotificationError.*`, `NotificationWarning.*`, `NotificationInfo.*` | -| Icon badges | `JBUI.CurrentTheme.IconBadge.*` | - -Use `JBColor.lazy { ... }` for colors that depend on runtime state or the active editor color scheme: - -```kotlin -val bg = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } -``` - -Use `JBColor.namedColor("Some.Semantic.Key", fallback)` only when defining or consuming a semantic color key. Prefer a fallback from an existing theme API over numeric RGB values in examples. - -For HTML/CSS snippets, compute the color from a theme API and convert it with `ColorUtil.toHtmlColor(...)`. Do not write hardcoded CSS color literals. - -```kotlin -val fg = ColorUtil.toHtmlColor(UIUtil.getContextHelpForeground()) -val html = HtmlChunk.span("color: $fg").addText(text) -``` - -Recommended examples: - -```kotlin -label.applyToComponent { - foreground = UIUtil.getContextHelpForeground() - font = JBFont.medium() -} -``` - -```kotlin -foreground = if (selected) { - UIUtil.getTreeForeground(true, hasFocus) -} else { - UIUtil.getContextHelpForeground() -} -``` - -Avoid inline `Color(...)`, numeric `JBColor(...)`, `Gray.xNN`, `JBColor.GRAY`, and hex color literals in runtime UI code. The exception is a centralized semantic color definition with a named color key when no existing platform key exists. - -## Theme-Derived Fonts - -Do not hardcode font sizes or font families. IntelliJ fonts must come from component defaults, platform typography helpers, or existing component fonts. - -- Prefer component default fonts when no style change is needed. -- Use `JBFont.h1()`, `JBFont.h2()`, `JBFont.h3()`, and `JBFont.h4()` for headings. -- Use `.asBold()`, `.asItalic()`, and `.asPlain()` for style changes on `JBFont` values. -- Use `JBFont.regular()`, `JBFont.medium()`, and `JBFont.small()` for regular and secondary text. -- Use `UIUtil.getLabelFont(UIUtil.FontSize.SMALL)` / `MINI` only when an API expects a raw `Font` and `JBFont` / `RelativeFont` is not a good fit. -- Use `RelativeFont` when adjusting an existing component font relatively. -- Use `RelativeFont.fromResource(...)` for theme-configurable font offsets when matching platform component patterns. -- Use `JBUI.Fonts.smallFont()` only when matching an existing platform component pattern that expects it. -- If editor-like text needs editor typography, derive it from the editor color scheme or existing editor component instead of hardcoding a font. - -For errors, grayed text, shortcuts, and links in renderers, prefer `SimpleTextAttributes.ERROR_ATTRIBUTES`, `GRAYED_ATTRIBUTES`, `SHORTCUT_ATTRIBUTES`, and `LINK_ATTRIBUTES` rather than manually selecting colors and styles. - -Recommended examples: - -```kotlin -label(title).applyToComponent { - font = JBFont.h3().asBold() -} -``` - -```kotlin -RelativeFont.BOLD.install(label) -``` - -Avoid `Font("...")`, raw font sizes, and `deriveFont(14f)` style examples. - -## Kilo Session Styling - -For session/chat UI under `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/`, keep general UI chrome separate from transcript styling. - -- Use `ai.kilocode.client.ui.UiStyle` for general UI helpers: `Colors`, `Borders`, `Insets`, `Gap`, `Space`, `Size`, and `Buttons`. -- Use `ai.kilocode.client.session.ui.SessionStyle` for session transcript/content styling that follows editor settings or live transcript configuration. -- `SessionStyle` is for user/assistant transcript text, reasoning text, tool output, prompt editor text, and markdown renderer fields such as `font`, `codeFont`, and future link/code-block styles. -- Long-lived session components should implement or propagate `SessionStyleTarget.applyStyle(style)` instead of reading editor globals once in constructors. -- Do not call `EditorColorsManager.getInstance().globalScheme` directly from individual views unless extending `SessionStyle.current()`. -- New transcript renderers must apply the current `SessionStyle` snapshot and ensure child parts created after a style change receive the queued style. -- Keep non-transcript IDE chrome on platform/UI style unless it is intentionally part of the transcript reading experience. - -Use `UiStyle` for the surrounding card chrome and `SessionStyle` for transcript content inside it: - -```kotlin -class ExamplePartView : PartView() { - override fun applyStyle(style: SessionStyle) { - var changed = false - if (md.font != style.transcriptFont) { - md.font = style.transcriptFont - changed = true - } - if (md.codeFont != style.editorFamily) { - md.codeFont = style.editorFamily - changed = true - } - if (!changed) return - revalidate() - repaint() - } -} -``` - -## Borders, Insets, and Spacing - -- Always create borders via `JBUI.Borders.empty(top, left, bottom, right)` and insets via `JBUI.insets()` so they are DPI-aware and auto-update on zoom. -- Use `JBUI.scale(int)` for any pixel dimension to ensure proper HiDPI scaling. -- Prefer Kotlin UI DSL gaps and row layout semantics over manually assigning borders or insets. -- Do not use `EmptyBorder`, raw `Insets`, or raw `Dimension` unless there is no platform alternative and the reason is obvious. - -Theme-dependent borders, insets, colors, and corner arcs must be re-evaluated when the Look and Feel changes. Do not assign a theme-derived border once in a constructor for a long-lived component. - -Prefer overriding `updateUI()` for component-local updates: - -```kotlin -class KiloPanel : JPanel() { - override fun updateUI() { - super.updateUI() - border = JBUI.Borders.customLine( - JBColor.namedColor("KiloPanel.borderColor", JBColor.border()), - 1, - ) - } -} -``` - -For long-lived tool-window panels or components that need explicit Look and Feel handling outside normal Swing updates, subscribe to `LafManagerListener.TOPIC` and reapply theme-dependent borders or cached UI state there. - -Use `JBValue.UIInteger` for themeable arc values, because defaults differ by theme: - -| Key | Use | -|---|---| -| `Component.arc` | General component corners | -| `Button.arc` | Button corners | -| `Popup.Selection.arc` | Popup selection background | -| `TabbedPane.tabSelectionArc` | Tab selection indicator | -| `Tree.Selection.arc` | Tree selection background | - -```kotlin -val arc = JBValue.UIInteger("Component.arc", 5).get() -val button = JBValue.UIInteger("Button.arc", 6).get() -``` - -Only define plugin-owned theme keys when a platform key is not enough. Themeable insets can use `JBUI.insets("KiloPanel.insets", JBUI.insets(4, 8))`; themeable colors should use `JBColor.namedColor("KiloPanel.borderColor", fallback)`. - -Themes can override plugin-owned UI keys through the `ui` section: - -```json -{ - "ui": { - "KiloPanel.insets": "6,12,6,12", - "KiloPanel.gap": 20, - "KiloPanel.borderColor": "#FF0000", - "KiloPanel.arc": 10 - } -} -``` - -Expose intentional custom UI keys to theme authors with the `themeMetadataProvider` extension point and a `*.themeMetadata.json` file. - -```xml - -``` - -```json -{ - "name": "Kilo Code", - "fixed": false, - "ui": [ - { "key": "KiloPanel.borderColor", "description": "Panel border color" }, - { "key": "KiloPanel.gap", "description": "Gap between panel items" } - ] -} -``` - -## Validation and Error UI - -Prefer built-in validation mechanisms over custom error rendering. - -- For Kotlin UI DSL, use `validationOnInput`, `validationOnApply`, `validationInfo`, `errorOnApply`, and `addValidationRule` on cells. -- For DSL dialogs and settings panels, call `DialogPanel.registerValidators(disposable)` when input validation should run continuously. -- Call `DialogPanel.validateAll()` before `apply()` when submitting a DSL panel manually. -- In `DialogWrapper`, use `doValidate()` and return `ValidationInfo(message, component)` for errors. -- Use `ValidationInfo.asWarning()` for warnings and `withOKEnabled()` when a warning should not block submission. -- For hand-built Swing forms, use `ComponentValidator` with `withValidator`, `withFocusValidator`, and `andRegisterOnDocumentListener` instead of custom tooltip/error border logic. -- If custom validation painting is unavoidable, use `JBUI.CurrentTheme.Validator.*` and `JBUI.CurrentTheme.Focus.*` colors. - -Kotlin UI DSL example: - -```kotlin -textField() - .validationOnInput { - if (it.text.isBlank()) error(KiloBundle.message("settings.name.required")) else null - } -``` - -Dialog validation example: - -```kotlin -override fun doValidate(): ValidationInfo? { - if (field.text.isBlank()) return ValidationInfo(KiloBundle.message("settings.name.required"), field) - return null -} -``` - -## Icons - -- Reuse platform icons. Browse at https://intellij-icons.jetbrains.design and access via `AllIcons.*` constants. -- Custom icons belong in `resources/icons/`. -- Load custom icons via `IconLoader.getIcon("/icons/foo.svg", MyClass::class.java)`. -- Organize custom icons in an `icons` package or a `*Icons` object with `@JvmField` on each constant. -- Icon sizing guideline values: actions/nodes = 16x16, tool window = 13x13 classic or 20x20 + 16x16 compact New UI, editor gutter = 12x12 classic or 14x14 New UI. -- Dark variants: `icon.svg` + `icon_dark.svg`. -- HiDPI variants: `icon@2x.svg` + `icon@2x_dark.svg`. -- New UI support: place New UI icons in `expui/`, create `*IconMappings.json`, register via `com.intellij.iconMapper` extension point. -- For SVG asset palette values, follow the IntelliJ icon asset guidelines. Do not copy guideline color literals into runtime Swing UI code. - -### SVG Assets - -IntelliJ does not theme SVG icons with `currentColor`, CSS classes, CSS variables, inherited styles, or ` + + + + ), +} + +// --- Completed write tool with content → "Open in Diff Viewer" icon visible --- +export const WithWriteToolOpenDiffAction: Story = { + name: "WithWriteTool (open-diff action visible)", + render: () => ( + {}}> + ), diff --git a/packages/kilo-ui/src/styles/vscode-bridge.css b/packages/kilo-ui/src/styles/vscode-bridge.css index 7493ff50c4c..2fe4a041a01 100644 --- a/packages/kilo-ui/src/styles/vscode-bridge.css +++ b/packages/kilo-ui/src/styles/vscode-bridge.css @@ -279,6 +279,52 @@ html[data-theme="kilo-vscode"] { --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(--vscode-editor-background, #1e1e1e); + --diffs-dark-bg: var(--vscode-editor-background, #1e1e1e); + + --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) + ); + /* ===== Markdown ===== */ --markdown-heading: var(--vscode-textLink-foreground); --markdown-text: var(--vscode-editor-foreground); diff --git a/packages/kilo-ui/src/util/escape-html.ts b/packages/kilo-ui/src/util/escape-html.ts new file mode 100644 index 00000000000..243d85be4c6 --- /dev/null +++ b/packages/kilo-ui/src/util/escape-html.ts @@ -0,0 +1,3 @@ +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">") +} diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 964a8a92c54..c08f13c8319 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,258 @@ # kilo-code +## 7.3.3 + +### Minor Changes + +- [#10099](https://github.com/Kilo-Org/kilocode/pull/10099) [`47c8600`](https://github.com/Kilo-Org/kilocode/commit/47c8600b21dfc9ce5f9010a471e075af10867cf0) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Render file mentions as styled chips with click-to-open in both the chat input and sent user messages, with atomic backspace removal, arrow key skipping, selection snapping, and mention formatting preserved after reverting a session. + +### Patch Changes + +- [#10450](https://github.com/Kilo-Org/kilocode/pull/10450) [`75f6819`](https://github.com/Kilo-Org/kilocode/commit/75f68190e45a4c10f72438d1cf6ab40730e865f8) - Keep final streamed responses visible during session reconciliation. + +- [#10440](https://github.com/Kilo-Org/kilocode/pull/10440) [`e739c29`](https://github.com/Kilo-Org/kilocode/commit/e739c29bb57262d7567be1a25b62a03460f6c8d1) - Persist Speech to Text experimental settings through Kilo config instead of VS Code settings. + +- [#10155](https://github.com/Kilo-Org/kilocode/pull/10155) [`371b7e8`](https://github.com/Kilo-Org/kilocode/commit/371b7e8ae6057f0fefae3982eee6923f2c0a61f0) - Resolve bundled tree-sitter WASM resources from the installed CLI layout so codebase indexing works in packaged CLI and VS Code builds. + +- [#9806](https://github.com/Kilo-Org/kilocode/pull/9806) [`c5903fd`](https://github.com/Kilo-Org/kilocode/commit/c5903fdc09b1cf731e538cd632b47e4befd8367a) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Fix stale indexing status updates in the VS Code extension on Windows so Settings and the chat indicator refresh while indexing progresses. Directory matching for indexing SSE events now handles Windows path casing, and indexing status pushes are no longer dropped if they arrive before the initial config payload. + +- Updated dependencies [[`241efc6`](https://github.com/Kilo-Org/kilocode/commit/241efc6959ae1fe2fd2adc3533737ad0712983c5)]: + - @kilocode/kilo-ui@7.3.3 + +## 7.3.2 + +### Patch Changes + +- [#10288](https://github.com/Kilo-Org/kilocode/pull/10288) [`889d567`](https://github.com/Kilo-Org/kilocode/commit/889d56758f176ec8c80d695e758480076808e98b) - Focus session history search when it opens so the first result can be resumed immediately. + +## 7.3.1 + +### Minor Changes + +- [#10382](https://github.com/Kilo-Org/kilocode/pull/10382) [`6ce2ded`](https://github.com/Kilo-Org/kilocode/commit/6ce2ded75c452326d1eec840d728a1616c7fb1db) - Make sections in the model picker collapsible. Click any section header (Favorites, Recommended, or a provider like Kilo Gateway) to hide its models. Collapse state resets each time the picker opens. + +- [#10274](https://github.com/Kilo-Org/kilocode/pull/10274) [`14a25b0`](https://github.com/Kilo-Org/kilocode/commit/14a25b037bbbd7596aea88729989511ffe4dfce6) - Support speech-to-text voice input in Agent Manager inline review comments. + +- [#10286](https://github.com/Kilo-Org/kilocode/pull/10286) [`6fcc3b6`](https://github.com/Kilo-Org/kilocode/commit/6fcc3b6a1e77e419482fe5c0377767b5500dbd51) - Export full VS Code session transcripts as Markdown files. + +### Patch Changes + +- [#10280](https://github.com/Kilo-Org/kilocode/pull/10280) [`3ac16c5`](https://github.com/Kilo-Org/kilocode/commit/3ac16c5be5c27ca35d46ca20da9475c606e048c5) - Keep long New Worktree prompts clear of selector controls and show inline model picker options without clipping. + +- [#10278](https://github.com/Kilo-Org/kilocode/pull/10278) [`3031de6`](https://github.com/Kilo-Org/kilocode/commit/3031de63028e2ba07e0d171bd671fb7ee5b10c50) - Keep continued Agent Manager sessions in the selected worktree instead of moving them to Local. + +- [#10287](https://github.com/Kilo-Org/kilocode/pull/10287) [`233da82`](https://github.com/Kilo-Org/kilocode/commit/233da827686f6dd38f908519b216eacc1cc8a012) - Keep inline review voice capture stable in rendered Markdown diffs. + +- [#10132](https://github.com/Kilo-Org/kilocode/pull/10132) [`b914576`](https://github.com/Kilo-Org/kilocode/commit/b91457673cd6a810e356f4f4cae0c6d0f257c6b2) - Restore only the Kilo Code sidebar, Agent Manager, or both on VS Code restart based on which views were open before shutdown. + +- [#10277](https://github.com/Kilo-Org/kilocode/pull/10277) [`4b816f6`](https://github.com/Kilo-Org/kilocode/commit/4b816f621baa163c8aaf3ec84ce15219d77bae19) - Send recorded voice prompts directly when the prompt send button is pressed. + +## 7.3.0 + +## 7.2.54 + +### Minor Changes + +- [#10230](https://github.com/Kilo-Org/kilocode/pull/10230) [`26f5393`](https://github.com/Kilo-Org/kilocode/commit/26f5393be272a31390f6a317555099ed2addd1b0) - Add a collapsible sidebar in the Agent Manager. The toggle button sits left of the tab title and the collapsed state persists across reloads and restarts. Starting a new session or worktree automatically reopens the sidebar so the new entry is visible. + +- [#10218](https://github.com/Kilo-Org/kilocode/pull/10218) [`4860e65`](https://github.com/Kilo-Org/kilocode/commit/4860e654ca1cc46c4e99acc3f40d4f1302e34944) - Support setting an auto-compaction threshold percentage so long sessions can compact before the context window is full. + +- [#10228](https://github.com/Kilo-Org/kilocode/pull/10228) [`911e7ce`](https://github.com/Kilo-Org/kilocode/commit/911e7ce73383cfd5780ec80ff8e8b8187061a18a) - Add syntax highlighting to shell command output with Shiki, labeled "Command" and "Output" sections, per-section copy buttons, and an "Open in Editor" action that opens the full untruncated output in a VS Code editor tab. + +- [#10258](https://github.com/Kilo-Org/kilocode/pull/10258) [`1af7973`](https://github.com/Kilo-Org/kilocode/commit/1af79731a8ed925f1f69aa536ba90a53b89e8dfb) - Support experimental speech-to-text voice input in VS Code prompt fields through Kilo Gateway. + +### Patch Changes + +- [#10196](https://github.com/Kilo-Org/kilocode/pull/10196) [`174d467`](https://github.com/Kilo-Org/kilocode/commit/174d467a4967c53fddfc97ae9663ebf17c0fafbc) - Keep Agent Manager state hidden from Git before creating local sessions. + +- [#10156](https://github.com/Kilo-Org/kilocode/pull/10156) [`e805d2f`](https://github.com/Kilo-Org/kilocode/commit/e805d2f756b785776cb1f60b97a253e3ae4c837c) - Keep VS Code session timeline, token, context, and cost stats in sync after restoring a checkpoint. + +- [#10136](https://github.com/Kilo-Org/kilocode/pull/10136) [`8af638e`](https://github.com/Kilo-Org/kilocode/commit/8af638e7e20c645b22d96da5e30665e8e9cbf6ad) - Show ChatGPT sign-in again when Codex authentication expires. + +- [#8754](https://github.com/Kilo-Org/kilocode/pull/8754) [`e498c02`](https://github.com/Kilo-Org/kilocode/commit/e498c02f7acc5c228bbd45f9e4f294bf5def21ca) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Fix TUI diff rendering when header-like content lines appear inside a unified diff hunk. + +- [#10265](https://github.com/Kilo-Org/kilocode/pull/10265) [`900ba85`](https://github.com/Kilo-Org/kilocode/commit/900ba85b838e035952ea6ca9adf20cbc923e8d99) - Ensure snapshot installs package a fresh CLI backend so new extension features are available in local VSIX builds. + +- [#10227](https://github.com/Kilo-Org/kilocode/pull/10227) [`e1e2454`](https://github.com/Kilo-Org/kilocode/commit/e1e2454b4285109f1d4ef34b93d5d47f612faaea) - Hide Task tool subagent sessions from Agent Manager session lists. + +- [#10229](https://github.com/Kilo-Org/kilocode/pull/10229) [`41e3cda`](https://github.com/Kilo-Org/kilocode/commit/41e3cda971c37d155de443e58ae2aeafdaf928b1) - Move worktree file revert diff status checks into the VS Code extension host so Kilo no longer asks the CLI server to run git diff commands through Bun during workspace and Agent Manager reverts. + +- [#10240](https://github.com/Kilo-Org/kilocode/pull/10240) [`09f0156`](https://github.com/Kilo-Org/kilocode/commit/09f0156bfc66643870835251eef873d6e62f2791) - Keep the New Worktree model picker open when interacting with its search field. + +- [#10243](https://github.com/Kilo-Org/kilocode/pull/10243) [`47c22a2`](https://github.com/Kilo-Org/kilocode/commit/47c22a28e98a50955bd9470a5b3f78bce5d5b4ba) - Keep chat tool cards open or closed when streaming updates remount them. + +- [#10159](https://github.com/Kilo-Org/kilocode/pull/10159) [`64e45ea`](https://github.com/Kilo-Org/kilocode/commit/64e45eaba30641dd7b36fb65f79ddee3c0e14cf1) - Use the VS Code-native placeholder color for input fields so placeholder text is consistently distinct from values you have typed (Settings, Agent Manager, prompt input, model search, etc.). + +- [#10206](https://github.com/Kilo-Org/kilocode/pull/10206) [`177f7df`](https://github.com/Kilo-Org/kilocode/commit/177f7df04937e13b3ea5abc5a9c79a56c9814ab6) - Reset the To-dos tab when reverting a session snapshot. + +- [#10158](https://github.com/Kilo-Org/kilocode/pull/10158) [`d8245a0`](https://github.com/Kilo-Org/kilocode/commit/d8245a0ceb0989b8596c5a5d17fd1095ba9521be) - Fix Mermaid diagrams rendering with empty text inside every shape by restoring the `foreignObject` HTML integration point that DOMPurify dropped in 3.1.7. + +- [#10256](https://github.com/Kilo-Org/kilocode/pull/10256) [`da9292f`](https://github.com/Kilo-Org/kilocode/commit/da9292fbccad9f4c20f439e240790da3ab3a92d3) - Support viewing Agent Manager terminal tabs alongside the right diff sidebar, and send review comments to the active terminal. + +- [#10275](https://github.com/Kilo-Org/kilocode/pull/10275) [`f5dc95b`](https://github.com/Kilo-Org/kilocode/commit/f5dc95b99394c17ad7140bb034bc15a0f9de60b6) - Keep GPT-4o voice transcriptions closer to the words that were spoken. + +- [#10095](https://github.com/Kilo-Org/kilocode/pull/10095) [`710c9dc`](https://github.com/Kilo-Org/kilocode/commit/710c9dca1e9d5f522fa3fe1495dc41ec1eea4b66) - Keep configured mode models in sync when switching modes in VS Code. + +- Updated dependencies [[`262c2d6`](https://github.com/Kilo-Org/kilocode/commit/262c2d670474f0da50aa0c900e49aad93863acaf), [`4860e65`](https://github.com/Kilo-Org/kilocode/commit/4860e654ca1cc46c4e99acc3f40d4f1302e34944), [`47c22a2`](https://github.com/Kilo-Org/kilocode/commit/47c22a28e98a50955bd9470a5b3f78bce5d5b4ba), [`1af7973`](https://github.com/Kilo-Org/kilocode/commit/1af79731a8ed925f1f69aa536ba90a53b89e8dfb), [`1ea86fb`](https://github.com/Kilo-Org/kilocode/commit/1ea86fb6e15cbe486cb0af6f26995d0b1b2745a2), [`f5dc95b`](https://github.com/Kilo-Org/kilocode/commit/f5dc95b99394c17ad7140bb034bc15a0f9de60b6)]: + - @kilocode/kilo-ui@7.2.53 + - @kilocode/sdk@7.3.0 + - @kilocode/kilo-gateway@7.3.0 + - @opencode-ai/ui@7.2.53 + - @kilocode/kilo-indexing@7.2.53 + +## 7.2.51 + +### Patch Changes + +- [#10143](https://github.com/Kilo-Org/kilocode/pull/10143) [`ea023b0`](https://github.com/Kilo-Org/kilocode/commit/ea023b06dd269445eaf0c433fb42f4c9de2605b9) - Reduce delays in long sessions by compacting older tool outputs when the model request becomes too large. + +## 7.2.50 + +### Patch Changes + +- [#10082](https://github.com/Kilo-Org/kilocode/pull/10082) [`0aa908c`](https://github.com/Kilo-Org/kilocode/commit/0aa908c7afe9f5e43c77ba157936511a7b959c9f) - Keep the Agent Manager new-tab button visible at the end of the tab row, separated by a divider, and show all tab tooltips above the tabs. + +- [#10082](https://github.com/Kilo-Org/kilocode/pull/10082) [`a5f2fbc`](https://github.com/Kilo-Org/kilocode/commit/a5f2fbc9fdba4c51adcdbf6298088a323631f769) - Move Agent Manager tab creation and search controls to the left side of the tab bar with clearer toolbar separators. + +- [#10134](https://github.com/Kilo-Org/kilocode/pull/10134) [`0e8d2fe`](https://github.com/Kilo-Org/kilocode/commit/0e8d2fe0911719a2e364a43fdc70b1a18ce4d935) - Fix session cost breakdowns after reloading conversations with subagents. + +- [#10125](https://github.com/Kilo-Org/kilocode/pull/10125) [`fc6ce73`](https://github.com/Kilo-Org/kilocode/commit/fc6ce735c9dde64a544e0952d789397fa9fb7815) - Hide the task header graph area until timeline data is available. + +- [#10129](https://github.com/Kilo-Org/kilocode/pull/10129) [`d858252`](https://github.com/Kilo-Org/kilocode/commit/d8582521c3d0b1daef7ca6da64cc2a8340786ca9) - Keep older revert checkpoints available after reverting a message in the VS Code extension. + +- [#10130](https://github.com/Kilo-Org/kilocode/pull/10130) [`5482cc0`](https://github.com/Kilo-Org/kilocode/commit/5482cc0d09f7c22e15063c75dac223f13b1fa4f9) - Improve review comment plus icon contrast in diff viewers. + +- [#10127](https://github.com/Kilo-Org/kilocode/pull/10127) [`3259008`](https://github.com/Kilo-Org/kilocode/commit/3259008074cafe232a30d380e9ecc73c63ca0a35) - Keep shell command blocks stable while output streams so pending animations and expanded state do not reset. + +- Updated dependencies [[`5482cc0`](https://github.com/Kilo-Org/kilocode/commit/5482cc0d09f7c22e15063c75dac223f13b1fa4f9)]: + - @kilocode/kilo-ui@7.2.50 + +## 7.2.49 + +## 7.2.48 + +### Minor Changes + +- [#10054](https://github.com/Kilo-Org/kilocode/pull/10054) [`e5d8c4f`](https://github.com/Kilo-Org/kilocode/commit/e5d8c4f18d6abc81d0caa73575f3bf42a7c3b8fd) - Support commenting on rendered Markdown lines in diff reviewers. + +### Patch Changes + +- [#10066](https://github.com/Kilo-Org/kilocode/pull/10066) [`3cf5d15`](https://github.com/Kilo-Org/kilocode/commit/3cf5d151ff03f086db5882ce525be904a716e20b) - Open the Kilo Code sidebar before adding selected code or terminal output to chat context. + +- [#10051](https://github.com/Kilo-Org/kilocode/pull/10051) [`2d50e1f`](https://github.com/Kilo-Org/kilocode/commit/2d50e1f2dda5533196425b55e5915ee2a49334b6) - Harden git operations against malicious repositories and environment variables by upgrading the underlying git library. + +- [#10055](https://github.com/Kilo-Org/kilocode/pull/10055) [`f9fa1a7`](https://github.com/Kilo-Org/kilocode/commit/f9fa1a7efd46453ad7d603262c42c1f266dc4bc9) - Open reviewable Agent Manager diffs by default while keeping generated or extreme files collapsed. + +- [#10057](https://github.com/Kilo-Org/kilocode/pull/10057) [`05b10d9`](https://github.com/Kilo-Org/kilocode/commit/05b10d975dd2604eda5ef3583626bdb4b68255e2) - Improve the diff viewer bulk expand and collapse control so it reflects the current file state. + +- [#10064](https://github.com/Kilo-Org/kilocode/pull/10064) [`ebda89b`](https://github.com/Kilo-Org/kilocode/commit/ebda89b34ae8f46a99f5b492327bfc9e96dfb562) - Stop repeatedly reloading non-renderable Agent Manager diff previews. + +- [#10058](https://github.com/Kilo-Org/kilocode/pull/10058) [`eb8334d`](https://github.com/Kilo-Org/kilocode/commit/eb8334db016e7942ce3f99a55353e2e10cfadf39) - Keep Agent Manager promoted sessions aware of their worktree location. + +- [#10063](https://github.com/Kilo-Org/kilocode/pull/10063) [`d08238d`](https://github.com/Kilo-Org/kilocode/commit/d08238d5d12e66eaebf9872e653c35539c0ef65e) - Improve Changes diff responsiveness when switching worktrees by deferring offscreen diff rendering and ignoring stale diff updates. + +- [#10053](https://github.com/Kilo-Org/kilocode/pull/10053) [`09a7be8`](https://github.com/Kilo-Org/kilocode/commit/09a7be8fcdab92263b4c589523f2011fd086b0c3) - Improve @ file search relevance and keep mention suggestions responsive while typing. + +- [#10067](https://github.com/Kilo-Org/kilocode/pull/10067) [`c451d00`](https://github.com/Kilo-Org/kilocode/commit/c451d007e8050c114846ff86fce6470104954ade) - Keep Agent Manager review diffs responsive during worktree diff refreshes. + +- [#10059](https://github.com/Kilo-Org/kilocode/pull/10059) [`5456c96`](https://github.com/Kilo-Org/kilocode/commit/5456c962dd83631c7b1625d5adfaad2a3e0cf320) - Speed up Agent Manager worktree creation when refreshing the base branch by avoiding unnecessary tag fetches. + +- Updated dependencies [[`924f034`](https://github.com/Kilo-Org/kilocode/commit/924f034e12f3455f8cb69bb112541f887f4adfe5), [`05b10d9`](https://github.com/Kilo-Org/kilocode/commit/05b10d975dd2604eda5ef3583626bdb4b68255e2), [`d08238d`](https://github.com/Kilo-Org/kilocode/commit/d08238d5d12e66eaebf9872e653c35539c0ef65e)]: + - @kilocode/kilo-indexing@7.2.48 + - @kilocode/kilo-ui@7.2.48 + +## 7.2.47 + +### Minor Changes + +- [#9851](https://github.com/Kilo-Org/kilocode/pull/9851) [`9de7c98`](https://github.com/Kilo-Org/kilocode/commit/9de7c986e78683015631d14fabd513c3123ff330) - Support Kilo-hosted embeddings as a selectable code indexing provider. + +### Patch Changes + +- [#10016](https://github.com/Kilo-Org/kilocode/pull/10016) [`d2ae16a`](https://github.com/Kilo-Org/kilocode/commit/d2ae16a9216f0de6e1cb08950f739108515e7998) - Support configuring Azure OpenAI resource names or endpoint URLs from the provider settings flow, and document using the native Azure provider for GPT-5 family deployments. + +- [#10017](https://github.com/Kilo-Org/kilocode/pull/10017) [`d9560d8`](https://github.com/Kilo-Org/kilocode/commit/d9560d8feb1b55509098090d4c09a8e0eaf90823) - Fix the task cost shown at the top of the chat double-counting subagent costs. Sessions that spawned subagents were overreporting their totals because the backend already rolls descendant costs up into the parent session, and the webview was then summing them again. + +- [#10015](https://github.com/Kilo-Org/kilocode/pull/10015) [`556b5e4`](https://github.com/Kilo-Org/kilocode/commit/556b5e47e0a2eeb037bd5e950451688362485e2b) - Collapse the Auto-Approve Exceptions list by default when it has more than 5 entries. + +- [#10008](https://github.com/Kilo-Org/kilocode/pull/10008) [`f539f59`](https://github.com/Kilo-Org/kilocode/commit/f539f59210e79a1e028ea6b2021283f7f9f89bf2) - Show an "Open in Diff Viewer" icon on Write tool calls (matching the Edit tool), so the changes can be inspected in a full diff tab. + +## 7.2.44 + +### Minor Changes + +- [#9857](https://github.com/Kilo-Org/kilocode/pull/9857) [`6fe38e4`](https://github.com/Kilo-Org/kilocode/commit/6fe38e47bc96b8649a97cd183fca602ddd7038e6) - Support copying, previewing, and exporting rendered Mermaid diagrams. + +- [#9968](https://github.com/Kilo-Org/kilocode/pull/9968) [`bf0c4df`](https://github.com/Kilo-Org/kilocode/commit/bf0c4df9d3067339e3438d51bf6ce2ff77d75092) - Add a searchable current-tabs menu to Agent Manager. + +- [#9764](https://github.com/Kilo-Org/kilocode/pull/9764) [`9886674`](https://github.com/Kilo-Org/kilocode/commit/98866740afd7f6c2fd06fecda1ffc69c1703974e) - Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation. + +- [#9718](https://github.com/Kilo-Org/kilocode/pull/9718) [`dcaccf3`](https://github.com/Kilo-Org/kilocode/commit/dcaccf38658415819b72390255b9f6555e4795e5) - Rate assistant responses with thumbs up/down. Click the thumbs buttons next to the copy button on any assistant message, or press `=` / `-` in the terminal UI. Only shown when telemetry is enabled; feedback is sent to Kilo to help improve model and prompt quality. + +- [#9897](https://github.com/Kilo-Org/kilocode/pull/9897) [`9c581d3`](https://github.com/Kilo-Org/kilocode/commit/9c581d39926688d2c1698afb540ae7670763a48d) - Unify the Changes panel into a single tab with a source dropdown to switch between workspace and current-session diffs. Show the session's additions and deletions on the sidebar changes badge, and warn in the panel when snapshots are disabled for the repository. + +### Patch Changes + +- [#9971](https://github.com/Kilo-Org/kilocode/pull/9971) [`de58267`](https://github.com/Kilo-Org/kilocode/commit/de58267b4026f25b21560f85b334a7fdd00b2d48) - Keep Agent Manager branch, mode, and model selectors usable in the new worktree dialog. + +- [#9989](https://github.com/Kilo-Org/kilocode/pull/9989) [`c46b40a`](https://github.com/Kilo-Org/kilocode/commit/c46b40ac1799bbbfae159b1d57205dc4903eff1b) - Show the autocomplete model selector with the same picker layout as other model selectors and save changes from the settings save bar. + +- [#10004](https://github.com/Kilo-Org/kilocode/pull/10004) [`6bb0f82`](https://github.com/Kilo-Org/kilocode/commit/6bb0f8261cadb91783f712b892efea17003617d7) - Keep Agent Manager worktree recovery metadata out of worktree git changes. + +- [#9970](https://github.com/Kilo-Org/kilocode/pull/9970) [`43204b6`](https://github.com/Kilo-Org/kilocode/commit/43204b680e82921b3139367f2e0fba3698d3901d) - Make Agent Manager session tabs compact, status-aware, and easier to scan when many tabs are open. + +- [#9915](https://github.com/Kilo-Org/kilocode/pull/9915) [`bcb47be`](https://github.com/Kilo-Org/kilocode/commit/bcb47be3b0cf71990fd3ee1ec562a716aefe3571) - Preserve the selected thinking level after compacting a session. + +- [#9688](https://github.com/Kilo-Org/kilocode/pull/9688) [`3095efc`](https://github.com/Kilo-Org/kilocode/commit/3095efcc4c141a2532f48dc77b38cf0309125c82) - Restore inline diff previews after session reloads while keeping large file contents stripped from VS Code webview messages. + +- [#9993](https://github.com/Kilo-Org/kilocode/pull/9993) [`98f5f65`](https://github.com/Kilo-Org/kilocode/commit/98f5f65c1a8a543687ae5b308805eec1a2c23dca) - Support global and per-project codebase indexing enablement. + +- [#9961](https://github.com/Kilo-Org/kilocode/pull/9961) [`aec2425`](https://github.com/Kilo-Org/kilocode/commit/aec2425f6e116ab6657d3ae048085073deec70b9) - Update regular Kilo editor tab titles to follow the current session title. + +- [#9991](https://github.com/Kilo-Org/kilocode/pull/9991) [`fa326f2`](https://github.com/Kilo-Org/kilocode/commit/fa326f21e33a8cb1a81b5e32a7735e96a66bef8a) - Support accepting command permission prompts with Enter and denying them with Escape. + +- [#9921](https://github.com/Kilo-Org/kilocode/pull/9921) [`e5e9d0b`](https://github.com/Kilo-Org/kilocode/commit/e5e9d0ba37bd1065aea5a9a83834c6749121e5bd) - Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled. + +- [#9922](https://github.com/Kilo-Org/kilocode/pull/9922) [`5fcc423`](https://github.com/Kilo-Org/kilocode/commit/5fcc4231c0a716e1012475b01ce2d77f99fdbe87) - Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. + +- [#9917](https://github.com/Kilo-Org/kilocode/pull/9917) [`42e43c4`](https://github.com/Kilo-Org/kilocode/commit/42e43c4e44a5ae783ca6eae51c05d28c2ef3cedf) - Capture the active VS Code terminal when using `@terminal` in Agent Manager. + +- Updated dependencies [[`3095efc`](https://github.com/Kilo-Org/kilocode/commit/3095efcc4c141a2532f48dc77b38cf0309125c82), [`9886674`](https://github.com/Kilo-Org/kilocode/commit/98866740afd7f6c2fd06fecda1ffc69c1703974e), [`e5e9d0b`](https://github.com/Kilo-Org/kilocode/commit/e5e9d0ba37bd1065aea5a9a83834c6749121e5bd)]: + - @opencode-ai/ui@7.2.43 + - @kilocode/sdk@7.3.0 + - @kilocode/kilo-ui@7.2.43 + - @kilocode/kilo-indexing@7.2.43 + +## 7.2.42 + +### Minor Changes + +- [#9909](https://github.com/Kilo-Org/kilocode/pull/9909) [`9ffd047`](https://github.com/Kilo-Org/kilocode/commit/9ffd047962039d6b73d301d5d4e67560cd501c4f) - Detect and preserve UTF-32 (LE and BE) with BOM when reading and editing files. UTF-16 and UTF-32 without a BOM remain unsupported. + +### Patch Changes + +- [#9939](https://github.com/Kilo-Org/kilocode/pull/9939) [`8ed0e17`](https://github.com/Kilo-Org/kilocode/commit/8ed0e17855baee32d48fa44881761a8ee6b53f1e) - Recover Agent Manager worktrees after restart when saved state is missing, corrupt, or stale. + +- [#9887](https://github.com/Kilo-Org/kilocode/pull/9887) [`d9453f0`](https://github.com/Kilo-Org/kilocode/commit/d9453f0da2b063041f6f98235220cde9129e162d) - Fix queued-turn auto-compaction so overflow recovery runs instead of exhausting compaction attempts. + +- [#9855](https://github.com/Kilo-Org/kilocode/pull/9855) [`59e8eff`](https://github.com/Kilo-Org/kilocode/commit/59e8effc3df8a03146f5ceddf95f79989b813417) - Respect project-specific semantic indexing decisions instead of enabling indexing globally across workspaces. + +- [#9904](https://github.com/Kilo-Org/kilocode/pull/9904) [`080faf4`](https://github.com/Kilo-Org/kilocode/commit/080faf4df88ad77762092a6c05a3c0b10297bb96) - Append a trailing space when autocompleting a file, folder, or special mention (terminal, git changes) via the `@` menu in the chat textarea, so typing immediately after selection no longer breaks the attachment. + +- [#9928](https://github.com/Kilo-Org/kilocode/pull/9928) [`520922f`](https://github.com/Kilo-Org/kilocode/commit/520922ff39354c2df72317dee0f70035c52c24c5) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Prevent VS Code empty windows from starting codebase indexing against the home directory. + +- [#9881](https://github.com/Kilo-Org/kilocode/pull/9881) [`c4fa6b3`](https://github.com/Kilo-Org/kilocode/commit/c4fa6b34c54839b7254f4aa1aa282113828023e0) - Route the "Sign In" action from the Providers settings tab, provider picker, and chat auth errors to the Profile view so the device-auth code, QR, and cancel button are always visible. + +- [#9905](https://github.com/Kilo-Org/kilocode/pull/9905) [`0c5d757`](https://github.com/Kilo-Org/kilocode/commit/0c5d75772d9e56f89c15cd08a5fa9a16097469b5) - Show snapshot initialization progress in the extension's existing loading indicator. + +- [#9881](https://github.com/Kilo-Org/kilocode/pull/9881) [`4d1023c`](https://github.com/Kilo-Org/kilocode/commit/4d1023c21a11d3c703a8dc7e7992267469cf26cf) - Trust the OS certificate store and honor corporate CA bundles for the bundled Kilo backend. The extension now defaults `NODE_USE_SYSTEM_CA=1` on the spawned CLI process so users behind MITM proxies (Zscaler, Netskope, Palo Alto, etc.) no longer hit TLS errors on sign-in. A new `kilo-code.new.extraCaCerts` setting accepts a PEM file path for additional CAs, and `http.proxyStrictSSL=false` is honored as an opt-out from verification. + +- [#9824](https://github.com/Kilo-Org/kilocode/pull/9824) [`b7657d1`](https://github.com/Kilo-Org/kilocode/commit/b7657d1aef37cefefe1557b981822535e41ea82c) - Add a Display setting for adjusting the Kilo webview UI font size. + +- Updated dependencies [[`40a4d5e`](https://github.com/Kilo-Org/kilocode/commit/40a4d5eae254a78d4f46a075557343ac5ae8de5a)]: + - @opencode-ai/ui@7.2.41 + - @kilocode/kilo-ui@7.2.41 + ## 7.2.39 ## 7.2.36 diff --git a/packages/kilo-vscode/README.md b/packages/kilo-vscode/README.md index 40687268e9a..328d3f5d623 100644 --- a/packages/kilo-vscode/README.md +++ b/packages/kilo-vscode/README.md @@ -35,7 +35,7 @@ ## Get Started 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). -2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly. +2. Create your account to access 500+ cutting-edge AI models including GPT-5.5, Claude Opus 4.7, Claude Sonnet 4.6, and Gemini 3.1 Pro Preview, with transparent pricing that matches provider rates exactly. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: Watch the video diff --git a/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md b/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md index c1d89d67351..ebb9764ef62 100644 --- a/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md +++ b/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md @@ -6,6 +6,7 @@ Chat Markdown renders fenced `mermaid` code blocks as diagrams after a response - Valid `mermaid` fences render inline as SVG diagrams. - The original Mermaid source remains available through the existing code-block copy button. +- Rendered diagrams include Copy and Download menus for Mermaid source, SVG, and PNG formats. - Invalid Mermaid syntax shows a contained error state and keeps the source visible. - Diagrams are not rendered while a message is streaming, which avoids repeated parse/render work on every token. - Diagram colors are derived from the active VS Code/Kilo CSS variables so light, dark, and high-contrast themes can render with matching backgrounds, text, borders, and link colors. @@ -13,4 +14,4 @@ Chat Markdown renders fenced `mermaid` code blocks as diagrams after a response ## Limitations - Mermaid is bundled by the current webview build, so bundle splitting remains a future optimization. -- Advanced legacy actions are not restored yet: AI syntax fixing, PNG open/save, export, and zoom modal. +- Advanced legacy actions are not restored yet: AI syntax fixing and zoom modal. diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index b11a766659b..0914b0366f7 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -38,7 +38,7 @@ export default [ // New code must stay ≤ 20. Do not raise these caps; refactor instead. { files: ["src/KiloProvider.ts"], - rules: { complexity: ["error", 150], "max-lines": ["error", 3420] }, + rules: { complexity: ["error", 150], "max-lines": ["error", 3600] }, }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], @@ -49,7 +49,10 @@ export default [ // (canvases must never leave the paint tree — see render.tsx), and // render-call wiring that must live at the top of // `AgentManagerContent` alongside the existing selection/session state. - rules: { complexity: ["error", 74], "max-lines": ["error", 3200] }, + // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider` + // wiring, which sits inside the provider chain and cannot be extracted + // without adding an intermediate wrapper component. + rules: { complexity: ["error", 74], "max-lines": ["error", 3210] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 96d635740df..a4e68f5bdbd 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.2.40", + "version": "7.3.3", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -31,19 +31,25 @@ ], "keywords": [ "kilo", - "claude", - "dev", - "mcp", - "openrouter", - "coding", - "agent", - "autonomous", - "chatgpt", - "sonnet", - "ai", - "llama", "Kilo Code", - "kilocode" + "kilocode", + "ai", + "agent", + "agentic", + "coding", + "coding-agent", + "coding-assistant", + "autocomplete", + "code-completion", + "pair-programming", + "chat", + "terminal", + "chatgpt", + "claude", + "sonnet", + "anthropic", + "openai", + "zoo-code" ], "activationEvents": [ "onStartupFinished" @@ -125,6 +131,41 @@ "title": "Settings", "icon": "$(settings-gear)" }, + { + "command": "kilo-code.new.sidebarTitle.plusButtonClicked", + "title": "New Task", + "icon": "$(add)" + }, + { + "command": "kilo-code.new.sidebarTitle.agentManagerOpen", + "title": "Agent Manager", + "icon": "$(organization)" + }, + { + "command": "kilo-code.new.sidebarTitle.kiloClawOpen", + "title": "KiloClaw", + "icon": "$(comment-discussion)" + }, + { + "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", + "title": "Marketplace", + "icon": "$(extensions)" + }, + { + "command": "kilo-code.new.sidebarTitle.historyButtonClicked", + "title": "History", + "icon": "$(history)" + }, + { + "command": "kilo-code.new.sidebarTitle.profileButtonClicked", + "title": "Profile", + "icon": "$(account)" + }, + { + "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", + "title": "Settings", + "icon": "$(settings-gear)" + }, { "command": "kilo-code.new.openInTab", "title": "Kilo Code", @@ -363,39 +404,69 @@ } ], "menus": { + "commandPalette": [ + { + "command": "kilo-code.new.sidebarTitle.plusButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.historyButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.agentManagerOpen", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.kiloClawOpen", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.profileButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", + "when": "false" + } + ], "view/title": [ { - "command": "kilo-code.new.plusButtonClicked", + "command": "kilo-code.new.sidebarTitle.plusButtonClicked", "group": "navigation@0", "when": "view == kilo-code.SidebarProvider" }, { - "command": "kilo-code.new.historyButtonClicked", + "command": "kilo-code.new.sidebarTitle.historyButtonClicked", "group": "navigation@1", "when": "view == kilo-code.SidebarProvider" }, { - "command": "kilo-code.new.agentManagerOpen", + "command": "kilo-code.new.sidebarTitle.agentManagerOpen", "group": "navigation@2", "when": "view == kilo-code.SidebarProvider" }, { - "command": "kilo-code.new.kiloClawOpen", + "command": "kilo-code.new.sidebarTitle.kiloClawOpen", "group": "navigation@3", "when": "view == kilo-code.SidebarProvider" }, { - "command": "kilo-code.new.marketplaceButtonClicked", + "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", "group": "navigation@4", "when": "view == kilo-code.SidebarProvider" }, { - "command": "kilo-code.new.profileButtonClicked", + "command": "kilo-code.new.sidebarTitle.profileButtonClicked", "group": "navigation@5", "when": "view == kilo-code.SidebarProvider" }, { - "command": "kilo-code.new.settingsButtonClicked", + "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", "group": "navigation@6", "when": "view == kilo-code.SidebarProvider" } @@ -753,11 +824,11 @@ "type": "string", "enum": [ "mistralai/codestral-2508", - "inception/mercury-edit" + "inception/mercury-edit-2" ], "enumDescriptions": [ "Codestral by Mistral AI (default)", - "Mercury Edit by Inception" + "Mercury Edit 2 by Inception" ], "description": "Model to use for inline autocomplete suggestions" }, @@ -776,6 +847,15 @@ "default": false, "description": "Enable chat textarea autocomplete" }, + "kilo-code.new.speechToText.enabled": { + "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." + }, "kilo-code.new.claudeCodeCompat": { "type": "boolean", "default": false, @@ -886,7 +966,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "knip": "knip", - "check-kilocode-change": "! grep -rn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'", + "check-kilocode-change": "! grep -rIn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'", "lint": "eslint src webview-ui", "test": "vscode-test", "test:unit": "bun test tests/unit/", @@ -927,6 +1007,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "fuzzysort": "3.1.0", + "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -945,15 +1027,16 @@ "ignore": "^7.0.3", "js-tiktoken": "^1.0.18", "lru-cache": "^11.0.2", + "marked": "catalog:", "openai": "^4.85.4", "quick-lru": "^7.0.0", - "simple-git": "3.35.2", + "simple-git": "3.36.0", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", "yaml": "2.8.3", "zod": "^3.24.2" - } + }, + "peerDependencies": {} } diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index cb285b327b4..56f65d9f5c0 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -2,6 +2,8 @@ import { $ } from "bun" import { join } from "node:path" import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs" +import { copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { ensureFfmpegForTarget } from "./ffmpeg-helper" const packageJsonPath = join(import.meta.dir, "..", "package.json") const packageJson = await Bun.file(packageJsonPath).json() @@ -74,6 +76,7 @@ for (const config of targets) { console.log(` 📥 Copying binary from ${config.cliDir}/bin/${config.binary}...`) await $`cp ${sourceBinary} ${targetBinary}` + await copyTreeSitterResources(sourceBinary, targetBinary) if (config.binary !== "kilo.exe") { chmodSync(targetBinary, 0o755) @@ -81,6 +84,9 @@ for (const config of targets) { console.log(` ✅ Binary ready at ${targetBinary}`) + console.log("Adding bundled FFmpeg helper...") + await ensureFfmpegForTarget(config.target, binDir) + console.log(` 📦 Packaging .vsix for ${config.target}${prerelease ? " (pre-release)" : ""}...`) const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`) const args = ["--no-dependencies", "--skip-license", "--target", config.target, "-o", vsixPath] diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index 7637579ea38..edbe590573e 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -41,7 +41,7 @@ console.log("\n📦 Rebuilding SDK...") await $`bun run --cwd ../sdk/js build`.cwd(root) console.log("\n🔧 Preparing CLI binary...") -await $`bun script/local-bin.ts`.cwd(root) +await $`bun script/local-bin.ts --force`.cwd(root) console.log("\n✅ Type-checking...") await $`bun run typecheck`.cwd(root) diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts new file mode 100644 index 00000000000..c0b5e440ef4 --- /dev/null +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -0,0 +1,49 @@ +import { $ } from "bun" +import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs" +import { join } from "node:path" + +const packages: Record = { + "darwin-x64": "@ffmpeg-installer/darwin-x64@4.1.0", + "darwin-arm64": "@ffmpeg-installer/darwin-arm64@4.1.5", + "linux-x64": "@ffmpeg-installer/linux-x64@4.1.0", + "linux-arm64": "@ffmpeg-installer/linux-arm64@4.1.4", + "alpine-x64": "@ffmpeg-installer/linux-x64@4.1.0", + "alpine-arm64": "@ffmpeg-installer/linux-arm64@4.1.4", + "win32-x64": "@ffmpeg-installer/win32-x64@4.1.0", +} + +export async function ensureFfmpegForTarget(target: string, bin: string): Promise { + if (target === "win32-arm64") { + console.warn("No Windows ARM64 FFmpeg helper package is available; speech input will use system FFmpeg.") + return + } + + const spec = packages[target] + if (!spec) throw new Error(`No FFmpeg helper package configured for target ${target}`) + + const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" + const dest = join(bin, exe) + if (existsSync(dest)) return + + const tmp = join(bin, ".ffmpeg-tmp") + rmSync(tmp, { recursive: true, force: true }) + mkdirSync(tmp, { recursive: true }) + + try { + const packed = await $`npm pack ${spec} --pack-destination ${tmp}`.quiet() + const name = packed.text().trim().split(/\s+/).pop() + if (!name) throw new Error(`npm pack did not return a tarball for ${spec}`) + + await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet() + copyFileSync(join(tmp, "package", exe), dest) + if (!target.startsWith("win32")) chmodSync(dest, 0o755) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} + +export function currentFfmpegTarget(): string { + const os = process.platform === "win32" ? "win32" : process.platform + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch + return `${os}-${arch}` +} diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index aeb57e81643..0c4beea4181 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -2,6 +2,8 @@ import { $ } from "bun" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" +import { copyTreeSitterResources, hasTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") @@ -82,6 +84,7 @@ async function findKiloBinaryInOpencodeDist(): Promise { const preferred = join(distDir, `@kilocode`, tag, "bin", binName) try { statSync(preferred) + if (!hasTreeSitterResources(preferred)) return null return preferred } catch { // fall through to generic search @@ -107,6 +110,7 @@ async function findKiloBinaryInOpencodeDist(): Promise { continue } if (e.isFile() && (e.name === "kilo" || e.name === "kilo.exe") && basename(dirname(p)) === "bin") { + if (!hasTreeSitterResources(p)) continue return p } } @@ -158,6 +162,7 @@ async function main() { log( `CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`, ) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) return } @@ -180,7 +185,9 @@ async function main() { const sourceBinPath = await ensureBuiltBinary() await $`mkdir -p ${targetBinDir}` await $`cp ${sourceBinPath} ${targetBinPath}` + await copyTreeSitterResources(sourceBinPath, targetBinPath) chmodSync(targetBinPath, 0o755) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) // Record the CLI source version so future runs detect when a rebuild is needed const hash = await cliSourceHash() diff --git a/packages/kilo-vscode/script/watch-cli.ts b/packages/kilo-vscode/script/watch-cli.ts index d7b9943563f..2a50cf8e7a2 100644 --- a/packages/kilo-vscode/script/watch-cli.ts +++ b/packages/kilo-vscode/script/watch-cli.ts @@ -9,6 +9,7 @@ import { watch, chmodSync } from "node:fs" import { join, relative } from "node:path" import { $ } from "bun" +import { copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") @@ -57,6 +58,7 @@ async function rebuild() { await $`mkdir -p ${targetBinDir}` await $`cp ${source} ${targetBinPath}` + await copyTreeSitterResources(source, targetBinPath) chmodSync(targetBinPath, 0o755) const elapsed = ((performance.now() - start) / 1000).toFixed(1) diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts deleted file mode 100644 index a81b51650ba..00000000000 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ /dev/null @@ -1,270 +0,0 @@ -import * as vscode from "vscode" -import type { KiloConnectionService } from "./services/cli-backend" -import { buildWebviewHtml, getWebviewFontSize } from "./utils" -import { GitOps } from "./agent-manager/GitOps" -import { watchFontSizeConfig } from "./kilo-provider/font-size" -import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" -import { - appendOutput, - getWorkspaceRoot, - hashFileDiffs, - openWorkspaceRelativeFile, - resolveLocalDiffTarget, -} from "./review-utils" -import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" - -/** - * DiffViewerProvider opens a full-screen diff viewer in an editor tab. - * It shows the local workspace diff and forwards review comments back to the sidebar chat. - */ -export class DiffViewerProvider implements vscode.Disposable { - public static readonly viewType = "kilo-code.new.DiffViewerPanel" - - private panel: vscode.WebviewPanel | undefined - private diffInterval: ReturnType | undefined - private lastDiffHash: string | undefined - private cachedDiffTarget: DiffTarget | undefined - private gitOps: GitOps - private outputChannel: vscode.OutputChannel - private fontConfigDisposable: vscode.Disposable | undefined - private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly connectionService: KiloConnectionService, - ) { - this.gitOps = new GitOps({ log: (...args) => this.log(...args) }) - this.outputChannel = vscode.window.createOutputChannel("Kilo Diff Viewer") - } - - private log(...args: unknown[]) { - appendOutput(this.outputChannel, "DiffViewer", ...args) - } - - public setCommentHandler(handler: (comments: unknown[], autoSend: boolean) => void): void { - this.onSendComments = handler - } - - public openPanel(): void { - if (this.panel) { - this.panel.reveal(vscode.ViewColumn.One) - return - } - - const panel = vscode.window.createWebviewPanel(DiffViewerProvider.viewType, "Changes", vscode.ViewColumn.One, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [this.extensionUri], - }) - - this.wirePanel(panel) - } - - /** Re-wire a deserialized panel after extension restart. */ - public deserializePanel(panel: vscode.WebviewPanel): void { - this.wirePanel(panel) - } - - private wirePanel(panel: vscode.WebviewPanel): void { - this.panel = panel - - 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.onDidReceiveMessage((msg) => this.onMessage(msg), undefined, []) - panel.webview.html = this.getHtml(panel.webview) - this.fontConfigDisposable?.dispose() - this.fontConfigDisposable = watchFontSizeConfig((msg) => this.post(msg)) - - panel.onDidDispose(() => { - this.log("Panel disposed") - this.stopDiffPolling() - this.fontConfigDisposable?.dispose() - this.fontConfigDisposable = undefined - this.panel = undefined - }) - } - - private onMessage(msg: Record): void { - const type = msg.type as string - - if (type === "webviewReady") { - this.post({ - type: "ready", - vscodeLanguage: vscode.env.language, - languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), - fontSize: getWebviewFontSize(), - workspaceDirectory: getWorkspaceRoot(), - }) - this.post({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) - this.startDiffPolling() - return - } - - if (type === "diffViewer.sendComments" && Array.isArray(msg.comments)) { - this.onSendComments?.(msg.comments, !!msg.autoSend) - return - } - - if (type === "diffViewer.close") { - this.panel?.dispose() - return - } - - if (type === "diffViewer.setDiffStyle" && (msg.style === "unified" || msg.style === "split")) { - return - } - - if (type === "diffViewer.setMarkdownRender" && typeof msg.render === "boolean") { - void setDiffMarkdownRender(msg.render) - return - } - - if (type === "diffViewer.revertFile" && typeof msg.file === "string") { - void this.revertFile(msg.file) - return - } - - if (type === "openFile" && typeof msg.filePath === "string") { - openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) - } - } - - private async revertFile(file: string): Promise { - const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget()) - if (!target) { - this.post({ - type: "diffViewer.revertFileResult", - file, - status: "error", - message: "Could not resolve diff target", - }) - return - } - - try { - const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) => - this.log(...args), - ) - const result = await diff.revertFile(target, file) - this.post({ - type: "diffViewer.revertFileResult", - file, - status: result.ok ? "success" : "error", - message: result.message, - }) - if (result.ok) void this.pollDiff() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.log("Failed to revert file:", message) - this.post({ type: "diffViewer.revertFileResult", file, status: "error", message }) - } - } - - private async resolveLocalDiffTarget(): Promise { - return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot()) - } - - private async initialFetch(): Promise { - this.post({ type: "diffViewer.loading", loading: true }) - - const target = await this.resolveLocalDiffTarget() - if (!target) { - this.post({ type: "diffViewer.diffs", diffs: [] }) - this.post({ type: "diffViewer.loading", loading: false }) - return - } - - this.cachedDiffTarget = target - - try { - await this.connectionService.connect(target.directory) - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diff( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - this.lastDiffHash = hashFileDiffs(diffs) - - this.log(`Initial diff: ${diffs.length} file(s)`) - this.post({ type: "diffViewer.diffs", diffs }) - } catch (err) { - this.log("Failed to fetch initial diff:", err) - } finally { - this.post({ type: "diffViewer.loading", loading: false }) - } - } - - private async pollDiff(): Promise { - const target = this.cachedDiffTarget - if (!target) { - await this.initialFetch() - return - } - - try { - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diff( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - const hash = hashFileDiffs(diffs) - - if (hash === this.lastDiffHash) return - this.lastDiffHash = hash - this.post({ type: "diffViewer.diffs", diffs }) - } catch (err) { - this.log("Failed to poll diff:", err) - } - } - - private startDiffPolling(): void { - this.stopDiffPolling() - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - - void this.initialFetch().then(() => { - if (!this.panel) return - this.diffInterval = setInterval(() => { - void this.pollDiff() - }, 2500) - }) - } - - private stopDiffPolling(): void { - if (this.diffInterval) { - clearInterval(this.diffInterval) - this.diffInterval = undefined - } - - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - } - - private post(message: Record): void { - if (this.panel?.webview) void this.panel.webview.postMessage(message) - } - - private getHtml(webview: vscode.Webview): string { - return buildWebviewHtml(webview, { - 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")), - title: "Changes", - port: this.connectionService.getServerInfo()?.port, - extraStyles: "#root { display: flex; flex-direction: column; }", - }) - } - - public dispose(): void { - this.stopDiffPolling() - this.fontConfigDisposable?.dispose() - this.gitOps.dispose() - this.panel?.dispose() - this.outputChannel.dispose() - } -} diff --git a/packages/kilo-vscode/src/DiffVirtualProvider.ts b/packages/kilo-vscode/src/DiffVirtualProvider.ts index 0e2954bbf76..90a170559dc 100644 --- a/packages/kilo-vscode/src/DiffVirtualProvider.ts +++ b/packages/kilo-vscode/src/DiffVirtualProvider.ts @@ -6,8 +6,7 @@ import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" export interface DiffVirtualFile { file: string - before: string - after: string + patch?: string additions: number deletions: number initialDiffStyle: "unified" | "split" diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1458bc45066..7ee897ff44b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -16,7 +16,14 @@ 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 { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry" +import { saveImage } from "./kilo-provider/save-image" +import { exportTranscript } from "./kilo-provider/export-transcript" +import { + TelemetryProxy, + type TelemetryPropertiesProvider, + pushTelemetryState, + watchTelemetryState, +} from "./services/telemetry" import { sessionToWebview, indexProvidersById, @@ -33,6 +40,7 @@ import { resolveContextDirectory, resolveNewSessionDirectory, resolveWorkspaceDirectory, + sameDirectory, SessionStreamScheduler, type SessionRefreshContext, } from "./kilo-provider-utils" @@ -44,6 +52,7 @@ import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./s import type { RemoteStatusService } from "./services/RemoteStatusService" import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, 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 { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" @@ -61,9 +70,10 @@ import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" import { abortSession } from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, - routeAutocompleteMessage, + validAutocompleteSetting, watchAutocompleteConfig, } from "./services/autocomplete/settings" +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" @@ -104,7 +114,8 @@ import { handleQuestionReject, fetchAndSendPendingQuestions, } from "./kilo-provider/handlers/question" -import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion" +import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" +import { nativeTitle } from "./kilo-provider/native-tab-title" import { buildActionContext, @@ -122,11 +133,11 @@ import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" - -type KiloProviderOptions = { projectDirectory?: string | null; slimEditMetadata?: boolean } +import type { KiloProviderOptions } from "./kilo-provider/options" +import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" - +type ContextMessage = { contextDirectory?: unknown } // Helper to map agent data to the subset of fields sent to the webview const mapAgent = (a: Agent) => ({ name: a.name, @@ -170,8 +181,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null + private cachedGlobalConfig: Config | null = null /** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */ private cachedIndexingStatusMessage: unknown = null + /** Cached kiloEmbeddingModelsLoaded payload so requestKiloEmbeddingModels is resilient offline. */ + private cachedKiloEmbeddingModelsMessage: unknown = null /** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */ private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ @@ -217,6 +231,7 @@ 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 telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null @@ -251,10 +266,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, private readonly extensionContext?: vscode.ExtensionContext, - options?: KiloProviderOptions, + private readonly opts: KiloProviderOptions = {}, ) { - this.projectDirectory = options?.projectDirectory - this.slimEditMetadata = options?.slimEditMetadata ?? true + this.projectDirectory = opts.projectDirectory + this.slimEditMetadata = opts.slimEditMetadata ?? true TelemetryProxy.getInstance().setProvider(this) } @@ -269,6 +284,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.autoApproveBridge = createAutoApproveBridge(ctrl, (msg) => this.postMessage(msg), this.onBeforeMessage) this.onBeforeMessage = (msg) => this.autoApproveBridge!.handle(msg) } + + private setCurrentSession(session: Session | null): void { + this.currentSession = session + this.opts.tabTitle?.(nativeTitle(session)) + } + private sendRemoteStatus(): void { const s = this.remoteService?.getState() if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected }) @@ -352,10 +373,8 @@ 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.postMessage({ type: "connectionState", state: this.connectionState }) + pushTelemetryState((m) => this.postMessage(m)) // Re-send ready so the webview can recover after refresh. if (serverInfo) { @@ -430,10 +449,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper webviewView.webview.html = this._getHtmlForWebview(webviewView.webview) this.setupWebviewMessageHandler(webviewView.webview) - vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) + this.setSidebarVisible(webviewView.visible) this.visibilityDisposable?.dispose() this.visibilityDisposable = webviewView.onDidChangeVisibility(() => { - vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) + this.setSidebarVisible(webviewView.visible) if (this.statsPoller) { this.statsPoller.setEnabled(webviewView.visible) this.statsPoller.setVisible(webviewView.visible) @@ -443,6 +462,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.initializeConnection() } + private setSidebarVisible(visible: boolean): void { + 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. */ @@ -466,12 +490,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.initializeConnection() } - /** - * Register a session created externally (e.g., worktree sessions from AgentManagerProvider). - * Sets currentSession, adds to trackedSessionIds, and notifies the webview. - */ + /** Register a session created externally and notify the webview. */ public registerSession(session: Session): void { - this.currentSession = session + this.setCurrentSession(session) this.contextSessionID = session.id this.trackedSessionIds.add(session.id) this.postMessage({ @@ -480,10 +501,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } - /** - * Add a session ID to the tracked set without changing currentSession. - * Used to re-register worktree sessions after clearSession wipes the set. - */ + /** Add a session ID to the tracked set without changing currentSession. */ public trackSession(sessionId: string): void { this.trackedSessionIds.add(sessionId) } @@ -582,6 +600,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) + this.telemetryStateDisposable?.dispose() + this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { const intercepted = await interceptMessage(message, { workspaceDir: (sid) => this.getWorkspaceDirectory(sid ?? this.currentSession?.id), @@ -592,15 +612,27 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (intercepted === null) return message = intercepted - await routeSuggestionWebviewMessage(this.questionCtx, message) - if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return - if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return + if ( + await routeEarlyMessage(message, { + question: this.questionCtx, + client: this.client, + connection: this.connectionService, + dir: this.getWorkspaceDirectory(this.currentSession?.id), + post: (msg) => this.postMessage(msg), + exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), + }) + ) { + return + } + if (this.handleEditorOpenMessage(message)) return if ( await handleSidebarWorktreeMessage(message, { post: (msg) => this.postMessage(msg), openAgentManager: () => vscode.commands.executeCommand("kilo-code.new.agentManagerOpen"), openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"), - openChanges: () => vscode.commands.executeCommand("kilo-code.new.showChanges"), + openChanges: (sessionId?: string, turnId?: string) => + vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId, turnId }), + currentSessionId: this.currentSession?.id, createWorktree: async (baseBranch, branchName) => { await this.createWorktreeHandler?.(baseBranch, branchName) }, @@ -619,7 +651,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.readyResolvers.splice(0).forEach((r) => r()) break case "sendMessage": { - const files = parseMessageFiles(message.files) + const msg = message as typeof message & ContextMessage await this.handleSendMessage( message.text, typeof message.messageID === "string" ? message.messageID : undefined, @@ -629,13 +661,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.modelID, message.agent, message.variant, - files, + parseMessageFiles(message.files), typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined, + typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined, ) break } case "sendCommand": { - const files = parseMessageFiles(message.files) + const msg = message as typeof message & ContextMessage await this.handleSendCommand( message.command, message.arguments, @@ -646,8 +679,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.modelID, message.agent, message.variant, - files, + parseMessageFiles(message.files), typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined, + typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined, ) break } @@ -656,7 +690,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.handleAbort(message.sessionID) break case "revertSession": - this.handleRevertSession(message.sessionID, message.messageID).catch((e) => + this.handleRevertSession(message.sessionID, message.messageID, message.partID).catch((e) => console.error("[Kilo New] handleRevertSession failed:", e), ) break @@ -680,7 +714,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break case "clearSession": this.contextSessionID = this.currentSession?.id ?? this.contextSessionID - this.currentSession = null + this.setCurrentSession(null) this.focusSession() break case "loadMessages": @@ -743,7 +777,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] handleForkSession failed:", e), ) break - case "retryConnection": console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...") this.initializeConnection().catch((e) => @@ -756,11 +789,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "previewImage": this.handlePreviewImage(message.dataUrl, message.filename) break - case "openFile": - if (message.filePath) { - this.handleOpenFile(message.filePath, message.line, message.column) - } - break + case "saveImage": + return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message) case "requestProviders": this.fetchAndSendProviders().catch((e) => console.error("[Kilo New] fetchAndSendProviders failed:", e)) break @@ -851,6 +881,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] fetchAndSendIndexingStatus failed:", e), ) break + case "requestKiloEmbeddingModels": + this.fetchAndSendKiloEmbeddingModels().catch((e) => + console.error("[Kilo New] fetchAndSendKiloEmbeddingModels failed:", e), + ) + break case "updateConfig": await this.handleUpdateConfig(message.config, message.projectConfig) break @@ -1039,7 +1074,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "enhancePromptResult", text: data.text, requestId: message.requestId }) }) .catch((err: unknown) => { - const msg = getErrorMessage(err) || "Failed to enhance prompt" + const raw = getErrorMessage(err) || "Failed to enhance prompt" + const msg = normalizeEnhancePromptErrorMessage(raw) console.error("[Kilo New] KiloProvider: Failed to enhance prompt:", err) vscode.window.showErrorMessage(`Enhance prompt failed: ${msg}`) this.postMessage({ @@ -1051,13 +1087,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break } case "fetchMarketplaceData": { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const mp = this.getMarketplace() - // Fetch skills from CLI backend (authoritative source) so the - // marketplace doesn't need to duplicate the CLI's skill scanning. - const skills = await this.fetchCliSkills() - const data = await mp.fetchData(workspace, skills) - this.postMessage({ type: "marketplaceData", ...data }) + await this.handleFetchMarketplaceData() break } case "filterMarketplaceItems": { @@ -1107,6 +1137,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper 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 }) + } + /** * Initialize connection to the CLI backend server. * Subscribes to the shared KiloConnectionService. @@ -1315,8 +1353,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getContextDirectory() - const { data: session } = await this.client.session.create({ directory: workspaceDir }, { throwOnError: true }) - this.currentSession = session + const { data: session } = await this.client.session.create( + { directory: workspaceDir, platform: this.opts.platform }, + { throwOnError: true }, + ) + this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, workspaceDir) this.trackedSessionIds.add(session.id) @@ -1342,7 +1383,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper .get({ sessionID, directory: dir }) .then((r) => { if (r.data && !signal?.aborted) { - this.currentSession = r.data + this.setCurrentSession(r.data) this.contextSessionID = r.data.id } }) @@ -1394,6 +1435,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.loadMessagesAbort = abort this.refreshSessionDetails(sessionID, dir, abort.signal) } + const since = mode === "reconcile" ? Date.now() : undefined try { const page = await fetchMessagePage(this.client, { sessionID, @@ -1425,6 +1467,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper mode, cursor: page.cursor, hasMore: Boolean(page.cursor), + since, }) // Recover any prompts missed while the webview was loading or during an SSE reconnection. this.recoverPendingPrompts() @@ -1505,6 +1548,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper client.session.list({ directory: dir, roots: true }, { throwOnError: true }).then(({ data }) => data) : null, sessionDirectories: this.sessionDirectories, + worktreeDirectories: this.opts.worktreeDirectories, workspaceDirectory: this.getWorkspaceDirectory(), postMessage: (msg: unknown) => this.postMessage(msg), } @@ -1582,7 +1626,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { - this.currentSession = null + this.setCurrentSession(null) this.focusSession(undefined) } this.postMessage({ type: "sessionDeleted", sessionID }) @@ -1611,7 +1655,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper { throwOnError: true }, ) if (this.currentSession?.id === sessionID) { - this.currentSession = updated + this.setCurrentSession(updated) } this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(updated) }) } catch (error) { @@ -1623,6 +1667,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Export a full session transcript as Markdown. + */ + private async handleExportSessionTranscript(sessionID: string): Promise { + if (!this.client) { + this.postMessage({ type: "error", message: "Not connected to CLI backend" }) + return + } + + try { + const saved = await exportTranscript(this.client, { + sessionID, + dir: this.getWorkspaceDirectory(sessionID), + }) + if (saved) void vscode.window.showInformationMessage("Session transcript exported as Markdown.") + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to export session transcript:", error) + this.postMessage({ + type: "error", + message: getErrorMessage(error) || "Failed to export session transcript", + }) + } + } + /** Fetch providers and send to webview. Coalesced: at most one in-flight + one queued. */ private async fetchAndSendProviders(): Promise { const next = ++this.providersGeneration @@ -1712,13 +1780,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) const set = (m: unknown) => { this.cachedConfigMessage = m + if (m && typeof m === "object" && "globalConfig" in m) + this.cachedGlobalConfig = (m as { globalConfig?: Config }).globalConfig ?? null } const method = typeof msg.method === "number" ? msg.method : 0 const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined const keyChanged = msg.apiKeyChanged === true const code = typeof msg.code === "string" ? msg.code : undefined const config = msg.config && typeof msg.config === "object" ? (msg.config as Record) : undefined - if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key) + const metadata = + msg.metadata && typeof msg.metadata === "object" ? (msg.metadata as Record) : undefined + if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key, metadata) if (msg.type === "authorizeProviderOAuth") return authorizeOAuthAction(ctx, rid, pid, method) if (msg.type === "completeProviderOAuth") return completeOAuthAction(ctx, rid, pid, method, code) if (msg.type === "disconnectProvider") return disconnectProviderAction(ctx, rid, pid, this.cachedConfigMessage, set) @@ -2065,10 +2137,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const { data: config } = await retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }), ) + const { data: global } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = global ?? null const message = { type: "configLoaded", config, + globalConfig: global, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2083,6 +2158,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || this.connectionState !== "connected") return try { const { data: config } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = config ?? null this.postMessage({ type: "globalConfigLoaded", config }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch global config:", error) @@ -2122,6 +2198,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async fetchAndSendKiloEmbeddingModels(): Promise { + const catalog = await fetchKiloEmbeddingModelCatalog() + const message = { type: "kiloEmbeddingModelsLoaded", catalog } + this.cachedKiloEmbeddingModelsMessage = message + this.postMessage(message) + } + /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see @@ -2146,8 +2229,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - this.cachedConfigMessage = { type: "configLoaded", config, features: configFeatures(config) } - this.postMessage({ type: "configUpdated", config, features: configFeatures(config) }) + const { data: global } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = global ?? null + this.cachedConfigMessage = { + type: "configLoaded", + config, + globalConfig: global, + features: configFeatures(config), + } + this.postMessage({ type: "configUpdated", config, globalConfig: global, features: configFeatures(config) }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -2313,6 +2403,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper partial.provider !== undefined || partial.disabled_providers !== undefined || partial.enabled_providers !== 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 @@ -2331,9 +2426,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - this.cachedConfigMessage = { type: "configLoaded", config: merged, features: configFeatures(merged) } - this.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) - if (refreshProviders) await this.fetchAndSendProviders() + const { data: global } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = global ?? null + this.cachedConfigMessage = { + type: "configLoaded", + config: merged, + globalConfig: global, + features: configFeatures(merged), + } + this.postMessage({ + type: "configUpdated", + config: merged, + globalConfig: global, + features: configFeatures(merged), + }) + await Promise.all([ + refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), + refreshAgents ? this.fetchAndSendAgents() : Promise.resolve(), + ]) } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) const patch = @@ -2347,6 +2457,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "configUpdated", config: optimistic, + globalConfig: this.cachedGlobalConfig ?? undefined, features: features ?? configFeatures(optimistic as Config), }) } finally { @@ -2361,7 +2472,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper details: getConfigErrorDetails(error), }) } - private async resolveSession(sessionID?: string, draftID?: string, context?: string) { + private async resolveSession(sessionID?: string, draftID?: string, context?: string, contextDirectory?: string) { if (!this.client) return undefined const dir = resolveNewSessionDirectory({ @@ -2369,13 +2480,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper currentSessionID: this.currentSession?.id, contextSessionID: this.contextSessionID, agentManagerContext: context, + contextDirectory, sessionDirectories: this.sessionDirectories, workspaceDirectory: this.getRootDirectory(), }) if (!sessionID && !this.currentSession) { - const { data: session } = await this.client.session.create({ directory: dir }, { throwOnError: true }) - this.currentSession = session + const { data: session } = await this.client.session.create( + { directory: dir, platform: this.opts.platform }, + { throwOnError: true }, + ) + this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, dir) this.trackedSessionIds.add(session.id) @@ -2478,6 +2593,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper variant?: string, files?: MessageFile[], context?: string, + contextDirectory?: string, ): Promise { if (!this.client) { this.postMessage({ @@ -2494,7 +2610,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { - resolved = await this.resolveSession(sessionID, draftID, context) + resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) const parts: Array = [] if (files) { @@ -2504,14 +2620,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } parts.push({ type: "text", text }) - const editorContext = await this.gatherEditorContext() - - if (messageID) { - this.connectionService.recordMessageSessionId(messageID, resolved!.sid) - } - const sid = resolved!.sid const dir = resolved!.dir + const editorContext = await this.gatherEditorContext(dir) + + if (messageID) { + this.connectionService.recordMessageSessionId(messageID, sid) + } + await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Message request", () => this.withRetry( () => @@ -2555,6 +2671,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper variant?: string, files?: MessageFile[], context?: string, + contextDirectory?: string, ): Promise { if (!this.client) { this.postMessage({ @@ -2571,7 +2688,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { - resolved = await this.resolveSession(sessionID, draftID, context) + resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) if (messageID) { this.connectionService.recordMessageSessionId(messageID, resolved!.sid) @@ -2640,10 +2757,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async handleRevertSession(sessionID: string, messageID: string): Promise { + private async handleRevertSession(sessionID: string, messageID: string, partID?: string): Promise { if (!this.client) return const dir = this.getWorkspaceDirectory(sessionID) - const { data, error } = await this.client.session.revert({ sessionID, messageID, directory: dir }) + const { data, error } = await this.client.session.revert({ sessionID, messageID, partID, directory: dir }) if (error) { console.error("[Kilo New] KiloProvider: Failed to revert session:", error) this.postMessage({ type: "error", message: "Failed to revert session", sessionID }) @@ -2748,7 +2865,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return self.currentSession }, set currentSession(session) { - self.currentSession = session + self.setCurrentSession(session) if (session) self.contextSessionID = session.id }, trackedSessionIds: this.trackedSessionIds, @@ -2833,6 +2950,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper .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 @@ -2843,17 +2989,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath) - 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.workspace.fs.stat(uri).then( + (stat) => { + if (stat.type & vscode.FileType.Directory) { + vscode.commands.executeCommand("revealInExplorer", uri) + return } - vscode.window.showTextDocument(doc, options) + 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: Failed to open file:", uri.fsPath, err), + (err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err), ) } @@ -2863,6 +3018,7 @@ 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 const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) await config.update(leaf, value, vscode.ConfigurationTarget.Global) } @@ -3028,27 +3184,28 @@ 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 && path.resolve(dir) !== path.resolve(this.getWorkspaceDirectory())) return + if (dir && !sameDirectory(dir, this.getWorkspaceDirectory())) return void this.reloadAfterAuthChange() return } // Config was updated without a full dispose (e.g. permission-only save). - // Fetch and push the updated config so the Settings panel reflects the change. + // Fetch and push the updated config + refresh agents and providers so the + // Settings panel and mode/model pickers reflect the change. if (event.type === "global.config.updated") { - void this.fetchAndSendConfigUpdated() + void Promise.all([this.fetchAndSendConfigUpdated(), this.fetchAndSendAgents(), this.fetchAndSendProviders()]) return } // Forward relevant events to webview // Side effects that must happen before the webview message is sent if (event.type === "session.created" && !this.currentSession) { - this.currentSession = event.properties.info + this.setCurrentSession(event.properties.info) 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.currentSession = event.properties.info + this.setCurrentSession(event.properties.info) this.contextSessionID = event.properties.info.id } @@ -3074,8 +3231,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper handleNetworkEvent(event.type as string, event.properties as any, this.client, (s) => this.getWorkspaceDirectory(s)) if (event.type === "indexing.status" && directory) { - const current = path.resolve(this.getWorkspaceDirectory(this.currentSession?.id)) - if (path.resolve(directory) !== current) return + if (!sameDirectory(directory, this.getWorkspaceDirectory(this.currentSession?.id))) return } const msg = mapSSEEventToWebviewMessage(event, sessionID) @@ -3196,8 +3352,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return controller } - private async gatherEditorContext(): Promise { - const workspaceDir = this.getWorkspaceDirectory() + private async gatherEditorContext(dir?: string): Promise { + const workspaceDir = dir ?? this.getWorkspaceDirectory() const controller = await this.getIgnoreController(workspaceDir) const toRelative = (fsPath: string): string | undefined => { @@ -3403,6 +3559,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() + this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() this.streams.dispose() this.isWebviewReady = false diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index a511ec63c89..8f7cad3014a 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -3,11 +3,11 @@ import * as path from "path" import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" -import { resolveLocalDiffTarget } from "../review-utils" +import { resolveLocalDiffTarget } from "../diff/shared/target" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" -import { remoteRef, WorktreeStateManager } from "./WorktreeStateManager" +import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager" import { handleSection } from "./section-handler" import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" @@ -29,6 +29,8 @@ import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" 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 { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" @@ -68,6 +70,8 @@ export class AgentManagerProvider implements Disposable { private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined private unsubTool: (() => void) | undefined + private closing: Promise | undefined + private onVisibilityChange: ((visible: boolean) => void) | undefined /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on @@ -117,7 +121,6 @@ export class AgentManagerProvider implements Disposable { getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, - getClient: () => this.connectionService.getClient(), 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)), @@ -179,10 +182,15 @@ export class AgentManagerProvider implements Disposable { this.attachPanel( this.host.openPanel({ onBeforeMessage: (msg) => this.onMessage(msg), + worktreeDirectories: () => this.getWorktreeDirectories(), }), ) } + public onPanelVisibilityChange(cb: (visible: boolean) => void): void { + this.onVisibilityChange = cb + } + /** Restore the Agent Manager panel from a previously serialized state. * The caller (extension.ts / vscode-host.ts) wraps the raw panel before passing it. */ public deserializePanel(ctx: PanelContext): void { @@ -210,6 +218,7 @@ export class AgentManagerProvider implements Disposable { this.panel = ctx this.statsPoller.setVisible(ctx.visible) + this.onVisibilityChange?.(ctx.visible) ctx.onDidChangeVisibility((visible) => { this.statsPoller.setVisible(visible) }) @@ -231,6 +240,7 @@ export class AgentManagerProvider implements Disposable { this.prBridge.poller.stop() this.diffs.stop() this.panel = undefined + this.onVisibilityChange?.(false) } ctx.sessions.dispose() }) @@ -248,14 +258,23 @@ export class AgentManagerProvider implements Disposable { return } - const migration = await state.load() + await this.ensureGitExclude(manager) + const loaded = await state.load() manager.cleanupOrphanedTempDirs() + if (loaded.status === "failed" && !(await state.prepareRecovery())) { + this.postToWebview({ type: "error", message: "Agent Manager state could not be recovered." }) + this.pushState() + return + } + + await this.recoverWorktrees(manager, state) + // When the .kilocode → .kilo migration rewrote git worktree refs, nudge // VS Code's git extension to re-discover them. Without this, worktrees // won't appear in Source Control until the next VS Code restart. - if (migration.refsFixed > 0) { - this.log(`Migration fixed ${migration.refsFixed} git worktree ref(s), refreshing git`) + if (loaded.refsFixed > 0) { + this.log(`Migration fixed ${loaded.refsFixed} git worktree ref(s), refreshing git`) this.host.refreshGit() } @@ -280,6 +299,26 @@ export class AgentManagerProvider implements Disposable { this.panel?.sessions.recoverPendingPrompts() } + private async ensureGitExclude(manager: WorktreeManager): Promise { + await manager.ensureGitExclude().catch((err) => { + this.log("Failed to update git exclude:", err) + }) + } + + private async recoverWorktrees(manager: WorktreeManager, state: WorktreeStateManager): Promise { + const infos = await manager.discoverWorktrees().catch((err) => { + this.log("Failed to discover worktrees during state recovery:", err) + return [] + }) + if (infos.length === 0) return + + const result = restoreWorktrees(state, infos) + if (result.worktrees === 0 && result.sessions === 0) return + + this.log(`Recovered ${result.worktrees} worktree(s) and ${result.sessions} session(s) from disk`) + await state.flush() + } + // --------------------------------------------------------------------------- // Message interceptor // --------------------------------------------------------------------------- @@ -291,6 +330,7 @@ export class AgentManagerProvider implements Disposable { } msg = await this.contextMessage(msg) const m = msg as unknown as AgentManagerInMessage + if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type) const worktree = await this.onWorktreeMessage(m) if (worktree !== undefined) return worktree @@ -346,7 +386,7 @@ export class AgentManagerProvider implements Disposable { if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId) if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId) if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId) - if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId) + if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId, m.sessionId) if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId, m.messageId) if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId) } @@ -386,13 +426,22 @@ export class AgentManagerProvider implements Disposable { return null } + if ((m.type === "sendMessage" || m.type === "sendCommand") && !m.sessionID) { + const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined + const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined + if (worktree) { + if (m.draftID) this.activeSessionId = m.draftID + return { ...msg, contextDirectory: worktree.path } + } + } + if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) { this.activeSessionId = m.draftID return msg } if (m.type === "requestTerminalContext") { - if (m.sessionID) this.terminalManager.showExisting(m.sessionID) + if (m.sessionID && !this.terminalManager.hasActiveTerminal()) this.terminalManager.showExisting(m.sessionID) return msg } @@ -456,6 +505,7 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "previewImage") return msg + if (m.type === "saveImage") return msg if (m.type === "agentManager.showExistingLocalTerminal") { this.terminalManager.syncLocalOnSessionSwitch() return null @@ -495,6 +545,10 @@ export class AgentManagerProvider implements Disposable { this.state?.setSessionsCollapsed(m.collapsed) return null } + if (m.type === "agentManager.setSidebarCollapsed") { + this.state?.setSidebarCollapsed(m.collapsed) + return null + } if (this.handleSection(m)) return null if (m.type === "agentManager.setReviewDiffStyle") { this.state?.setReviewDiffStyle(m.style) @@ -803,6 +857,40 @@ export class AgentManagerProvider implements Disposable { await this.stateReady.catch((err) => this.log(`${context}: stateReady rejected, continuing:`, err)) } + private shouldWaitForState(m: AgentManagerInMessage): boolean { + switch (m.type) { + case "agentManager.deleteWorktree": + case "agentManager.removeStaleWorktree": + case "agentManager.openLocally": + case "agentManager.addSessionToWorktree": + case "agentManager.closeSession": + case "agentManager.persistSession": + case "agentManager.forgetSession": + case "agentManager.renameWorktree": + case "agentManager.requestBranches": + case "agentManager.importFromBranch": + case "agentManager.importFromPR": + case "agentManager.importExternalWorktree": + case "agentManager.importAllExternalWorktrees": + case "agentManager.setTabOrder": + case "agentManager.setWorktreeOrder": + case "agentManager.setSessionsCollapsed": + case "agentManager.setSidebarCollapsed": + case "agentManager.setReviewDiffStyle": + case "agentManager.setDefaultBaseBranch": + case "agentManager.createSection": + case "agentManager.renameSection": + case "agentManager.deleteSection": + case "agentManager.setSectionColor": + case "agentManager.toggleSectionCollapsed": + case "agentManager.moveToSection": + case "agentManager.moveSection": + return true + default: + return false + } + } + private onToolEvent(event: unknown, directory?: string): void { const properties = (event as { properties?: unknown }).properties const req = parseToolRequest(properties) @@ -958,13 +1046,27 @@ export class AgentManagerProvider implements Disposable { } this.registerWorktreeSession(sessionId, created.result.path) + await this.recordPromotionHandoff(sessionId, created.result.path, created.result.branch) this.notifyWorktreeReady(sessionId, created.result, created.worktree.id) this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`) return null } + private async recordPromotionHandoff(sessionId: string, dir: string, branch: string): Promise { + try { + await recordPromotionHandoff({ + client: this.connectionService.getClient(), + sessionId, + directory: dir, + branch, + }) + } catch (err) { + this.log("Failed to record worktree promotion handoff:", getErrorMessage(err)) + } + } + /** Add a new session to an existing worktree. */ - private async onAddSessionToWorktree(worktreeId: string): Promise { + private async onAddSessionToWorktree(worktreeId: string, sessionId?: string): Promise { let client: KiloClient try { client = this.connectionService.getClient() @@ -983,6 +1085,26 @@ export class AgentManagerProvider implements Disposable { return null } + if (sessionId) { + if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId) + else state.addSession(sessionId, worktreeId) + this.registerWorktreeSession(sessionId, worktree.path) + this.pushState() + this.postToWebview({ + type: "agentManager.sessionAdded", + sessionId, + worktreeId, + }) + this.host.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId, + worktreeId, + existing: true, + }) + this.log(`Added existing session ${sessionId} to worktree ${worktreeId}`) + return null + } + let session: Session try { const { data } = await client.session.create( @@ -1296,6 +1418,9 @@ export class AgentManagerProvider implements Disposable { // --------------------------------------------------------------------------- private registerWorktreeSession(sessionId: string, directory: string): void { + const worktree = this.state?.findWorktreeByPath(directory) + if (worktree) this.writeMetadata(sessionId, worktree) + if (!this.panel) return this.panel.sessions.setSessionDirectory(sessionId, directory) this.panel.sessions.trackSession(sessionId) @@ -1305,6 +1430,14 @@ export class AgentManagerProvider implements Disposable { this.panel.sessions.recoverPendingPrompts() } + private writeMetadata(sessionId: string, worktree: Worktree): void { + const manager = this.getWorktreeManager() + if (!manager) return + void manager + .writeMetadata(worktree.path, sessionId, worktree.parentBranch, worktree.remote) + .catch((err) => this.log(`Failed to write worktree metadata for ${worktree.id}:`, err)) + } + /** Route a plan follow-up session to its worktree instead of LOCAL. */ private adoptFollowupInWorktree(session: Session, directory: string): void { const state = this.getStateManager() @@ -1405,6 +1538,7 @@ export class AgentManagerProvider implements Disposable { tabOrder: state.getTabOrder(), worktreeOrder: state.getWorktreeOrder(), sessionsCollapsed: state.getSessionsCollapsed(), + sidebarCollapsed: state.getSidebarCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), isGitRepo: true, @@ -1551,6 +1685,14 @@ export class AgentManagerProvider implements Disposable { return this.panel?.sessions.getSessionDirectories() ?? new Map() } + public getWorktreeDirectories(): string[] { + return ( + this.getStateManager() + ?.getWorktrees() + .map((wt) => wt.path) ?? [] + ) + } + /** * Continue a sidebar session in a new worktree. * Captures git state, creates worktree, applies state, forks session. @@ -1614,7 +1756,18 @@ export class AgentManagerProvider implements Disposable { this.panel?.postMessage(message) } + public shutdown(): Promise { + if (!this.closing) this.closing = this.disposeAsync() + return this.closing + } + public dispose(): void { + void this.shutdown() + } + + private async disposeAsync(): Promise { + 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.connectionService.unregisterFocused("agent-manager") this.connectionService.registerOpen("agent-manager", []) @@ -1624,7 +1777,7 @@ export class AgentManagerProvider implements Disposable { this.prBridge.poller.stop() this.run.dispose() this.terminalManager.dispose() - void this.terminalRouter.dispose() + await this.terminalRouter.dispose() this.panel?.dispose() this.outputChannel.dispose() this.host.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 0f0ff9a2048..c490a576411 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -3,7 +3,13 @@ import * as os from "os" import * as fs from "fs/promises" import { spawn } from "../util/process" import simpleGit from "simple-git" -import { parseWorktreeList, normalizePath } from "./git-import" +import { + parseWorktreeList, + normalizePath, + parseForEachRefOutput, + buildBranchList, + type BranchListItem, +} from "./git-import" import type { Semaphore } from "./semaphore" interface GitOpsOptions { @@ -42,6 +48,14 @@ export interface ExecResult { 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 + * `GIT_SSH_COMMAND` originated from Kilo (safe) or was inherited from the + * parent process (untrusted). + */ +export const KILO_NON_INTERACTIVE_SSH_COMMAND = "ssh -o BatchMode=yes" + /** * Build environment variables that prevent git and SSH from opening interactive * prompts. Used for background operations (e.g. periodic fetch) so users with @@ -57,11 +71,20 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv { GIT_TERMINAL_PROMPT: "0", } if (!process.env.GIT_SSH_COMMAND) { - env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes" + env.GIT_SSH_COMMAND = KILO_NON_INTERACTIVE_SSH_COMMAND } return env } +/** + * True when `env.GIT_SSH_COMMAND` is the fixed value Kilo sets, rather than + * an inherited one from the parent process. Use this to decide whether it's + * safe to pass `allowUnsafeSshCommand: true` to simple-git. + */ +export function isKiloOwnedSshCommand(env: NodeJS.ProcessEnv): boolean { + return env.GIT_SSH_COMMAND === KILO_NON_INTERACTIVE_SSH_COMMAND +} + export class GitOps { private readonly log: (...args: unknown[]) => void private readonly runGit: (args: string[], cwd: string) => Promise @@ -219,6 +242,31 @@ export class GitOps { .catch(() => false) } + /** + * List local branches and `origin/*` remotes sorted by last commit date, + * with the resolved default branch flagged. Mirrors WorktreeManager's + * `listBranches` shape but takes `cwd` per call so it works outside the + * worktree context (e.g. for the diff viewer's base branch picker). + */ + async listBranches(cwd: string): Promise<{ branches: BranchListItem[]; defaultBranch: string }> { + const def = (await this.resolveDefaultBranch(cwd)) ?? "" + const raw = await this.raw( + [ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)\t%(committerdate:iso-strict)", + "refs/heads/", + "refs/remotes/origin/", + ], + cwd, + ).catch((err) => { + this.log("listBranches: for-each-ref failed", err instanceof Error ? err.message : String(err)) + return "" + }) + const { locals, remotes, dates } = parseForEachRefOutput(raw) + return { branches: buildBranchList(locals, remotes, dates, def), defaultBranch: def } + } + /** Return the set of worktree paths for the repo, excluding bare entries. */ async listWorktreePaths(cwd: string): Promise> { const raw = await this.raw(["worktree", "list", "--porcelain"], cwd) diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 63e310f8bc2..189045d5a76 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -182,6 +182,10 @@ export class SessionTerminalManager { return entry !== undefined && entry.terminal.exitStatus === undefined } + hasActiveTerminal(): boolean { + return this.host.activeTerminal() !== undefined + } + dispose(): void { void this.host.setContext("kilo-code.agentTerminalFocus", false) for (const entry of this.terminals.values()) entry.terminal.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 6a64d4bd176..ae1d77e05d6 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -11,7 +11,7 @@ import * as fs from "fs" import { randomUUID } from "crypto" import simpleGit, { type SimpleGit } from "simple-git" import { generateBranchName, sanitizeBranchName } from "./branch-name" -import { type GitOps, nonInteractiveEnv } from "./GitOps" +import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps" import { execWithShellEnv } from "./shell-env" import { markNoIndex } from "../util/spotlight" import { @@ -31,7 +31,7 @@ import { const TEMP_PREFIX = ".kilo-delete-" const RM_OPTS: fs.RmOptions = { recursive: true, force: true, maxRetries: 3, retryDelay: 200 } -interface WorktreeInfo { +export interface WorktreeInfo { branch: string path: string /** Bare branch name (e.g. "main"), without remote prefix. */ @@ -86,6 +86,7 @@ import { KILO_DIR, LEGACY_DIR, migrateAgentManagerData } from "./constants" const SESSION_ID_FILE = "session-id" const METADATA_FILE = "metadata.json" +const GIT_METADATA_FILE = "kilo-agent-manager-metadata.json" export class WorktreeManager { private readonly root: string @@ -438,24 +439,21 @@ export class WorktreeManager { } async writeMetadata(worktreePath: string, sessionId: string, parentBranch: string, remote?: string): Promise { - const dir = path.join(worktreePath, KILO_DIR) - if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }) - const meta: Record = { sessionId, parentBranch } if (remote) meta.remote = remote - // Write both formats: session-id for backward compat, metadata.json for parentBranch+remote - await Promise.all([ - fs.promises.writeFile(path.join(dir, SESSION_ID_FILE), sessionId, "utf-8"), - fs.promises.writeFile(path.join(dir, METADATA_FILE), JSON.stringify(meta), "utf-8"), - ]) + const file = await this.gitMetadataPath(worktreePath) + if (!file) throw new Error(`Could not resolve git metadata directory for ${worktreePath}`) + await fs.promises.writeFile(file, JSON.stringify(meta), "utf-8") this.log(`Wrote metadata for session ${sessionId} to ${worktreePath}`) - await this.ensureWorktreeExclude(worktreePath) } async readMetadata( worktreePath: string, ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + const current = await this.readCurrentMetadata(worktreePath) + if (current) return current + // Check .kilo/ first, then legacy .kilocode/ for (const dirName of [KILO_DIR, LEGACY_DIR]) { const result = await this.readMetadataFrom(worktreePath, dirName) @@ -464,6 +462,44 @@ export class WorktreeManager { return undefined } + private async readCurrentMetadata( + worktreePath: string, + ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + try { + const file = await this.gitMetadataPath(worktreePath) + if (!file) return undefined + const content = await fs.promises.readFile(file, "utf-8") + const data = JSON.parse(content) as { sessionId?: string; parentBranch?: string; remote?: string } + if (!data.sessionId) return undefined + return { + sessionId: data.sessionId, + parentBranch: data.parentBranch, + remote: data.remote, + } + } catch (e) { + this.log(`readMetadata: git metadata unreadable in ${worktreePath}: ${e}`) + return undefined + } + } + + private async gitMetadataPath(worktreePath: string): Promise { + const dir = await this.worktreeGitDir(worktreePath) + if (!dir) return undefined + return path.join(dir, GIT_METADATA_FILE) + } + + private async worktreeGitDir(worktreePath: string): Promise { + const gitPath = path.join(worktreePath, ".git") + const stat = await fs.promises.stat(gitPath) + if (stat.isDirectory()) return gitPath + if (!stat.isFile()) return undefined + + const content = await fs.promises.readFile(gitPath, "utf-8") + const match = content.match(/^gitdir:\s*(.+)$/m) + if (!match) return undefined + return path.resolve(worktreePath, match[1].trim()) + } + private async readMetadataFrom( worktreePath: string, dirName: string, @@ -526,20 +562,6 @@ export class WorktreeManager { } } - private async ensureWorktreeExclude(worktreePath: string): Promise { - try { - const content = await fs.promises.readFile(path.join(worktreePath, ".git"), "utf-8") - const match = content.match(/^gitdir:\s*(.+)$/m) - if (!match) return - - const worktreeGitDir = path.resolve(worktreePath, match[1].trim()) - const mainGitDir = path.dirname(path.dirname(worktreeGitDir)) - await this.addExcludeEntry(path.join(mainGitDir, "info", "exclude"), `${KILO_DIR}/`, "Kilo Code session metadata") - } catch (error) { - this.log(`Warning: Failed to update git exclude for worktree: ${error}`) - } - } - /** * Returns true when target is strictly inside the managed worktrees directory. * Prevents sibling-prefix confusion such as "/worktrees-evil". @@ -669,7 +691,13 @@ export class WorktreeManager { // Use non-interactive env to prevent SSH passphrase popups. onProgress?.("fetching", `Fetching ${remote}/${branch}...`) try { - await simpleGit(this.root).env(nonInteractiveEnv()).fetch(remote, branch) + // Only opt into simple-git's allowUnsafeSshCommand when the SSH command + // is the fixed value Kilo injects — never for an inherited one, which + // could be attacker-controlled. + const env = nonInteractiveEnv() + await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } }) + .env(env) + .fetch(remote, branch, { "--quiet": null, "--no-tags": null }) WorktreeManager.fetchCache.set(cacheKey, Date.now()) if (await this.refExistsLocally(`${remote}/${branch}`)) { return { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 3491a613963..352932fde03 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -44,7 +44,7 @@ export interface Section { name: string /** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */ color: string | null - /** Position among top-level sidebar children (sections and ungrouped worktrees). */ + /** Position among sections. Ungrouped worktrees are always rendered above sections. */ order: number collapsed: boolean } @@ -71,10 +71,17 @@ interface StateFile { tabOrder?: Record worktreeOrder?: string[] sessionsCollapsed?: boolean + sidebarCollapsed?: boolean reviewDiffStyle?: "unified" | "split" defaultBaseBranch?: string } +export type StateLoadStatus = "loaded" | "missing" | "failed" + +export interface StateLoadResult extends MigrationResult { + status: StateLoadStatus +} + import { KILO_DIR, migrateAgentManagerData, type MigrationResult } from "./constants" const STATE_FILE = "agent-manager.json" @@ -93,11 +100,13 @@ export class WorktreeStateManager { private tabOrder: Record = {} private worktreeOrder: string[] = [] private collapsed = false + private sidebar = false private reviewDiffStyle: "unified" | "split" = "unified" private defaultBase: string | undefined private readonly log: (msg: string) => void private saving: Promise | undefined - private pendingSave = false + private dirty = false + private failed = false private readonly root: string private migrated = false @@ -188,6 +197,31 @@ export class WorktreeStateManager { return wt } + restoreWorktree(params: { + branch: string + path: string + parentBranch: string + remote?: string + createdAt: string + }): Worktree { + const existing = this.findWorktreeByPath(params.path) + if (existing) return existing + const id = generateId("wt") + const wt: Worktree = { + id, + branch: params.branch, + path: params.path, + parentBranch: params.parentBranch, + createdAt: params.createdAt, + } + if (params.remote) wt.remote = params.remote + this.worktrees.set(id, wt) + this.setNormalizedWorktreeOrder(this.worktreeOrder) + this.log(`Restored worktree ${id}: ${params.branch} (${params.path})`) + void this.save() + return wt + } + updateWorktreeBranch(id: string, branch: string): boolean { const wt = this.worktrees.get(id) if (!wt || wt.branch === branch) return false @@ -302,6 +336,14 @@ export class WorktreeStateManager { void this.save() } + private ordered(order: string[]): string[] { + const idx = new Map(order.map((id, i) => [id, i] as const)) + return [...this.worktrees.values()] + .filter((wt) => !wt.sectionId) + .sort((a, b) => (idx.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (idx.get(b.id) ?? Number.MAX_SAFE_INTEGER)) + .map((wt) => wt.id) + } + private setNormalizedWorktreeOrder(order: string[]): boolean { const valid = new Set() for (const sec of this.sections.values()) valid.add(sec.id) @@ -319,17 +361,22 @@ export class WorktreeStateManager { for (const sec of [...this.sections.values()].sort((a, b) => a.order - b.order)) add(sec.id) for (const wt of this.worktrees.values()) add(wt.id) + const normalized = [ + ...this.ordered(result), + ...result.filter((id) => this.sections.has(id)), + ...result.filter((id) => this.worktrees.get(id)?.sectionId), + ] + const changed = - result.length !== this.worktreeOrder.length || result.some((id, idx) => id !== this.worktreeOrder[idx]) - this.worktreeOrder = result + normalized.length !== this.worktreeOrder.length || normalized.some((id, idx) => id !== this.worktreeOrder[idx]) + this.worktreeOrder = normalized return this.syncSectionOrder() || changed } private syncSectionOrder(): boolean { const top = this.worktreeOrder.filter((id) => { if (this.sections.has(id)) return true - const wt = this.worktrees.get(id) - return !!wt && !wt.sectionId + return false }) const index = new Map(top.map((id, idx) => [id, idx] as const)) const changes = [...this.sections.values()].map((sec) => { @@ -477,6 +524,19 @@ export class WorktreeStateManager { void this.save() } + // --------------------------------------------------------------------------- + // Sidebar collapsed + // --------------------------------------------------------------------------- + + getSidebarCollapsed(): boolean { + return this.sidebar + } + + setSidebarCollapsed(value: boolean): void { + this.sidebar = value + void this.save() + } + // --------------------------------------------------------------------------- // Review diff style // --------------------------------------------------------------------------- @@ -507,7 +567,7 @@ export class WorktreeStateManager { // Persistence // --------------------------------------------------------------------------- - async load(): Promise { + async load(): Promise { // Migrate Agent Manager data from .kilocode → .kilo before first read let migration: MigrationResult = { refsFixed: 0 } if (!this.migrated) { @@ -518,15 +578,34 @@ export class WorktreeStateManager { const content = await fs.promises.readFile(this.file, "utf-8") this.apply(content) this.loadFailed = false + return { ...migration, status: "loaded" } } catch (error) { const code = (error as NodeJS.ErrnoException).code - if (code === "ENOENT") this.loadFailed = false + if (code === "ENOENT") { + this.loadFailed = false + return { ...migration, status: "missing" } + } if (code !== "ENOENT") { this.log(`Failed to load state: ${error}`) this.loadFailed = true } } - return migration + return { ...migration, status: "failed" } + } + + async prepareRecovery(): Promise { + if (!this.loadFailed) return true + const stamp = new Date().toISOString().replace(/[:.]/g, "-") + const backup = `${this.file}.corrupt-${stamp}` + try { + await fs.promises.rename(this.file, backup) + this.loadFailed = false + this.log(`Backed up unreadable state to ${backup}`) + return true + } catch (error) { + this.log(`Failed to back up unreadable state: ${error}`) + return false + } } private apply(content: string): void { @@ -549,8 +628,9 @@ export class WorktreeStateManager { let pruned = 0 for (const [id, s] of Object.entries(data.sessions ?? {})) { const ref = s.worktreeId + const session: ManagedSession = { id, worktreeId: s.worktreeId, createdAt: s.createdAt } if (ref === null) { - this.sessions.set(id, { id, ...s }) + this.sessions.set(id, session) continue } // Skip orphaned sessions referencing a deleted worktree. @@ -558,7 +638,7 @@ export class WorktreeStateManager { pruned++ continue } - this.sessions.set(id, { id, ...s }) + this.sessions.set(id, session) } for (const [id, sec] of Object.entries(data.sections ?? {})) { this.sections.set(id, { id, ...sec }) @@ -571,6 +651,7 @@ export class WorktreeStateManager { } const repaired = this.setNormalizedWorktreeOrder(this.worktreeOrder) this.collapsed = data.sessionsCollapsed ?? false + this.sidebar = data.sidebarCollapsed ?? false if (data.reviewDiffStyle === "split") { this.reviewDiffStyle = "split" } @@ -610,7 +691,9 @@ export class WorktreeStateManager { /** Wait for any in-flight save to complete without triggering a new one. */ async flush(): Promise { - if (this.saving) await this.saving + const active = this.saving + if (active) await active + if (this.dirty) await this.save() } async save(): Promise { @@ -619,27 +702,32 @@ export class WorktreeStateManager { return } - // Serialize concurrent saves — if a save is in-flight, queue one follow-up - if (this.saving) { - this.pendingSave = true - await this.saving - // The in-flight save finished but our data may not have been written yet. - // If there's a new save already running (the pendingSave follow-up), wait for it. - if (this.saving) await this.saving - return + this.dirty = true + this.failed = false + while (this.dirty && !this.failed) { + await (this.saving ?? this.startSave()) } + } - this.saving = this.writeToDisk() - try { - await this.saving - } finally { - this.saving = undefined - } + private startSave(): Promise { + const run = this.drain().finally(() => { + if (this.saving === run) this.saving = undefined + }) + this.saving = run + return run + } - // If another save was requested while we were writing, flush it now - if (this.pendingSave) { - this.pendingSave = false - await this.save() + private async drain(): Promise { + while (this.dirty) { + this.dirty = false + try { + await this.writeToDisk() + } catch (error) { + this.dirty = true + this.failed = true + this.log(`Failed to save state: ${error}`) + return + } } } @@ -669,6 +757,9 @@ export class WorktreeStateManager { if (this.collapsed) { data.sessionsCollapsed = true } + if (this.sidebar) { + data.sidebarCollapsed = true + } if (this.reviewDiffStyle === "split") { data.reviewDiffStyle = "split" } diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 0c4dd458fcf..bf1f2dbd434 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -101,6 +101,7 @@ function createHarness() { prBridge: { handleMessage: ReturnType } activeSessionId: string | undefined stateReady: Promise | undefined + contextTarget: ReturnType createWorktreeOnDisk: ReturnType runSetupScriptForWorktree: ReturnType createSessionInWorktree: ReturnType @@ -121,6 +122,7 @@ function createHarness() { manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) } manager.activeSessionId = undefined manager.stateReady = Promise.resolve() + manager.contextTarget = vi.fn() manager.createWorktreeOnDisk = vi.fn() manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined) manager.createSessionInWorktree = vi.fn() @@ -202,4 +204,28 @@ describe("AgentManagerProvider worktree creation", () => { expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" }) }) + + it("resolves new sends to the selected worktree directory", async () => { + const manager = createHarness() + const state = { + getWorktree: vi.fn().mockReturnValue({ id: "wt-1", path: "/repo/.kilo/worktrees/wt-1" }), + } + manager.getStateManager.mockReturnValue(state) + manager.contextTarget.mockResolvedValue(undefined) + + const result = await manager.onMessage({ + type: "sendMessage", + text: "continue", + agentManagerContext: "wt-1", + draftID: "draft-1", + }) + + expect(result).toEqual({ + type: "sendMessage", + text: "continue", + agentManagerContext: "wt-1", + draftID: "draft-1", + contextDirectory: "/repo/.kilo/worktrees/wt-1", + }) + }) }) diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 92970ecac71..079b610f590 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -93,6 +93,7 @@ export interface Host { */ openPanel(opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }): PanelContext /** Get the workspace/project root path. */ diff --git a/packages/kilo-vscode/src/agent-manager/promotion-handoff.ts b/packages/kilo-vscode/src/agent-manager/promotion-handoff.ts new file mode 100644 index 00000000000..31d20870b55 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/promotion-handoff.ts @@ -0,0 +1,35 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export interface PromoteHandoffInput { + client: KiloClient + sessionId: string + directory: string + branch: string +} + +export function handoffText(input: Omit): string { + return [ + "", + "This session was moved to a git worktree.", + `Use this as the current working directory: ${input.directory}`, + `The worktree branch is: ${input.branch}`, + "", + ].join("\n") +} + +export async function recordPromotionHandoff(input: PromoteHandoffInput): Promise { + const payload = { + sessionID: input.sessionId, + directory: input.directory, + noReply: true, + parts: [ + { + type: "text", + text: handoffText(input), + synthetic: true, + }, + ], + } as Parameters[0] + + await input.client.session.promptAsync(payload, { throwOnError: true }) +} diff --git a/packages/kilo-vscode/src/agent-manager/state-recovery.ts b/packages/kilo-vscode/src/agent-manager/state-recovery.ts new file mode 100644 index 00000000000..b39cbc0f529 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/state-recovery.ts @@ -0,0 +1,39 @@ +import type { WorktreeInfo } from "./WorktreeManager" +import type { WorktreeStateManager } from "./WorktreeStateManager" + +export interface RecoveryResult { + worktrees: number + sessions: number +} + +export function restoreWorktrees(state: WorktreeStateManager, infos: WorktreeInfo[]): RecoveryResult { + const result: RecoveryResult = { worktrees: 0, sessions: 0 } + + for (const info of infos) { + const existing = state.findWorktreeByPath(info.path) + const wt = + existing ?? + state.restoreWorktree({ + branch: info.branch, + path: info.path, + parentBranch: info.parentBranch, + remote: info.remote, + createdAt: new Date(info.createdAt).toISOString(), + }) + + if (!existing) result.worktrees++ + if (!info.sessionId) continue + + const session = state.getSession(info.sessionId) + if (!session) { + state.addSession(info.sessionId, wt.id) + result.sessions++ + continue + } + if (session.worktreeId === wt.id) continue + state.moveSession(info.sessionId, wt.id) + result.sessions++ + } + + return result +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 765442070bd..f6f312fe466 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -126,6 +126,7 @@ interface StateMessage { tabOrder?: Record worktreeOrder?: string[] sessionsCollapsed?: boolean + sidebarCollapsed?: boolean reviewDiffStyle?: "unified" | "split" reviewMarkdownRender?: boolean isGitRepo?: boolean @@ -347,6 +348,7 @@ interface OpenLocallyIn { interface AddSessionToWorktreeIn { type: "agentManager.addSessionToWorktree" worktreeId: string + sessionId?: string } interface CloseSessionIn { @@ -456,6 +458,11 @@ interface SetSessionsCollapsedIn { collapsed: boolean } +interface SetSidebarCollapsedIn { + type: "agentManager.setSidebarCollapsed" + collapsed: boolean +} + interface SetReviewDiffStyleIn { type: "agentManager.setReviewDiffStyle" style: "unified" | "split" @@ -564,6 +571,12 @@ interface PreviewImageIn { filename: string } +interface SaveImageIn { + type: "saveImage" + dataUrl: string + filename: string +} + interface LoadMessagesIn { type: "loadMessages" sessionID: string @@ -594,6 +607,7 @@ interface SendMessageIn { variant?: string files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }> agentManagerContext?: string + contextDirectory?: string } interface SendCommandIn { @@ -609,6 +623,7 @@ interface SendCommandIn { variant?: string files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }> agentManagerContext?: string + contextDirectory?: string } interface RequestTerminalContextIn { @@ -730,6 +745,7 @@ export type AgentManagerInMessage = | SetTabOrderIn | SetWorktreeOrderIn | SetSessionsCollapsedIn + | SetSidebarCollapsedIn | SetReviewDiffStyleIn | SetReviewMarkdownRenderIn | SetDefaultBaseBranchIn @@ -750,6 +766,7 @@ export type AgentManagerInMessage = | OpenFileIn | GenericOpenFileIn | PreviewImageIn + | SaveImageIn | LoadMessagesIn | SendMessageIn | SendCommandIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 222ea03857d..54e2b6ce5b3 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -9,6 +9,7 @@ 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 { DiffVirtualProvider } from "../DiffVirtualProvider" import { buildWebviewHtml } from "../utils" import { openFileInEditor, getWorkspaceRoot } from "../review-utils" @@ -35,6 +36,7 @@ export class VscodeHost implements Host { openPanel(opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }): PanelContext { const panel = vscode.window.createWebviewPanel( "kilo-code.new.AgentManagerPanel", @@ -54,6 +56,7 @@ export class VscodeHost implements Host { panel: vscode.WebviewPanel, opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }, ): PanelContext { return this.wirePanel(panel, opts) @@ -63,6 +66,7 @@ export class VscodeHost implements Host { panel: vscode.WebviewPanel, opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }, ): PanelContext { panel.webview.options = { @@ -85,7 +89,9 @@ export class VscodeHost implements Host { }) const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { + platform: PLATFORM, slimEditMetadata: true, + worktreeDirectories: () => opts.worktreeDirectories?.() ?? [], }) if (this.diffVirtual) { provider.setDiffVirtualProvider(this.diffVirtual) 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 26cef806ba2..79694070a7e 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,6 +1,8 @@ -import type { KiloClient } from "@kilocode/sdk/v2/client" -import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" -import { WorktreeDiffClient } from "../worktree-diff-client" +import { SourceController } from "../diff/SourceController" +import { resolveLocalDiffTarget } from "../diff/shared/target" +import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" +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 { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" @@ -10,17 +12,16 @@ const LOCAL_DIFF_ID = "local" as const type Target = { sessionId: string; directory: string; baseBranch: string } +type AgentManagerDiffFile = DiffFile & WorktreeDiffEntry + export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined /** - * SDK client — used by `revert()` via `WorktreeDiffClient` for the one-shot - * file-status lookup. Hot polling paths (`request`, `requestFile`, `poll`) - * deliberately bypass the client and go through `localDiff`/`localDiffFile` - * to keep git spawns out of the Bun `kilo serve` process (see oven-sh/bun#18265). + * In-process diff paths deliberately bypass the SDK client to keep git spawns + * out of the Bun `kilo serve` process (see oven-sh/bun#18265). */ - getClient: () => KiloClient git: GitOps /** In-process diff summary (replaces client.worktree.diffSummary). */ localDiff: (dir: string, base: string) => Promise @@ -31,16 +32,53 @@ export interface WorktreeDiffControllerContext { } export class WorktreeDiffController { - private interval: ReturnType | undefined - private session: string | undefined - private hash: string | undefined + private readonly controller: SourceController private target: Target | undefined private applying: string | undefined - constructor(private readonly ctx: WorktreeDiffControllerContext) {} + constructor(private readonly ctx: WorktreeDiffControllerContext) { + this.controller = new SourceController( + (id) => this.source(id), + () => [], + (msg) => this.ctx.post(msg as AgentManagerOutMessage), + { + loading: (source, loading) => ({ + type: "agentManager.worktreeDiffLoading", + sessionId: source.descriptor.id, + loading, + }), + diffs: (source, diffs) => ({ + type: "agentManager.worktreeDiff", + sessionId: source.descriptor.id, + diffs: diffs as AgentManagerDiffFile[], + }), + diffFile: (source, file, diff) => ({ + type: "agentManager.worktreeDiffFile", + sessionId: source?.descriptor.id ?? "", + file, + diff: diff as AgentManagerDiffFile | null, + }), + revertFileResult: (source, file, result) => ({ + type: "agentManager.revertWorktreeFileResult", + sessionId: source?.descriptor.id ?? "", + file, + status: result.ok ? "success" : "error", + message: result.message, + }), + unsupportedRevert: (source, file) => ({ + type: "agentManager.revertWorktreeFileResult", + sessionId: source?.descriptor.id ?? "", + file, + status: "error", + message: "Revert is not supported for the current source", + }), + }, + ) + this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) + } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.session) + return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -106,128 +144,47 @@ export class WorktreeDiffController { public async revert(sessionId: string, file: string): Promise { if (!file) return - await this.ready("stateReady rejected, continuing revert resolve:") - - const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId) - if (!target) { - this.ctx.post({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: "error", - message: "Could not resolve diff target", - }) + if (this.controller.currentId !== sessionId) { + const result = await this.revertFile(sessionId, file) + this.postRevertResult(sessionId, file, result) return } - - try { - const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) - const result = await diff.revertFile(target, file) - this.ctx.post({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: result.ok ? "success" : "error", - message: result.message, - }) - - if (result.ok) void this.request(sessionId) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - this.ctx.log("Failed to revert worktree file:", msg) - this.ctx.post({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: "error", - message: msg, - }) - } + await this.controller.revertFile(file) } public async request(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - - const target = await this.resolve(sessionId) - if (!target) return - - this.target = { sessionId, ...target } - this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) - - try { - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - this.hash = hashFileDiffs(files) - this.session = sessionId - this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) - } catch (error) { - this.ctx.log("Failed to fetch worktree diff:", error) - } finally { - this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: false }) + if (this.controller.currentId !== sessionId) { + await this.activate(sessionId, false, true) + return } + this.target = undefined + await this.controller.refresh() } public async requestFile(sessionId: string, file: string): Promise { if (!file) return - await this.ready("stateReady rejected, continuing diff detail resolve:") - - const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId) - if (!target) return - - this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch } - - try { - const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file) - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data }) - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) + if (this.controller.currentId !== sessionId) { this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + return } + await this.controller.requestFile(file) } public start(sessionId: string): void { - if (this.session === sessionId && this.interval) { - this.ctx.log(`Already polling session ${sessionId}, skipping restart`) - return - } - - this.stop() - this.session = sessionId - this.hash = undefined + if (this.controller.isPolling && this.controller.currentId === sessionId) return this.ctx.log(`Starting diff polling for session ${sessionId}`) - - void this.request(sessionId).then(() => { - if (this.session !== sessionId) return - this.interval = setInterval(() => { - void this.poll(sessionId) - }, 2500) - }) + void this.activate(sessionId, true, true) } public stop(): void { - if (this.interval) { - clearInterval(this.interval) - this.interval = undefined - } - this.session = undefined - this.hash = undefined + this.controller.stop() this.target = undefined } - private async poll(sessionId: string): Promise { - const target = this.target?.sessionId === sessionId ? this.target : undefined - if (!target) return - - try { - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - const hash = hashFileDiffs(files) - if (hash === this.hash && this.session === sessionId) return - this.hash = hash - this.session = sessionId - this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) - } catch (error) { - this.ctx.log("Failed to poll worktree diff:", error) - } + private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + this.target = undefined + this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) + await this.controller.activate(sessionId, { poll, fetch }) } private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { @@ -266,6 +223,87 @@ export class WorktreeDiffController { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } + private source(sessionId: string): DiffSource { + const descriptor: DiffSourceDescriptor = { + id: sessionId, + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, + } + + return { + descriptor, + fetch: () => this.fetch(sessionId), + fetchFile: (file) => this.fetchFile(sessionId, file), + revert: (file) => this.revertFile(sessionId, file), + } + } + + private async fetch(sessionId: string): Promise { + await this.ready("stateReady rejected, continuing diff resolve:") + const target = await this.ensureTarget(sessionId) + if (!target) return { diffs: [], stopPolling: true } + + const files = await this.ctx.localDiff(target.directory, target.baseBranch) + this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) + return { diffs: files as AgentManagerDiffFile[] } + } + + 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 + } + } + + private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + await this.ready("stateReady rejected, continuing revert resolve:") + const target = await this.resolveTarget(sessionId) + if (!target) return { ok: false, message: "Could not resolve diff target" } + + try { + const status: StatusResolver = async (current, item) => { + const diff = await this.ctx.localDiffFile(current.directory, current.baseBranch, item) + return diff?.status + } + const diff = new WorktreeDiffReverter(this.ctx.git, status, (...args) => this.ctx.log(...args)) + return await diff.revertFile(target, file) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.ctx.log("Failed to revert worktree file:", msg) + return { ok: false, message: msg } + } + } + + private async ensureTarget(sessionId: string): Promise { + if (this.controller.currentId !== sessionId) return undefined + if (this.target?.sessionId === sessionId) return this.target + return await this.resolveTarget(sessionId) + } + + private async resolveTarget(sessionId: string): Promise { + const target = await this.resolve(sessionId) + if (!target) return undefined + this.target = { sessionId, ...target } + return this.target + } + + private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { + this.ctx.post({ + type: "agentManager.revertWorktreeFileResult", + sessionId, + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + } + private postApplyResult( worktreeId: string, status: "checking" | "applying" | "success" | "conflict" | "error", diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts new file mode 100644 index 00000000000..476cdb92034 --- /dev/null +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -0,0 +1,250 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend" +import { appendOutput, getWorkspaceRoot, openWorkspaceRelativeFile } from "../review-utils" +import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" +import { buildWebviewHtml, getWebviewFontSize } from "../utils" +import { watchFontSizeConfig } from "../kilo-provider/font-size" +import type { DiffSourceCatalog } from "./sources/catalog" +import { turnSourceId } from "./sources/turn" +import type { PanelContext } from "./types" +import { SourceController } from "./SourceController" + +type CommentHandler = (comments: unknown[], autoSend: boolean) => void + +export interface DiffViewerProviderOptions { + sessionIdProvider?: () => string | undefined +} + +/** + * Single global "Changes" panel. Owns the webview panel lifecycle and + * routes webview messages to a SourceController, which owns the active + * DiffSource. + */ +export class DiffViewerProvider implements vscode.Disposable { + public static readonly viewType = "kilo-code.new.DiffViewerPanel" + + private panel: vscode.WebviewPanel | undefined + private ctx: PanelContext | undefined + private controller: SourceController | undefined + private panelDisposables: vscode.Disposable[] = [] + private commentHandler: CommentHandler | undefined + private fontConfigDisposable: vscode.Disposable | undefined + private baseBranchOverride: string | undefined + private readonly sessionIdProvider: () => string | undefined + private readonly output: vscode.OutputChannel + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly connection: KiloConnectionService, + private readonly catalog: DiffSourceCatalog, + opts: DiffViewerProviderOptions = {}, + ) { + this.sessionIdProvider = opts.sessionIdProvider ?? (() => undefined) + this.output = vscode.window.createOutputChannel("Kilo Diff Panel") + } + + setCommentHandler(handler: CommentHandler): void { + this.commentHandler = handler + } + + openPanel(ctx: PanelContext): void { + this.ctx = { ...ctx, baseBranchOverride: this.baseBranchOverride } + + if (this.panel && this.controller) { + this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.One) + this.controller.setContext(this.ctx) + const nextId = this.catalog.defaultSourceId(this.ctx) + if (nextId && nextId !== this.controller.currentId) this.swap(nextId) + return + } + + this.createPanel() + } + + /** + * Entry point for the `kilo-code.new.showChanges` command. Composes the + * PanelContext from the arg + injected session/workspace lookups so + * callers don't have to know about it. + * + * When `turnId` is passed, opens the panel scoped to that single turn with + * the source picker hidden — the view becomes a static "diff of this turn" + * rather than the switchable workspace/session viewer. + */ + openFromCommand(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }): void { + const sessionId = arg?.sessionId ?? this.sessionIdProvider() + const turnInitialSourceId = arg?.turnId && sessionId ? turnSourceId(sessionId, arg.turnId) : undefined + this.openPanel({ + workspaceRoot: getWorkspaceRoot(), + sessionId, + initialSourceId: turnInitialSourceId ?? arg?.initialSourceId, + hidePicker: !!turnInitialSourceId, + }) + } + + /** + * Called when VS Code restores a serialized panel after restart. State + * is not persisted, so we discard the panel instead of rewiring it. + */ + deserializePanel(panel: vscode.WebviewPanel): void { + panel.dispose() + } + + dispose(): void { + this.controller?.dispose() + this.controller = undefined + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined + this.disposePanel() + this.output.dispose() + } + + private createPanel(): void { + const panel = vscode.window.createWebviewPanel(DiffViewerProvider.viewType, "Changes", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }) + 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.html = this.getHtml(panel.webview) + this.panel = panel + + this.controller = new SourceController( + (id, ctx) => this.catalog.build(id, ctx), + (ctx) => this.catalog.listAvailable(ctx), + (msg) => void panel.webview.postMessage(msg), + ) + if (this.ctx) this.controller.setContext(this.ctx) + + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = watchFontSizeConfig((msg) => void panel.webview.postMessage(msg)) + + this.panelDisposables.push( + panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg as Record)), + panel.onDidDispose(() => this.onPanelDisposed()), + ) + } + + private onPanelDisposed(): void { + this.log("Panel disposed") + this.controller?.dispose() + this.controller = undefined + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined + this.baseBranchOverride = undefined + this.disposePanel() + } + + private disposePanel(): void { + for (const d of this.panelDisposables) d.dispose() + this.panelDisposables = [] + this.panel = undefined + } + + private onMessage(msg: Record): void { + const handler = this.messageHandlers[msg.type as string] + handler?.(msg) + } + + private readonly messageHandlers: Record) => void> = { + webviewReady: () => this.onWebviewReady(), + selectSource: (msg) => { + if (typeof msg.id === "string") this.swap(msg.id) + }, + "diffViewer.sendComments": (msg) => { + if (Array.isArray(msg.comments)) this.commentHandler?.(msg.comments, !!msg.autoSend) + }, + "diffViewer.close": () => this.panel?.dispose(), + "diffViewer.setDiffStyle": () => {}, + "diffViewer.setMarkdownRender": (msg) => { + if (typeof msg.render === "boolean") void setDiffMarkdownRender(msg.render) + }, + "diffViewer.revertFile": (msg) => { + if (typeof msg.file === "string") void this.controller?.revertFile(msg.file) + }, + "diffViewer.requestFile": (msg) => { + if (typeof msg.file === "string") void this.controller?.requestFile(msg.file) + }, + "diffViewer.requestBranches": () => { + void this.sendBranches() + }, + "diffViewer.setBaseBranch": (msg) => { + const branch = typeof msg.branch === "string" && msg.branch.length > 0 ? msg.branch : undefined + this.baseBranchOverride = branch + if (this.ctx) { + this.ctx = { ...this.ctx, baseBranchOverride: branch } + this.controller?.setContext(this.ctx) + } + void this.controller?.reactivate() + void this.sendBranches() + }, + openFile: (msg) => { + if (typeof msg.filePath !== "string") return + openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) + }, + } + + private async sendBranches(): Promise { + if (!this.panel) return + try { + const result = await this.catalog.listWorkspaceBranches(this.baseBranchOverride) + if (!result || !this.panel) return + void this.panel.webview.postMessage({ + type: "diffViewer.branches", + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } catch (err) { + this.log("Failed to list workspace branches:", err instanceof Error ? err.message : String(err)) + } + } + + private onWebviewReady(): void { + if (!this.panel) return + void this.panel.webview.postMessage({ + type: "ready", + vscodeLanguage: vscode.env.language, + languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), + fontSize: getWebviewFontSize(), + workspaceDirectory: getWorkspaceRoot(), + }) + void this.panel.webview.postMessage({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) + const initial = this.ctx ? this.catalog.defaultSourceId(this.ctx) : undefined + if (initial) this.swap(initial) + } + + private swap(id: string): void { + if (!this.panel || !this.controller) return + if (this.controller.currentId === id) return + + void this.panel.webview.postMessage({ type: "diffViewer.loading", loading: true }) + void this.panel.webview.postMessage({ type: "diffViewer.diffs", diffs: [] }) + void this.panel.webview.postMessage({ type: "diffViewer.notice", notice: undefined }) + + void this.controller.activate(id).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to activate source:", message) + }) + } + + private getHtml(webview: vscode.Webview): string { + return buildWebviewHtml(webview, { + 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")), + title: "Changes", + port: this.connection.getServerInfo()?.port, + extraStyles: "#root { display: flex; flex-direction: column; }", + }) + } + + private log(...args: unknown[]): void { + appendOutput(this.output, "DiffViewerProvider", ...args) + } +} diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts new file mode 100644 index 00000000000..47d1a771471 --- /dev/null +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -0,0 +1,251 @@ +import { hashFileDiffs } from "./shared/hash" +import { DIFF_POLL_INTERVAL_MS } from "./polling" +import type { DiffSource, DiffSourceCapabilities, DiffSourceDescriptor, DiffSourceNotice } from "./sources/types" +import type { DiffFile } from "./types" +import type { PanelContext } from "./types" + +type Messages = { + available?: (descriptors: DiffSourceDescriptor[], id: string) => unknown + capabilities?: (capabilities: DiffSourceCapabilities) => unknown + loading?: (source: DiffSource, loading: boolean) => unknown + diffs: (source: DiffSource, diffs: DiffFile[]) => unknown + notice?: (source: DiffSource, notice: DiffSourceNotice | undefined) => unknown + diffFile: (source: DiffSource | undefined, file: string, diff: DiffFile | null) => unknown + revertFileResult: (source: DiffSource | undefined, file: string, result: { ok: boolean; message: string }) => unknown + unsupportedRevert: (source: DiffSource | undefined, file: string) => unknown +} + +type ActivateOptions = { poll?: boolean; fetch?: boolean } + +const viewerMessages: Messages = { + available: (descriptors, id) => ({ + type: "setAvailableSources", + descriptors, + currentId: id, + }), + capabilities: (capabilities) => ({ + type: "diffViewer.capabilities", + capabilities, + }), + loading: (_source, loading) => ({ type: "diffViewer.loading", loading }), + diffs: (_source, diffs) => ({ type: "diffViewer.diffs", diffs }), + notice: (_source, notice) => ({ type: "diffViewer.notice", notice }), + diffFile: (_source, file, diff) => ({ type: "diffViewer.diffFile", file, diff }), + revertFileResult: (_source, file, result) => ({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }), + unsupportedRevert: (_source, file) => ({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Revert is not supported for the current source", + }), +} + +/** + * Owns the active DiffSource for a panel: builds it via the injected `build` + * function, runs an initial fetch, and then polls on a fixed interval with + * hash-dedup. Posts loading / diffs / notice messages to the webview, and + * disposes the source on swap or teardown. + * + * Sources are declarative — they only implement `fetch()` (and optionally + * `fetchFile` / `revert` / `dispose`). All lifecycle, polling, and message + * posting lives here so that concrete sources can be plain factory functions + * with closure state instead of classes with a `post`/`start`/`dispose` dance. + * + * Stale results are filtered via an internal epoch counter that bumps on + * every stop/activate, so in-flight fetches from a disposed source are + * dropped. + */ +export class SourceController { + private ctx: PanelContext | undefined + private activeId: string | undefined + private active: DiffSource | undefined + private interval: ReturnType | undefined + private lastHash: string | undefined + private epoch = 0 + + constructor( + private readonly build: (id: string, ctx: PanelContext) => DiffSource, + private readonly listAvailable: (ctx: PanelContext) => DiffSourceDescriptor[], + private readonly post: (msg: unknown) => void, + private readonly messages: Messages = viewerMessages, + ) {} + + setContext(ctx: PanelContext): void { + this.ctx = ctx + } + + get currentId(): string | undefined { + return this.activeId + } + + get isPolling(): boolean { + return this.interval !== undefined + } + + /** Dispose the active source and bump the epoch so in-flight fetches are dropped. */ + stop(): void { + this.epoch++ + this.stopPolling() + this.active?.dispose?.() + this.active = undefined + this.activeId = undefined + this.lastHash = undefined + } + + /** + * Build, initial-fetch, and start polling source `id` in the current context. + * Disposes any previously active source. Throws if the catalog can't build + * the id — callers should catch and log. + */ + async activate(id: string, opts: ActivateOptions = {}): Promise { + const ctx = this.ctx + if (!ctx) return + this.stop() + const epoch = this.epoch + this.activeId = id + + const source = this.build(id, ctx) + this.active = source + + this.send(this.messages.available?.(this.listAvailable(ctx), id)) + this.send(this.messages.capabilities?.(source.descriptor.capabilities)) + + if (opts.fetch === false) return + + const keepPolling = await this.runFetch(source, epoch, true) + // Prevents the polling interval from starting after teardown or swap. + if (this.epoch !== epoch || this.activeId !== id) return + if (opts.poll !== false && keepPolling) this.startPolling(source, epoch) + } + + /** + * Rebuild the active source with current catalog state. Used when an + * external setting changes (e.g. base branch override) and the source + * needs to refetch with the new params, but the source id stays the same. + * No-op when nothing is active. + */ + async reactivate(): Promise { + const id = this.activeId + if (!id) return + await this.activate(id) + } + + async revertFile(file: string): Promise { + const source = this.active + if (!source?.revert) { + this.send(this.messages.unsupportedRevert(source, file)) + return + } + + const epoch = this.epoch + const result = await source.revert(file).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message } + }) + this.send(this.messages.revertFileResult(source, file, result)) + // Push fresh diffs immediately after a successful revert so the webview + // doesn't have to wait for the next polling tick. + if (result.ok && this.epoch === epoch && this.active === source) { + await this.runFetch(source, epoch, false) + } + } + + /** Run an immediate fetch against the active source without changing polling state. */ + async refresh(): Promise { + const source = this.active + if (!source) return + const epoch = this.epoch + await this.runFetch(source, epoch, true) + } + + /** + * Lazy detail load for a single file. Forwards to the active source's + * `fetchFile`. Posts `diff: null` when the source can't resolve the file + * or doesn't support per-file detail, so the webview can clear its + * pending-loading indicator either way. + */ + async requestFile(file: string): Promise { + const source = this.active + const epoch = this.epoch + if (!source) { + this.send(this.messages.diffFile(undefined, file, null)) + return + } + if (!source.fetchFile) { + this.send(this.messages.diffFile(source, file, null)) + return + } + const diff = await source.fetchFile(file).catch(() => null) + // Drop the response if the source has been disposed/swapped while we waited. + if (this.epoch !== epoch) return + this.send(this.messages.diffFile(source, file, diff)) + } + + dispose(): void { + this.stop() + } + + /** + * Run one fetch against the source and post results. Returns whether the + * controller should keep polling this source — false when the source + * requests a stop or the epoch has moved on. + */ + private async runFetch(source: DiffSource, epoch: number, initial: boolean): Promise { + if (initial) this.send(this.messages.loading?.(source, true)) + + try { + const result = await source.fetch() + if (this.epoch !== epoch) return false + + if (result.notice !== undefined) { + this.send(this.messages.notice?.(source, result.notice)) + } + + const hash = hashFileDiffs(result.diffs as never) + if (initial || hash !== this.lastHash) { + this.lastHash = hash + this.send(this.messages.diffs(source, result.diffs)) + } + + return !result.stopPolling + } catch (err) { + if (this.epoch !== epoch) return false + // Errors are swallowed for the webview (it just needs the loading + // indicator cleared below), but we always log so initial-fetch + // failures leave a trace in the Extension Host output — previously + // they were silent and invisible in production. + console.log("[Kilo New] SourceController.fetch error", { initial, err }) + return true + } finally { + if (initial && this.epoch === epoch) { + this.send(this.messages.loading?.(source, false)) + } + } + } + + private send(msg: unknown): void { + if (msg === undefined) return + this.post(msg) + } + + private startPolling(source: DiffSource, epoch: number): void { + this.stopPolling() + this.interval = setInterval(async () => { + // Self-cancel when the tick reports the source is done + const keep = await this.runFetch(source, epoch, false) + if (!keep) this.stopPolling() + }, DIFF_POLL_INTERVAL_MS) + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } +} diff --git a/packages/kilo-vscode/src/diff/polling.ts b/packages/kilo-vscode/src/diff/polling.ts new file mode 100644 index 00000000000..a054aebc67f --- /dev/null +++ b/packages/kilo-vscode/src/diff/polling.ts @@ -0,0 +1,5 @@ +/** + * Shared polling cadence for diff sources in + * `diff/sources/` and the Agent Manager's `WorktreeDiffController` + */ +export const DIFF_POLL_INTERVAL_MS = 2500 diff --git a/packages/kilo-vscode/src/diff/shared/hash.ts b/packages/kilo-vscode/src/diff/shared/hash.ts new file mode 100644 index 00000000000..53bd7116e6e --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/hash.ts @@ -0,0 +1,29 @@ +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" + +export function hashFileDiffs( + diffs: Array< + SnapshotFileDiff & { + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string + } + >, +): string { + return diffs + .map((diff) => { + const content = diff.summarized ? "" : diff.patch + return [ + diff.file, + diff.status, + diff.additions, + diff.deletions, + diff.tracked ? "tracked" : "untracked", + diff.generatedLike ? "generated" : "source", + diff.summarized ? "summary" : "detail", + diff.stamp ?? "", + content, + ].join(":") + }) + .join("|") +} diff --git a/packages/kilo-vscode/src/diff/shared/reverter.ts b/packages/kilo-vscode/src/diff/shared/reverter.ts new file mode 100644 index 00000000000..84900a4ccf7 --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/reverter.ts @@ -0,0 +1,46 @@ +import type { GitOps } from "../../agent-manager/GitOps" + +/** + * A worktree target: the working directory and the base branch we diff + * against (usually the tracking branch). + */ +export type DiffTarget = { directory: string; baseBranch: string } + +export type DiffStatus = "added" | "deleted" | "modified" +export type StatusResolver = (target: DiffTarget, file: string) => Promise + +/** + * Thin coordinator that wraps local diff status lookup with GitOps revert + * behavior used by the Changes panel and Agent Manager. + */ +export class WorktreeDiffReverter { + constructor( + private readonly git: GitOps, + private readonly status: StatusResolver, + private readonly log: (...args: unknown[]) => void, + ) {} + + /** + * Look up the diff status for a single file. Used by revert flows to pick + * the right git strategy (added means delete, modified/deleted means checkout). + * Returns `undefined` on error so callers can still attempt a best-effort + * revert, `GitOps.revertFile` defaults to a modified-file strategy. + */ + async fileStatus(target: DiffTarget, file: string): Promise { + try { + return await this.status(target, file) + } catch (err) { + this.log("Failed to look up file status for revert:", err) + return undefined + } + } + + /** + * Revert a single file in the worktree. Composes `fileStatus` and `GitOps.revertFile`. + * Returns a normalized result; callers handle UI/messaging. + */ + async revertFile(target: DiffTarget, file: string): Promise<{ ok: boolean; message: string }> { + const status = await this.fileStatus(target, file) + return this.git.revertFile(target.directory, target.baseBranch, file, status) + } +} diff --git a/packages/kilo-vscode/src/diff/shared/target.ts b/packages/kilo-vscode/src/diff/shared/target.ts new file mode 100644 index 00000000000..9f1bc49eab3 --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/target.ts @@ -0,0 +1,28 @@ +import type { GitOps } from "../../agent-manager/GitOps" +import { resolveBase } from "../../agent-manager/local-diff" + +export async function resolveLocalDiffTarget( + gitOps: GitOps, + log: (...args: unknown[]) => void, + root?: string, +): Promise<{ directory: string; baseBranch: string } | undefined> { + if (!root) { + log("Local diff: no workspace root") + return + } + + const branch = await gitOps.currentBranch(root) + if (!branch || branch === "HEAD") { + log("Local diff: detached HEAD or no branch") + return + } + + const tracking = await gitOps.resolveTrackingBranch(root, branch) + const fallback = tracking ? undefined : await gitOps.resolveDefaultBranch(root, branch) + const raw = tracking || fallback || "HEAD" + const base = await resolveBase(gitOps, root, raw) + + log(`Local diff: branch=${branch} tracking=${tracking ?? "none"} default=${fallback ?? "none"} base=${base}`) + + return { directory: root, baseBranch: base } +} diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts new file mode 100644 index 00000000000..fa36aaecf2d --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -0,0 +1,158 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../services/cli-backend" +import { GitOps } from "../../agent-manager/GitOps" +import { resolveLocalDiffTarget } from "../shared/target" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { BranchListItem } from "../../agent-manager/git-import" +import type { PanelContext } from "../types" +import type { DiffSource, DiffSourceDescriptor } from "./types" +import { createWorktreeDiffSource, WORKSPACE_DESCRIPTOR, WORKSPACE_SOURCE_ID } from "./worktree" +import { + SESSION_PREFIX, + createSessionDiffSource, + sessionDescriptor, + sessionSourceId, + type SessionDiffFetch, + type SnapshotEnabledCheck, +} from "./session" +import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn" +import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged" +import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged" + +export interface WorkspaceBranchesResult { + branches: BranchListItem[] + defaultBranch: string + /** Resolved auto base (tracking → default), shown next to the "Default" option. */ + autoBase: string | undefined + /** Currently active base — the override when set, otherwise `autoBase`. */ + currentBase: string | undefined + /** True when no override is active and `currentBase === autoBase`. */ + isAuto: boolean + /** Currently checked-out branch (HEAD). Undefined when detached or unresolved. */ + currentBranch: string | undefined +} + +/** + * Enumerates and constructs diff sources for a PanelContext. + */ +export class DiffSourceCatalog implements vscode.Disposable { + private readonly sessionFetch: SessionDiffFetch = async ({ sessionID, directory }) => { + const client = this.connection.getClient() + const { data } = await client.session.diff({ sessionID, directory }, { throwOnError: true }) + return data ?? [] + } + + /** + * Turn diffs are stored on the user message itself (`summary.diffs`), not + * on the session-level snapshot. The `/session/:id/diff` endpoint ignores + * its `messageID` param today, so we fetch the message directly instead. + */ + private readonly turnFetch: TurnDiffFetch = async ({ sessionID, messageID, directory }) => { + const client = this.connection.getClient() + const { data } = await client.session.message({ sessionID, messageID, directory }, { throwOnError: true }) + const info = data?.info + if (!info || info.role !== "user") return [] + return info.summary?.diffs ?? [] + } + + private readonly checkSnapshotsEnabled: SnapshotEnabledCheck = async (directory) => { + const client = this.connection.getClient() + const { data } = await client.config.get({ directory }, { throwOnError: true }) + // Snapshot tracking defaults to true when omitted. + return data?.snapshot !== false + } + + // Lazily created git ops for branch listing / auto base resolution. The + // worktree source has its own GitOps for diff operations; this one is + // owned by the catalog so it survives source swaps. + private branchGit: GitOps | undefined + private branchOutput: vscode.OutputChannel | undefined + + constructor(private readonly connection: KiloConnectionService) {} + + listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { + if (ctx.hidePicker) return [] + const out: DiffSourceDescriptor[] = [] + if (ctx.workspaceRoot) { + out.push(WORKSPACE_DESCRIPTOR) + out.push(STAGED_DESCRIPTOR) + out.push(UNSTAGED_DESCRIPTOR) + } + if (ctx.sessionId) out.push(sessionDescriptor(ctx.sessionId)) + return out + } + + defaultSourceId(ctx: PanelContext): string | undefined { + if (ctx.initialSourceId) return ctx.initialSourceId + if (ctx.workspaceRoot) return WORKSPACE_SOURCE_ID + if (ctx.sessionId) return sessionSourceId(ctx.sessionId) + return undefined + } + + build(id: string, ctx: PanelContext): DiffSource { + if (id === WORKSPACE_SOURCE_ID) { + return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) + } + + if (id === STAGED_SOURCE_ID) return createStagedDiffSource() + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + + if (id.startsWith(TURN_PREFIX)) { + const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") + if (!sessionId || !messageId) { + throw new Error(`DiffSourceCatalog.build: malformed turn id "${id}" (expected turn::)`) + } + return createTurnDiffSource(sessionId, messageId, this.turnFetch, ctx.workspaceRoot) + } + + if (id.startsWith(SESSION_PREFIX)) { + const sessionId = id.slice(SESSION_PREFIX.length) + if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) + return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + } + + throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) + } + + async listWorkspaceBranches(override: string | undefined): Promise { + const root = getWorkspaceRoot() + if (!root) return undefined + + const git = this.ensureBranchGit() + const [{ branches, defaultBranch }, autoTarget, head] = await Promise.all([ + git.listBranches(root), + resolveLocalDiffTarget(git, this.branchLog, root), + git.currentBranch(root), + ]) + const autoBase = autoTarget?.baseBranch + const currentBase = override ?? autoBase + const currentBranch = head && head !== "HEAD" ? head : undefined + return { + branches, + defaultBranch, + autoBase, + currentBase, + isAuto: !override, + currentBranch, + } + } + + dispose(): void { + this.branchGit?.dispose() + this.branchGit = undefined + this.branchOutput?.dispose() + this.branchOutput = undefined + } + + private readonly branchLog = (...args: unknown[]) => { + if (!this.branchOutput) return + appendOutput(this.branchOutput, "DiffSourceCatalog", ...args) + } + + private ensureBranchGit(): GitOps { + if (this.branchGit) return this.branchGit + this.branchOutput = vscode.window.createOutputChannel("Kilo Diff: Branches") + this.branchGit = new GitOps({ log: this.branchLog }) + return this.branchGit + } +} diff --git a/packages/kilo-vscode/src/diff/sources/git-status.ts b/packages/kilo-vscode/src/diff/sources/git-status.ts new file mode 100644 index 00000000000..f2463cf0606 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/git-status.ts @@ -0,0 +1,144 @@ +// Shared parsing helpers for the staged / unstaged diff sources. Both run +// 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 type { DiffFile } from "../types" + +export { MAX_DETAIL_BYTES } from "../../agent-manager/local-diff" + +export type Status = "added" | "deleted" | "modified" + +export interface FileEntry { + file: string + status: Status + additions: number + deletions: number + tracked: boolean + stamp?: string +} + +/** Parse `git diff --name-status` output into entries (status code + path). */ +export function parseNameStatus(stdout: string): { file: string; status: Status }[] { + const out: { file: string; status: Status }[] = [] + for (const line of stdout.split("\n")) { + if (!line) continue + const parts = line.split("\t") + const code = parts[0] + const file = parts.slice(1).join("\t") + if (!code || !file) continue + out.push({ file, status: statusFromCode(code) }) + } + return out +} + +/** Parse `git diff --numstat` output into a per-file `{additions, deletions}` 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 file = parts.slice(2).join("\t") + if (file) map.set(file, { additions, deletions }) + } + return map +} + +function statusFromCode(code: string): Status { + if (code.startsWith("A")) return "added" + if (code.startsWith("D")) return "deleted" + return "modified" +} + +/** + * Build the summarized DiffFile shape the viewer expects. `before`/`after` + * are left empty — the controller fetches detail lazily through `fetchFile`. + */ +export function summarize(entry: FileEntry): DiffFile { + return { + file: entry.file, + before: "", + after: "", + additions: entry.additions, + deletions: entry.deletions, + status: entry.status, + tracked: entry.tracked, + generatedLike: generatedLike(entry.file), + summarized: true, + // 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}`, + } +} + +// Used to tell Git "read the file from the staging area" instead of from a commit. +export const INDEX_REF = "" + +/** + * `git show :` to dump file contents at a specific revision. + * Returns `""` on failure (binary, missing, etc.) — the caller decides whether + * to surface that as `summarized` or just empty. + */ +export async function showBlob(git: GitOps, dir: string, ref: string, file: string): Promise { + const result = await git.execGit(["show", `${ref}:${file}`], dir) + return result.code === 0 ? result.stdout : "" +} + +/** + * 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 + * stores as the blob) rather than following it and reading the target file's + * contents. `""` on missing/unreadable. + * + * Following symlinks here would be both incorrect (mismatches the index + * "before" side from `git show :file`) and unsafe — an untracked symlink + * pointing at e.g. `~/.aws/credentials` would otherwise be surfaced in the + * diff viewer despite `resolveInside` (which is purely lexical). + */ +export async function readDisk(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return "" + const lstat = await fs.lstat(full).catch(() => undefined) + if (!lstat) return "" + if (lstat.isSymbolicLink()) return fs.readlink(full).catch(() => "") + if (!lstat.isFile()) return "" + return fs.readFile(full, "utf-8").catch(() => "") +} + +// 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 { + const result = await git.execGit(["cat-file", "-s", `${ref}:${file}`], dir) + if (result.code !== 0) return 0 + return parseInt(result.stdout.trim(), 10) || 0 +} + +/** + * 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 + * whatever the link points at — see `readDisk` for why. + */ +export async function fileSize(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return 0 + 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 new file mode 100644 index 00000000000..cf10f0e2a2a --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -0,0 +1,81 @@ +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import { normalize, text } from "@kilocode/kilo-ui/session-diff" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" + +export type SessionDiffFetch = (params: { sessionID: string; directory?: string }) => Promise + +export type SnapshotEnabledCheck = (directory?: string) => Promise + +export const SESSION_PREFIX = "session:" + +export function sessionSourceId(sessionId: string): string { + return `${SESSION_PREFIX}${sessionId}` +} + +export function sessionDescriptor(sessionId: string): DiffSourceDescriptor { + return { + id: sessionSourceId(sessionId), + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, + } +} + +/** + * Diff for the current session. Returns file diffs from the SDK's session + * snapshot endpoint, or a `snapshots-disabled` notice if snapshotting is + * turned off in the workspace config (in which case the controller stops + * polling because repeated fetches can't surface new data). + */ +export function createSessionDiffSource( + sessionId: string, + fetch: SessionDiffFetch, + workspaceRoot?: string, + checkSnapshotsEnabled?: SnapshotEnabledCheck, +): DiffSource { + // Cached across fetches so subsequent polling ticks skip the config lookup. + let snapshotsDisabled = false + + return { + descriptor: sessionDescriptor(sessionId), + + async fetch(): Promise { + if (snapshotsDisabled) { + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } + } + + if (checkSnapshotsEnabled) { + const enabled = await checkSnapshotsEnabled(workspaceRoot) + if (!enabled) { + snapshotsDisabled = true + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } + } + } + + const raw = await fetch({ sessionID: sessionId, directory: workspaceRoot }) + return { diffs: raw.map(toSessionDiffFile) } + }, + } +} + +/** + * 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 { + // 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) + return { + file: raw.file, + before: view ? text(view, "deletions") : "", + after: view ? text(view, "additions") : "", + additions: raw.additions, + deletions: raw.deletions, + status: raw.status, + tracked: true, + generatedLike: false, + summarized: raw.patch === "", + } +} diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts new file mode 100644 index 00000000000..9af28badce3 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -0,0 +1,145 @@ +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 type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import { + blobSize, + INDEX_REF, + MAX_DETAIL_BYTES, + parseNameStatus, + parseNumstat, + showBlob, + summarize, + type FileEntry, +} from "./git-status" + +export const STAGED_SOURCE_ID = "staged" + +export const STAGED_DESCRIPTOR: DiffSourceDescriptor = { + id: STAGED_SOURCE_ID, + type: "staged", + group: "Git", + capabilities: { revert: false, comments: true }, +} + +/** + * 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 + * a real git client). Read-only view. + */ +export function createStagedDiffSource(): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) + const git = new GitOps({ log }) + + const root = (): string | undefined => getWorkspaceRoot() + + const listEntries = async (dir: string): Promise => { + const [nameStatus, numstat] = 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), + ]) + 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, + })) + } + + return { + descriptor: STAGED_DESCRIPTOR, + + async fetch(): Promise { + const dir = root() + if (!dir) { + log("No workspace root") + return { diffs: [] } + } + const entries = await listEntries(dir) + log(`Staged diff: ${entries.length} file(s)`) + return { diffs: entries.map(summarize) } + }, + + async fetchFile(file: string): Promise { + const dir = root() + if (!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 + // skip impossible reads (e.g. HEAD: for an added file). + const entry = await fileEntry(git, dir, file, log) + if (!entry) return null + + 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 (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) + } + + // 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 summarized = before === "" && after === "" && entry.status === "modified" + return { + file, + before, + after, + additions: entry.additions, + deletions: entry.deletions, + status: entry.status, + tracked: true, + generatedLike: generatedLike(file), + summarized, + stamp: `${entry.status}:${entry.additions}:${entry.deletions}`, + } + }, + + dispose(): void { + git.dispose() + output.dispose() + }, + } +} + +async function fileEntry( + git: GitOps, + dir: string, + file: string, + log: (...args: unknown[]) => void, +): Promise { + const result = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--cached", "--name-status", "--no-renames", "HEAD", "--", file], + dir, + ) + if (result.code !== 0) { + log("Single-file staged status lookup failed", { file, stderr: result.stderr.trim() }) + return undefined + } + const items = parseNameStatus(result.stdout) + const item = items[0] + if (!item) return undefined + + const counts = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--cached", "--numstat", "--no-renames", "HEAD", "--", file], + dir, + ) + const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") + return { + file: item.file, + status: item.status, + additions: stats.get(item.file)?.additions ?? 0, + deletions: stats.get(item.file)?.deletions ?? 0, + tracked: true, + } +} diff --git a/packages/kilo-vscode/src/diff/sources/turn.ts b/packages/kilo-vscode/src/diff/sources/turn.ts new file mode 100644 index 00000000000..24b6b5e1bc9 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/turn.ts @@ -0,0 +1,53 @@ +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import { toSessionDiffFile } from "./session" + +export const TURN_PREFIX = "turn:" + +export function turnSourceId(sessionId: string, messageId: string): string { + return `${TURN_PREFIX}${sessionId}:${messageId}` +} + +export function turnDescriptor(sessionId: string, messageId: string): DiffSourceDescriptor { + return { + id: turnSourceId(sessionId, messageId), + type: "turn", + // Group is irrelevant here because turn sources only open in hide-picker + // mode; the picker never renders them. Default to "Session" for cohesion. + group: "Session", + capabilities: { revert: false, comments: true }, + } +} + +/** + * Fetches the per-turn diffs attached to a user message. The session-level + * `/session/:id/diff` endpoint ignores `messageID`, so the per-turn view has + * to read from the message's own `summary.diffs`. + */ +export type TurnDiffFetch = (params: { + sessionID: string + messageID: string + directory?: string +}) => Promise + +/** + * Static diff for a single turn (the file changes attributed to one user + * message). Returns `stopPolling: true` so the controller runs `fetch` once + * and never schedules a polling tick — a completed turn's snapshot doesn't + * change. + */ +export function createTurnDiffSource( + sessionId: string, + messageId: string, + fetch: TurnDiffFetch, + workspaceRoot?: string, +): DiffSource { + return { + descriptor: turnDescriptor(sessionId, messageId), + + async fetch(): Promise { + const raw = await fetch({ sessionID: sessionId, messageID: messageId, directory: workspaceRoot }) + return { diffs: raw.map(toSessionDiffFile), stopPolling: true } + }, + } +} diff --git a/packages/kilo-vscode/src/diff/sources/types.ts b/packages/kilo-vscode/src/diff/sources/types.ts new file mode 100644 index 00000000000..6c30639135d --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/types.ts @@ -0,0 +1,65 @@ +import type { DiffFile } from "../types" + +export interface DiffSourceCapabilities { + revert: boolean + comments: boolean +} + +/** + * Closed enum of diff source kinds. Drives i18n key composition for types + * that appear in the picker: `diffViewer.source..label` and + * `diffViewer.source..tooltip`. Types that are only ever shown in + * hide-picker mode (e.g. `turn`) don't need matching i18n entries because + * `DiffPickerHeader` never renders them. + */ +export type DiffSourceType = "workspace" | "session" | "turn" | "staged" | "unstaged" + +export interface DiffSourceDescriptor { + /** Unique within a panel context. E.g. "workspace", "session:". */ + id: string + type: DiffSourceType + group: "Session" | "Git" + /** kilo-ui icon name. */ + icon?: string + capabilities: DiffSourceCapabilities +} + +/** + * Well-known notice kinds that a source can surface to the diff viewer. + * The webview maps these to translated messages. + */ +export type DiffSourceNotice = "snapshots-disabled" + +export interface DiffSourceFetch { + diffs: DiffFile[] + notice?: DiffSourceNotice + /** + * When true the controller stops polling the source after this fetch. + * Used for terminal states like snapshots-disabled, where repeat fetches + * can't surface new data. + */ + stopPolling?: boolean +} + +/** + * A DiffSource is a plain data producer for a given context (local workspace, + * session changes, a turn, a git ref...). The SourceController owns one active + * source at a time, calls `fetch` on activation and on a polling tick, and + * forwards the results to the webview. + */ +export interface DiffSource { + readonly descriptor: DiffSourceDescriptor + + fetch(): Promise + + /** + * Lazy detail load for a single file, for sources that emit summarized + * entries (no `before`/`after` content) so the webview can fetch full + * content on demand. + */ + fetchFile?(file: string): Promise + + revert?(file: string): Promise<{ ok: boolean; message: string }> + + dispose?(): void +} diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts new file mode 100644 index 00000000000..26f1210b928 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -0,0 +1,220 @@ +import * as fs from "fs/promises" +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 type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import { + blobSize, + fileSize, + INDEX_REF, + MAX_DETAIL_BYTES, + parseNameStatus, + parseNumstat, + readDisk, + resolveInside, + showBlob, + summarize, + type FileEntry, +} from "./git-status" + +export const UNSTAGED_SOURCE_ID = "unstaged" + +export const UNSTAGED_DESCRIPTOR: DiffSourceDescriptor = { + id: UNSTAGED_SOURCE_ID, + type: "unstaged", + group: "Git", + capabilities: { revert: false, comments: true }, +} + +/** + * Diff between the working tree and the index — what `git diff` shows for + * tracked files, plus untracked files (treated as fully-added). Read-only; + * polls on the standard interval. + */ +export function createUnstagedDiffSource(): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) + const git = new GitOps({ log }) + + const root = (): string | undefined => getWorkspaceRoot() + + const listTracked = async (dir: string): Promise => { + const [nameStatus, numstat] = 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), + ]) + 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 listUntracked = async (dir: string): Promise => { + const result = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir) + if (result.code !== 0) { + log("git ls-files --others failed", { code: result.code, stderr: result.stderr.trim() }) + return [] + } + const out: FileEntry[] = [] + for (const file of result.stdout.split("\n")) { + if (!file.trim()) continue + const full = resolveInside(dir, file) + if (!full) continue + // `lstat` so an untracked symlink listed by `ls-files --others` is + // recognized via the link itself rather than its target. `readDisk` + // returns the target string for symlinks (matching git's blob), so + // we never read whatever is on the other side. + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) continue + out.push({ + file, + status: "added", + additions: 0, + deletions: 0, + tracked: false, + // 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 + // cache invalidates and refetches detail. Without this the user + // sees stale before/after content while polling continues. + stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`, + }) + } + return out + } + + return { + descriptor: UNSTAGED_DESCRIPTOR, + + async fetch(): Promise { + const dir = root() + if (!dir) { + log("No workspace root") + return { diffs: [] } + } + const [tracked, untracked] = await Promise.all([listTracked(dir), listUntracked(dir)]) + // Drop any tracked entry that's also untracked (shouldn't happen but be + // defensive — git can race here when files are added concurrently). + const seen = new Set(tracked.map((t) => t.file)) + const merged = tracked.concat(untracked.filter((u) => !seen.has(u.file))) + log(`Unstaged diff: ${merged.length} file(s) (${tracked.length} tracked, ${untracked.length} untracked)`) + return { diffs: merged.map(summarize) } + }, + + async fetchFile(file: string): Promise { + const dir = root() + if (!dir || !file) return null + + const entry = await fileEntry(git, dir, file, log) + if (!entry) return null + + 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 }) + return summarize(entry) + } + + // Untracked: no index blob, after = disk content. + // Tracked added/modified/deleted: before = index blob (or "" for added), + // 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 summarized = before === "" && after === "" && entry.status === "modified" + + // For untracked added files numstat doesn't return counts, so backfill + // from the disk content's line count. + const additions = !entry.tracked ? lineCount(after) : entry.additions + return { + file, + before, + after, + additions, + deletions: entry.deletions, + status: entry.status, + tracked: entry.tracked, + generatedLike: generatedLike(file), + summarized, + // Match the summary stamp so cache invalidation is consistent across + // summarize → fetchFile transitions. `entry.stamp` is set for + // untracked entries (size:mtime); tracked entries fall back to the + // numstat-derived stamp. + stamp: entry.stamp ?? `${entry.status}:${additions}:${entry.deletions}`, + } + }, + + dispose(): void { + git.dispose() + output.dispose() + }, + } +} + +async function fileEntry( + git: GitOps, + dir: string, + file: string, + log: (...args: unknown[]) => void, +): Promise { + // Tracked path: `git diff --name-status -- ` resolves status without + // reading content. If the file isn't tracked, fall through to untracked. + const tracked = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", "--", file], + dir, + ) + if (tracked.code === 0 && tracked.stdout.trim()) { + const item = parseNameStatus(tracked.stdout)[0] + if (item) { + const counts = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", "--", file], + dir, + ) + const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") + return { + file: item.file, + status: item.status, + additions: stats.get(item.file)?.additions ?? 0, + deletions: stats.get(item.file)?.deletions ?? 0, + tracked: true, + } + } + } + + // Untracked branch: confirm via fs.lstat that the file actually exists. + // `resolveInside` rejects absolute paths and `..` segments so a crafted + // webview message can't read outside the workspace; `lstat` (vs `stat`) + // ensures we don't follow symlinks into arbitrary filesystem locations. + const full = resolveInside(dir, file) + if (!full) { + log("Unstaged file rejected: outside workspace", { file }) + return undefined + } + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) { + log("Unstaged file not found", { file }) + return undefined + } + return { + file, + status: "added", + additions: 0, + deletions: 0, + tracked: false, + stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`, + } +} + +function lineCount(text: string): number { + if (!text) return 0 + return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length +} diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts new file mode 100644 index 00000000000..fdeea76a44a --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -0,0 +1,159 @@ +import * as vscode from "vscode" +import { GitOps } from "../../agent-manager/GitOps" +import { diffSummary, diffFile } from "../../agent-manager/local-diff" +import type { WorktreeDiffEntry } from "../../agent-manager/types" +import { WorktreeDiffReverter, type DiffTarget, type StatusResolver } from "../shared/reverter" +import { resolveLocalDiffTarget } from "../shared/target" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" + +export const WORKSPACE_SOURCE_ID = "workspace" + +export const WORKSPACE_DESCRIPTOR: DiffSourceDescriptor = { + id: WORKSPACE_SOURCE_ID, + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, +} + +export interface WorktreeDiffSourceOptions { + /** + * When set, overrides the auto-resolved base branch. The HEAD side stays + * the current branch — only the comparison target changes. Reset on dispose. + */ + baseBranchOverride?: string +} + +/** + * Diffs between the local working tree and the base branch. Each fetch returns + * a summary (one entry per changed file, no content); the viewer loads + * `before`/`after` per file on demand via `fetchFile`. Runs entirely in the + * extension host — no `kilo serve` round-trip. + */ +export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) + const git = new GitOps({ log }) + + // Cached between fetches so repeated polling doesn't re-resolve the base + // branch every tick. Reset only on dispose (when the source is swapped out). + let target: DiffTarget | undefined + + const resolveTarget = async (): Promise => { + if (target) return target + if (opts.baseBranchOverride) { + const root = getWorkspaceRoot() + if (!root) { + log("Local diff: no workspace root (override mode)") + return + } + const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + if (!resolved) { + log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) + } else { + target = { directory: root, baseBranch: resolved } + log(`Local diff: using override base=${resolved}`) + return target + } + } + target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + return target + } + + const status: StatusResolver = async (current, file) => { + const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + return entry?.status + } + + return { + descriptor: WORKSPACE_DESCRIPTOR, + + async fetch(): Promise { + const current = await resolveTarget() + if (!current) return { diffs: [] } + + const entries = await diffSummary(git, current.directory, current.baseBranch, log) + const diffs = entries.map(toDiffFile) + log(`Diff: ${diffs.length} file(s)`) + return { diffs } + }, + + async fetchFile(file: string): Promise { + if (!file) return null + const current = await resolveTarget() + if (!current) return null + + try { + const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + if (!entry) return null + return toDiffFile(entry) + } catch (err) { + log("Failed to fetch worktree diff file:", err) + return null + } + }, + + async revert(file: string): Promise<{ ok: boolean; message: string }> { + const current = await resolveTarget() + if (!current) return { ok: false, message: "Could not resolve diff target" } + + try { + const diff = new WorktreeDiffReverter(git, status, log) + return await diff.revertFile(current, file) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + log("Failed to revert file:", message) + return { ok: false, message } + } + }, + + dispose(): void { + git.dispose() + output.dispose() + target = undefined + }, + } +} + +// Branches surfaced by `parseForEachRefOutput` come as short names (e.g. +// `feature` for `refs/remotes/origin/feature`), which `git merge-base` can't +// resolve when there's no local branch of the same name. Try the short name +// first, then `origin/` before giving up. +async function resolveOverrideRef( + git: GitOps, + dir: string, + name: string, + log: (...args: unknown[]) => void, +): Promise { + const direct = await git.execGit(["rev-parse", "--verify", "--quiet", name], dir) + if (direct.code === 0) return name + const remote = `origin/${name}` + const viaRemote = await git.execGit(["rev-parse", "--verify", "--quiet", remote], dir) + if (viaRemote.code === 0) { + log(`override "${name}" not a local ref, resolved to "${remote}"`) + return remote + } + return undefined +} + +/** + * 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. + */ +function toDiffFile(entry: WorktreeDiffEntry): DiffFile { + return { + file: entry.file, + before: entry.before ?? "", + after: entry.after ?? "", + additions: entry.additions, + deletions: entry.deletions, + status: entry.status, + tracked: entry.tracked, + generatedLike: entry.generatedLike, + summarized: entry.summarized, + stamp: entry.stamp, + } +} diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts new file mode 100644 index 00000000000..59dd384f5a4 --- /dev/null +++ b/packages/kilo-vscode/src/diff/types.ts @@ -0,0 +1,27 @@ +export interface PanelContext { + workspaceRoot: string | undefined + sessionId?: string + /** Overrides the computed default source on open. */ + initialSourceId?: string + /** + * Hides the source picker header in the diff viewer. Used for panels that + * open in a fixed view (e.g. a specific turn's diff) + */ + hidePicker?: boolean + /** User-picked base branch for the workspace source. Undefined = auto. */ + baseBranchOverride?: string +} + +/** Mirrors `WorktreeFileDiff` in webview-ui/src/types/messages/agent-manager.ts. */ +export interface DiffFile { + file: string + before: string + after: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string +} diff --git a/packages/kilo-vscode/src/enhance-prompt-error.ts b/packages/kilo-vscode/src/enhance-prompt-error.ts new file mode 100644 index 00000000000..f53f3c854f5 --- /dev/null +++ b/packages/kilo-vscode/src/enhance-prompt-error.ts @@ -0,0 +1,22 @@ +/** + * Convert raw Enhance Prompt provider failures into actionable messages. + */ +export function normalizeEnhancePromptErrorMessage(raw: string): string { + const base = raw || "Failed to enhance prompt" + const normalized = base.toLowerCase() + + const looksLikeQuotaError = + normalized.includes("insufficient_quota") || + normalized.includes("insufficient quota") || + normalized.includes("exceeded your current quota") || + (normalized.includes("quota") && normalized.includes("billing")) + + if (!looksLikeQuotaError) return base + + const details = base === "Failed to enhance prompt" ? "" : ` Provider response: ${base}` + return ( + "Enhance Prompt failed due to provider quota/billing limits. " + + "Check your provider account billing/quota and API access, then retry." + + details + ) +} diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index fadee229dc8..60e972d82e4 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -3,7 +3,8 @@ import { KiloProvider } from "./KiloProvider" import { AgentManagerProvider } from "./agent-manager/AgentManagerProvider" import { VscodeHost } from "./agent-manager/vscode-host" import { KiloClawProvider } from "./kiloclaw/KiloClawProvider" -import { DiffViewerProvider } from "./DiffViewerProvider" +import { DiffViewerProvider } from "./diff/DiffViewerProvider" +import { DiffSourceCatalog } from "./diff/sources/catalog" import { DiffVirtualProvider } from "./DiffVirtualProvider" import { SettingsEditorProvider } from "./SettingsEditorProvider" import { SubAgentViewerProvider } from "./SubAgentViewerProvider" @@ -13,7 +14,7 @@ import { registerAutocompleteProvider } from "./services/autocomplete" import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend" import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" import { BrowserAutomationService } from "./services/browser-automation" -import { TelemetryProxy } from "./services/telemetry" +import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions" import { registerToggleAutoApprove } from "./commands/toggle-auto-approve" @@ -21,17 +22,41 @@ import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" +let agentManager: AgentManagerProvider | undefined +let shuttingDown = false + +const RESTORE_KEY = "kilo.workbench.restore" + +type RestoreState = { + sidebar?: boolean + agentManager?: boolean +} + +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; // 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") + shuttingDown = false const telemetry = TelemetryProxy.getInstance() // 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) + } // Create browser automation service (manages Playwright MCP registration) const browserAutomationService = new BrowserAutomationService(connectionService) @@ -50,6 +75,11 @@ export function activate(context: vscode.ExtensionContext) { const config = connectionService.getServerConfig() if (config) { telemetry.configure(config.baseUrl, config.password) + // Sync the CLI's PostHog client with the current consent state. The + // CLI reads KILO_TELEMETRY_LEVEL once at spawn, so without this call + // a fresh CLI started while VS Code telemetry was off would stay + // opted out for the rest of the session. + telemetry.setEnabled(vscode.env.isTelemetryEnabled) } try { remoteService.setClient(connectionService.getClient()) @@ -65,6 +95,14 @@ export function activate(context: vscode.ExtensionContext) { } }) + // Propagate runtime telemetry consent changes to the CLI subprocess so its + // PostHog client stays in sync with the user's VS Code telemetry setting. + context.subscriptions.push( + vscode.env.onDidChangeTelemetryEnabled((enabled) => { + telemetry.setEnabled(enabled) + }), + ) + // Prewarm the CLI backend early so autocomplete is ready before first editor use. ensureBackendForAutocomplete(connectionService) @@ -85,7 +123,9 @@ export function activate(context: vscode.ExtensionContext) { } // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService, context) + const provider = new KiloProvider(context.extensionUri, connectionService, context, { + onSidebarVisibilityChange: (visible) => remember({ sidebar: visible }), + }) provider.setRemoteService(remoteService) // Register the webview view provider for the sidebar. @@ -95,6 +135,7 @@ 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 @@ -111,6 +152,8 @@ export function activate(context: vscode.ExtensionContext) { // Create Agent Manager provider for editor panel const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context) const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService) + agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) + agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) // Wire "Continue in Worktree" from sidebar → Agent Manager @@ -148,8 +191,13 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.window.registerWebviewPanelSerializer(AgentManagerProvider.viewType, { deserializeWebviewPanel(panel: vscode.WebviewPanel) { + if (restore.agentManager === false) { + panel.dispose() + return Promise.resolve() + } const ctx = agentManagerHost.wrapExistingPanel(panel, { onBeforeMessage: (msg) => agentManagerProvider.handleMessage(msg), + worktreeDirectories: () => agentManagerProvider.getWorktreeDirectories(), }) agentManagerProvider.deserializePanel(ctx) return Promise.resolve() @@ -171,7 +219,9 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.window.registerWebviewPanelSerializer("kilo-code.new.TabPanel", { deserializeWebviewPanel(panel: vscode.WebviewPanel) { - const tabProvider = new KiloProvider(context.extensionUri, connectionService, context) + const tabProvider = new KiloProvider(context.extensionUri, connectionService, context, { + tabTitle: panelTitleHandler(panel), + }) tabProvider.setRemoteService(remoteService) tabProvider.setAutoApproveController(autoApprove) tabProvider.setContinueInWorktreeHandler((sessionId, progress) => @@ -197,8 +247,11 @@ export function activate(context: vscode.ExtensionContext) { }), ) - // Create standalone diff viewer provider for the sidebar "Show Changes" action - const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService) + const diffSourceCatalog = new DiffSourceCatalog(connectionService) + context.subscriptions.push(diffSourceCatalog) + const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService, diffSourceCatalog, { + sessionIdProvider: () => provider.getCurrentSessionId(), + }) diffViewerProvider.setCommentHandler((comments, autoSend) => { void provider.appendReviewComments(comments, autoSend) }) @@ -252,8 +305,39 @@ export function activate(context: vscode.ExtensionContext) { }), ) + // Sidebar menus use wrapper commands so this event measures real title button presses, + // not programmatic opens, shortcuts, or editor title commands. + const track = (button: string, command: string) => { + TelemetryProxy.capture(TelemetryEventName.TITLE_BUTTON_CLICKED, { + button, + surface: "sidebar_title", + }) + void vscode.commands.executeCommand(command) + } + // Register toolbar button command handlers context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.plusButtonClicked", () => { + track("new_task", "kilo-code.new.plusButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.historyButtonClicked", () => { + track("history", "kilo-code.new.historyButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.agentManagerOpen", () => { + track("agent_manager", "kilo-code.new.agentManagerOpen") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.kiloClawOpen", () => { + track("kiloclaw", "kilo-code.new.kiloClawOpen") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.marketplaceButtonClicked", () => { + track("marketplace", "kilo-code.new.marketplaceButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.profileButtonClicked", () => { + track("profile", "kilo-code.new.profileButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.settingsButtonClicked", () => { + track("settings", "kilo-code.new.settingsButtonClicked") + }), vscode.commands.registerCommand("kilo-code.new.plusButtonClicked", () => { const tab = activeTabProvider() if (tab) tab.postMessage({ type: "action", action: "plusButtonClicked" }) @@ -323,9 +407,12 @@ export function activate(context: vscode.ExtensionContext) { autoApprove, ) }), - vscode.commands.registerCommand("kilo-code.new.showChanges", () => { - diffViewerProvider.openPanel() - }), + vscode.commands.registerCommand( + "kilo-code.new.showChanges", + (arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }) => { + diffViewerProvider.openFromCommand(arg) + }, + ), vscode.commands.registerCommand("kilo-code.new.openSubAgentViewer", (sessionID: string, title?: string) => { subAgentViewerProvider.openPanel(sessionID, title) }), @@ -423,6 +510,7 @@ export function activate(context: vscode.ExtensionContext) { // Dispose services when extension deactivates (kills the server) context.subscriptions.push({ dispose: () => { + shuttingDown = true unsubscribeStateChange() browserAutomationService.dispose() provider.dispose() @@ -431,7 +519,9 @@ export function activate(context: vscode.ExtensionContext) { }) } -export function deactivate() { +export async function deactivate() { + shuttingDown = true + await agentManager?.shutdown() TelemetryProxy.getInstance().shutdown() } @@ -464,7 +554,9 @@ async function openKiloInNewTab( dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-dark.svg"), } - const tabProvider = new KiloProvider(context.extensionUri, connectionService, context) + const tabProvider = new KiloProvider(context.extensionUri, connectionService, context, { + tabTitle: panelTitleHandler(panel), + }) tabProvider.setRemoteService(remoteService) tabProvider.setAutoApproveController(autoApprove) tabProvider.setContinueInWorktreeHandler((sessionId, progress) => diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 8aa963f2152..6f0409f90e1 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -3,6 +3,7 @@ import { prettifyError } from "zod/v4" import type { CloudSessionMessage, IndexingStatus } from "./services/cli-backend/types" import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler" import type { PartRemove } from "./shared/stream-messages" +import * as path from "path" export { SessionStreamScheduler } from "./kilo-provider/session-stream-scheduler" @@ -222,6 +223,7 @@ export interface SessionRefreshContext { connectionState: "connecting" | "connected" | "disconnected" | "error" listSessions: ((dir: string) => Promise) | null sessionDirectories: Map + worktreeDirectories?: () => string[] workspaceDirectory: string postMessage(message: unknown): void } @@ -245,7 +247,7 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise() const extra = await Promise.all( [...worktreeDirs].map((dir) => @@ -339,6 +341,7 @@ export function resolveNewSessionDirectory(input: { currentSessionID?: string contextSessionID?: string agentManagerContext?: string + contextDirectory?: string sessionDirectories: Map workspaceDirectory: string }) { @@ -350,6 +353,8 @@ export function resolveNewSessionDirectory(input: { }) } + if (input.contextDirectory) return input.contextDirectory + return resolveContextDirectory({ currentSessionID: input.currentSessionID, contextSessionID: input.contextSessionID, @@ -359,6 +364,17 @@ export function resolveNewSessionDirectory(input: { }) } +export function sameDirectory(a: string, b: string): boolean { + if (!a || !b) return false + + const left = path.resolve(a) + const right = path.resolve(b) + if (path.relative(left, right) === "") return true + + if (process.platform !== "win32") return false + return path.relative(left.toLowerCase(), right.toLowerCase()) === "" +} + export type WebviewMessage = | PartUpdate | PartBatch @@ -607,30 +623,3 @@ export function isEventFromForeignProject(event: Event, expectedProjectID: strin } return false } - -/** - * Merge open-tab paths with backend file search results for the @ mention dropdown. - * - * Ordering: active file → other open tabs → backend results (all deduplicated). - * When a query is present, open tabs are filtered to only include matches. - * The `active` path (if provided) is placed first when it exists in `open`. - */ -export function mergeFileSearchResults(input: { - query: string - backend: string[] - open: Set - active?: string -}): string[] { - const norm = (p: string) => p.replaceAll("\\", "/") - const query = norm(input.query).trim().toLowerCase() - const open = new Set([...input.open].map(norm)) - const active = input.active ? norm(input.active) : undefined - const backend = input.backend.map(norm) - const ok = (p: string) => !query || p.toLowerCase().includes(query) - const tabs = - active && open.has(active) && ok(active) - ? [active, ...[...open].filter((p) => p !== active && ok(p))] - : [...open].filter(ok) - const seen = new Set(tabs) - return [...tabs, ...backend.filter((p) => !seen.has(p))] -} diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts new file mode 100644 index 00000000000..4ff6c787b3d --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -0,0 +1,26 @@ +import { routeSuggestionWebviewMessage } from "./handlers/suggestion" +import * as ModelState from "./model-state" +import { routeInputToolMessage } from "../services/input-tools" +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import type { SuggestionContext } from "./handlers/suggestion" +import type { KiloClient } from "@kilocode/sdk/v2/client" + +type Ctx = { + question: SuggestionContext + client: KiloClient | null + connection: KiloConnectionService + dir: string + post: (msg: unknown) => void + exportTranscript: (sessionID: string) => Promise +} + +export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise { + await routeSuggestionWebviewMessage(ctx.question, message) + if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true + if (message.type === "exportSessionTranscript") { + const input = message as { sessionID?: unknown } + if (typeof input.sessionID === "string") await ctx.exportTranscript(input.sessionID) + return true + } + return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post }) +} diff --git a/packages/kilo-vscode/src/kilo-provider/export-transcript.ts b/packages/kilo-vscode/src/kilo-provider/export-transcript.ts new file mode 100644 index 00000000000..6592d509735 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/export-transcript.ts @@ -0,0 +1,58 @@ +import * as path from "path" +import * as vscode from "vscode" +import type { KiloClient, Message, Part, Session } from "@kilocode/sdk/v2/client" +import { fetchMessagePage } from "./message-page" + +type Item = { + info: Message + parts: Part[] +} + +export async function exportTranscript( + client: KiloClient, + input: { + sessionID: string + dir: string + }, +) { + const [{ data: session }, page] = await Promise.all([ + client.session.get({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true }), + fetchMessagePage(client, { sessionID: input.sessionID, workspaceDir: input.dir, limit: 0 }), + ]) + const text = formatTranscript(session, page.items) + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(input.dir, `session-${session.id.slice(0, 8)}.md`)), + filters: { Markdown: ["md", "markdown"] }, + saveLabel: "Export", + }) + if (!uri) return false + await vscode.workspace.fs.writeFile(uri, Buffer.from(text, "utf8")) + return true +} + +export function formatTranscript(session: Session, items: Item[]): string { + const head = [ + `# ${session.title}`, + "", + `**Session ID:** ${session.id}`, + `**Created:** ${new Date(session.time.created).toLocaleString()}`, + `**Updated:** ${new Date(session.time.updated).toLocaleString()}`, + "", + "---", + "", + "", + ].join("\n") + const body = items.map((item) => formatMessage(item)).join("---\n\n") + return `${head}${body}${items.length > 0 ? "---\n\n" : ""}` +} + +function formatMessage(item: Item): string { + const head = item.info.role === "user" ? "## User\n\n" : "## Assistant\n\n" + return `${head}${item.parts.map((part) => formatPart(part)).join("")}` +} + +function formatPart(part: Part): string { + if (part.type === "text" && !part.synthetic) return `${part.text}\n\n` + if (part.type === "tool") return `**Tool: ${part.tool}**\n\n` + return "" +} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts index 83bb074b64e..5b4876f1ad3 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts @@ -1,4 +1,4 @@ -export type FileSearchItem = { path: string; type: "file" | "folder" } +export type FileSearchItem = { path: string; type: "file" | "folder" | "opened-file" } const normalize = (p: string) => p.replaceAll("\\", "/") const trim = (p: string) => normalize(p).replace(/\/+$/, "") @@ -18,9 +18,20 @@ function rank(query: string, p: string): number { return 4 } -export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] { +export function mergeFileSearchItems(input: { + query: string + files: string[] + folders: string[] + open?: Set +}): FileSearchItem[] { const query = normalize(input.query).trim().toLowerCase() - const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const })) + const open = new Set([...(input.open ?? [])].map(normalize)) + const files = input.files.map((p) => { + const path = normalize(p) + return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const) } + }) + const pinned = files.filter((item) => item.type === "opened-file") + const rest = files.filter((item) => !open.has(item.path)) // Dedup folders against themselves; a file and a folder that share a stem are distinct entries. const seen = new Set() const folders = input.folders @@ -40,6 +51,6 @@ export function mergeFileSearchItems(input: { query: string; files: string[]; fo const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index) const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item) - const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item) - return [...boosted, ...files, ...rest] + const other = sorted.filter((x) => x.rank > 1).map((x) => x.item) + return [...pinned, ...boosted, ...rest, ...other] } diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-results.ts b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts new file mode 100644 index 00000000000..390fe8a7eae --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts @@ -0,0 +1,78 @@ +import fuzzysort from "fuzzysort" + +function base(p: string): string { + const clean = p.replace(/\/+$/, "") + return clean.split("/").pop() ?? clean +} + +function depth(p: string): number { + return p.split("/").length - 1 +} + +function score(query: string, p: string) { + const name = base(p) + const label = fuzzysort.single(query, name) + const path = fuzzysort.single(query, p) + return { + p, + label, + path, + depth: depth(p), + } +} + +function compare(a: ReturnType, b: ReturnType): number { + const alabel = a.label !== null + const blabel = b.label !== null + if (alabel !== blabel) return alabel ? -1 : 1 + + const ascore = a.label?.score ?? a.path?.score ?? 0 + const bscore = b.label?.score ?? b.path?.score ?? 0 + if (ascore !== bscore) return bscore - ascore + + const aname = base(a.p) + const bname = base(b.p) + if (aname.length !== bname.length) return aname.length - bname.length + if (a.depth !== b.depth) return a.depth - b.depth + if (a.p.length !== b.p.length) return a.p.length - b.p.length + return a.p.localeCompare(b.p) +} + +function rankOpen(query: string, paths: string[]): string[] { + if (!query || !paths.length) return paths + const scored: Array> = [] + for (const p of paths) { + const result = score(query, p) + if (result.path) scored.push(result) + } + return scored.sort(compare).map((x) => x.p) +} + +function rankBackend(query: string, paths: string[]): string[] { + if (!query || paths.length <= 1) return paths + return paths + .map((p) => score(query, p)) + .sort(compare) + .map((x) => x.p) +} + +export function mergeFileSearchResults(input: { + query: string + backend: string[] + open: Set + active?: string +}): string[] { + const norm = (p: string) => p.replaceAll("\\", "/") + const query = norm(input.query).trim().toLowerCase() + const open = new Set([...input.open].map(norm)) + const active = input.active ? norm(input.active) : undefined + const backend = input.backend.map(norm) + const matched = rankOpen(query, [...open]) + const tabs = (() => { + if (!active || !matched.includes(active)) return matched + return [active, ...matched.filter((p) => p !== active)] + })() + const seen = new Set(tabs) + const remaining = backend.filter((p) => !seen.has(p)) + return [...tabs, ...rankBackend(query, remaining)] +} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search.ts b/packages/kilo-vscode/src/kilo-provider/file-search.ts index dbac4cba7f3..712ef68ff4c 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search.ts @@ -1,7 +1,7 @@ import * as path from "path" import * as vscode from "vscode" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { mergeFileSearchResults } from "../kilo-provider-utils" +import { mergeFileSearchResults } from "./file-search-results" import { mergeFileSearchItems } from "./file-search-items" type Message = { @@ -42,7 +42,12 @@ export async function handleFileSearch(input: Input): Promise { const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined const result = mergeFileSearchResults({ query, backend: files, open, active }) - const items = mergeFileSearchItems({ query, files: result, folders }) + const items = mergeFileSearchItems({ + query, + files: result, + folders, + open: new Set(active ? [active, ...open] : open), + }) input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId }) }) } diff --git a/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts b/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts index e469d2517e4..6bbf602ca33 100644 --- a/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts +++ b/packages/kilo-vscode/src/kilo-provider/git-changes-target.ts @@ -1,5 +1,5 @@ import { GitOps } from "../agent-manager/GitOps" -import { resolveLocalDiffTarget } from "../review-utils" +import { resolveLocalDiffTarget } from "../diff/shared/target" let shared: GitOps | undefined diff --git a/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts b/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts new file mode 100644 index 00000000000..7166b9cd9a9 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/native-tab-title.ts @@ -0,0 +1,12 @@ +import type { Session } from "@kilocode/sdk/v2/client" +import { EXTENSION_DISPLAY_NAME } from "../constants" + +const DEFAULT_SESSION_TITLE = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +const TITLE_LIMIT = 19 + +export const nativeTitle = (session: Session | null) => { + const title = session?.title?.trim() + if (!title || DEFAULT_SESSION_TITLE.test(title)) return EXTENSION_DISPLAY_NAME + if (title.length <= TITLE_LIMIT) return title + return `${title.slice(0, TITLE_LIMIT)}...` +} diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts new file mode 100644 index 00000000000..8fad1c57c48 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -0,0 +1,8 @@ +export type KiloProviderOptions = { + projectDirectory?: string | null + platform?: string + slimEditMetadata?: boolean + tabTitle?: (title: string) => void + onSidebarVisibilityChange?: (visible: boolean) => void + worktreeDirectories?: () => string[] +} diff --git a/packages/kilo-vscode/src/kilo-provider/save-image.ts b/packages/kilo-vscode/src/kilo-provider/save-image.ts new file mode 100644 index 00000000000..6e24fa5380f --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/save-image.ts @@ -0,0 +1,25 @@ +import * as path from "path" +import * as vscode from "vscode" +import { parseImage } from "../image-preview" + +type ImageMessage = { + dataUrl: string + filename: string +} + +export function saveImage(dir: string, msg: ImageMessage) { + void save(dir, msg).catch((err) => console.error("[Kilo New] KiloProvider: Failed to save image:", err)) +} + +async function save(dir: string, msg: ImageMessage) { + const img = parseImage(msg.dataUrl, msg.filename) + if (!img) return undefined + + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(dir, img.name)), + filters: { Images: [img.ext] }, + saveLabel: "Save", + }) + if (!uri) return undefined + return vscode.workspace.fs.writeFile(uri, img.data) +} diff --git a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts index a478d9b13c4..dc4d54fe5b0 100644 --- a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -7,13 +7,15 @@ interface Msg { baseBranch?: string branchName?: string sessionId?: string + turnId?: string } interface Ctx { post: (msg: unknown) => void openAgentManager: () => Thenable openAdvancedWorktree: () => Thenable - openChanges: () => Thenable + openChanges: (sessionId?: string, turnId?: string) => Thenable + currentSessionId?: string createWorktree?: (baseBranch?: string, branchName?: string) => Promise continueInWorktree?: ( sessionId: string, @@ -53,7 +55,7 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { } if (message.type === "openChanges") { - await ctx.openChanges() + await ctx.openChanges(ctx.currentSessionId, message.turnId) return true } diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts index 1c5c3eafe49..b8fa13b3c0b 100644 --- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts +++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts @@ -16,6 +16,7 @@ // Max chars to keep for truncated output fields (bash metadata.output etc.) const OUTPUT_CAP = 4000 +const PATCH_CAP = 64_000 // --------------------------------------------------------------------------- // Helpers @@ -32,11 +33,22 @@ function cap(v: unknown, limit = OUTPUT_CAP): string | undefined { return v.slice(0, limit) + `\n… (truncated, ${v.length - limit} chars omitted)` } +function patch(v: unknown): string | undefined { + if (typeof v !== "string") return undefined + if (v.length > PATCH_CAP) return undefined + return v +} + +function withPatch(v: unknown): { patch: string } | {} { + const kept = patch(v) + return kept ? { patch: kept } : {} +} + // --------------------------------------------------------------------------- // Per-tool slimmers // --------------------------------------------------------------------------- -/** edit: strip filediff.before/after (webview falls back to input.oldString/newString). */ +/** edit: strip filediff.before/after while preserving bounded patches for inline diffs. */ function slimEdit(state: Record): Record { const next = { ...state } const meta = state.metadata @@ -50,6 +62,7 @@ function slimEdit(state: Record): Record { if (isObj(fd)) { result.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), + ...withPatch(fd.patch), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } @@ -59,7 +72,7 @@ function slimEdit(state: Record): Record { return next } -/** apply_patch: strip files[].before/after/diff, metadata.diff, + input.patchText. */ +/** apply_patch: strip full file contents and input patch text while preserving bounded rendered patches. */ function slimPatch(state: Record): Record { const next = { ...state } const meta = state.metadata @@ -67,14 +80,18 @@ function slimPatch(state: Record): Record { const slim: Record = {} if (meta.diagnostics) slim.diagnostics = meta.diagnostics if (Array.isArray(meta.files)) { - slim.files = (meta.files as Record[]).map((f) => ({ - filePath: f.filePath, - relativePath: f.relativePath, - type: f.type, - additions: f.additions, - deletions: f.deletions, - movePath: f.movePath, - })) + slim.files = (meta.files as Record[]).map((f) => { + const diff = patch(f.patch) ?? patch(f.diff) + return { + filePath: f.filePath, + relativePath: f.relativePath, + type: f.type, + ...withPatch(diff), + additions: f.additions, + deletions: f.deletions, + movePath: f.movePath, + } + }) } next.metadata = slim } @@ -101,6 +118,7 @@ function slimMultiedit(state: Record): Record if (isObj(fd)) { rs.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), + ...withPatch(fd.patch), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } @@ -130,6 +148,7 @@ function slimWrite(state: Record): Record { if (isObj(fd)) { slim.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), + ...withPatch(fd.patch), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index 4def9b0fda7..fb0c4beebfc 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -1,8 +1,13 @@ /** * KiloClaw panel provider for the VS Code extension. * - * Owns the Stream Chat WebSocket connection (in the extension host Node.js runtime) - * and relays messages to/from the webview via postMessage. + * Owns the Kilo Chat HTTP client + event-service WebSocket connection + * (in the extension host Node.js runtime) and relays messages to/from + * the webview via postMessage. + * + * Architecture: extension host owns both clients and reactive state; + * the webview is a stateless renderer that issues commands via + * postMessage and receives state diffs back. */ import * as vscode from "vscode" @@ -11,35 +16,79 @@ import type { KiloConnectionService } from "../services/cli-backend" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildWebviewHtml } from "../utils" import { watchFontSizeConfig } from "../kilo-provider/font-size" -import { connect, history, presence, type ClawChatClient } from "./chat-client" +import { TokenManager } from "./token-manager" +import { KiloChatApiError, KiloChatClient } from "./kilo-chat-client" +import { EventServiceClient, WebSocketAuthError } from "./event-service-client" +import { ulid } from "./ulid" import type { + ActionDeliveryFailedEvent, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationLeftEvent, + ConversationListItem, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, KiloClawInMessage, KiloClawOutMessage, KiloClawState, - ClawStatus, - ChatCredentials, - ChatMessage, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + TypingMember, + TypingEvent, } from "./types" -const MAX_MESSAGES = 500 const STATUS_POLL_MS = 10_000 +const BOT_STATUS_NUDGE_MS = 15_000 +const TYPING_TIMEOUT_MS = 5_000 +const MESSAGES_PAGE = 50 +const CONVERSATIONS_PAGE = 50 export class KiloClawProvider implements vscode.Disposable { static readonly viewType = "kilo-code.new.KiloClawPanel" private panel: vscode.WebviewPanel | null = null - private chat: ClawChatClient | null = null private timer: ReturnType | null = null + private botNudge: ReturnType | null = null private subs: Array<() => void> = [] private chatSubs: Array<() => void> = [] - private messages: ChatMessage[] = [] - private status: ClawStatus | null = null - private online = false - private connected = false private disposed = false private initializing = false private generation = 0 + // Clients (created lazily per init) + private events: EventServiceClient | null = null + private chat: KiloChatClient | null = null + private tokens: TokenManager | null = null + + // Reactive state mirrored to the webview + private status: ClawStatus | null = null + private currentUserId: string | null = null + private sandboxId: string | null = null + private conversations: ConversationListItem[] = [] + private conversationsCursor: string | null = null + private hasMoreConversations = false + private activeConversationId: string | null = null + private messages: Message[] = [] + private hasMoreMessages = false + private botStatus: BotStatusRecord | null = null + private conversationStatus: ConversationStatusRecord | null = null + private typingMembers: TypingMember[] = [] + private typingTimers = new Map>() + private subscribedSandboxContext: string | null = null + private subscribedConversationContext: string | null = null + constructor( private readonly uri: vscode.Uri, private readonly connection: KiloConnectionService, @@ -106,14 +155,18 @@ export class KiloClawProvider implements vscode.Disposable { }) this.subs.push(() => disposeSub.dispose()) - // Pause status polling when the panel is not visible to avoid unnecessary HTTP traffic + // Pause status polling + bot nudge when the panel is not visible const viewSub = panel.onDidChangeViewState(() => { - if (panel.visible) this.startPolling() - else this.stopPolling() + if (panel.visible) { + this.startPolling() + this.startBotNudge() + } else { + this.stopPolling() + this.stopBotNudge() + } }) this.subs.push(() => viewSub.dispose()) - // Subscribe to language changes broadcast by other KiloProvider instances const unsub = this.connection.onLanguageChanged((locale) => { this.post({ type: "kiloclaw.locale", locale }) }) @@ -130,17 +183,59 @@ export class KiloClawProvider implements vscode.Disposable { switch (msg.type) { case "kiloclaw.ready": await this.init() - break - case "kiloclaw.send": - await this.sendChat(msg.text) - break + return case "kiloclaw.openExternal": { const uri = vscode.Uri.parse(msg.url) if (uri.scheme === "https" || uri.scheme === "http") { void vscode.env.openExternal(uri) } - break + return } + case "kiloclaw.selectConversation": + await this.selectConversation(msg.conversationId) + return + case "kiloclaw.createConversation": + await this.createConversation(msg.title) + return + case "kiloclaw.renameConversation": + await this.renameConversation(msg.conversationId, msg.title) + return + case "kiloclaw.leaveConversation": + await this.leaveConversation(msg.conversationId) + return + case "kiloclaw.loadMoreConversations": + await this.loadMoreConversations() + return + case "kiloclaw.sendMessage": + await this.sendMessage(msg.conversationId, msg.content, msg.inReplyToMessageId) + return + case "kiloclaw.editMessage": + await this.editMessage(msg.conversationId, msg.messageId, msg.content) + return + case "kiloclaw.deleteMessage": + await this.deleteMessage(msg.conversationId, msg.messageId) + return + case "kiloclaw.loadMoreMessages": + await this.loadMoreMessages(msg.conversationId, msg.before) + return + case "kiloclaw.addReaction": + await this.addReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.removeReaction": + await this.removeReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.executeAction": + await this.executeAction(msg.conversationId, msg.messageId, msg.groupId, msg.value) + return + case "kiloclaw.sendTyping": + await this.sendTyping(msg.conversationId) + return + case "kiloclaw.sendTypingStop": + await this.sendTypingStop(msg.conversationId) + return + case "kiloclaw.markRead": + await this.markRead(msg.conversationId) + return } } @@ -153,14 +248,13 @@ export class KiloClawProvider implements vscode.Disposable { return gen !== this.generation || this.disposed } + // ── init / lifecycle ──────────────────────────────────────────────── + private async init(): Promise { if (this.initializing || this.disposed) return this.initializing = true const gen = this.generation - // Track whether we deferred to waitForConnection — if so, keep - // `initializing` true so duplicate kiloclaw.ready messages are - // harmlessly ignored until the connection arrives. let deferred = false try { @@ -174,89 +268,195 @@ export class KiloClawProvider implements vscode.Disposable { return } - const credentials = await this.fetchCreds(client, gen) - if (!credentials) return - - // Connect to Stream Chat - try { - await this.connectChat(credentials, gen) - } catch (err: unknown) { - if (this.stale(gen)) return - const msg = err instanceof Error ? err.message : String(err) - console.error("[Kilo New] KiloClaw chat connect failed:", msg) - this.post({ - type: "kiloclaw.state", - state: { - phase: "ready", - locale: this.locale, - status: this.status, - connected: false, - online: false, - messages: [], - }, - }) - this.post({ type: "kiloclaw.error", error: msg || "Failed to connect to chat" }) - this.startPolling() - return - } - + const ok = await this.bootstrap(client, gen) + if (!ok) return if (this.stale(gen)) return - // Push ready state const state: KiloClawState = { phase: "ready", locale: this.locale, status: this.status, - connected: this.connected, - online: this.online, + currentUserId: this.currentUserId ?? "", + sandboxId: this.sandboxId ?? "", + conversations: this.conversations, + hasMoreConversations: this.hasMoreConversations, + activeConversationId: this.activeConversationId, messages: this.messages, + hasMoreMessages: this.hasMoreMessages, + botStatus: this.botStatus, + conversationStatus: this.conversationStatus, + typingMembers: this.typingMembers, } this.post({ type: "kiloclaw.state", state }) this.startPolling() + this.startBotNudge() } finally { if (!deferred) this.initializing = false } } /** - * Fetch and validate instance status + chat credentials. - * Returns credentials on success, null when stale or after posting a state. - * - * Matches the TUI flow in packages/opencode/src/kilocode/kilo-commands.tsx:67,75 — - * any failure of status() (SDK error, non-2xx from the gateway, missing data, or - * missing userId) funnels to noInstance (SetupView). Any failure of - * chatCredentials() funnels to needsUpgrade (UpgradeView). The upstream Kilo API - * returns a non-2xx when no instance is provisioned, which the gateway mirrors - * and the SDK surfaces as res.error — not a thrown exception. + * Resolve instance status, fetch chat token, and wire up all clients. + * Returns `true` if everything is ready, `false` if a non-ready phase + * was already posted (loading / noInstance / needsUpgrade / error). */ - private async fetchCreds(client: KiloClient, gen: number): Promise { - const res = await client.kilo.claw.status().catch(() => null) - if (this.stale(gen)) return null + private async bootstrap(client: KiloClient, gen: number): Promise { + const ok = await this.resolveStatus(client, gen) + if (!ok) return false + if (this.stale(gen)) return false - const data = res?.data as (ClawStatus & { userId?: string }) | undefined - if (!res || (res as Record).error || !data || !data.userId) { + const envelope = await this.fetchChatToken(gen) + if (!envelope) return false + if (this.stale(gen)) return false + + if (!(await this.openChatStream(envelope, gen))) return false + if (this.stale(gen)) return false + + if (!this.sandboxId) { this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) - return null + return false } - this.status = data + await this.loadInitialSnapshots() + return true + } - const creds = await client.kilo.claw.chatCredentials().catch(() => null) - if (this.stale(gen)) return null + private async resolveStatus(client: KiloClient, gen: number): Promise { + const statusRes = await client.kilo.claw.status().catch(() => null) + if (this.stale(gen)) return false - if (!creds || (creds as Record).error || !creds.data) { + const statusData = statusRes?.data as (ClawStatus & { userId?: string }) | undefined + if (!statusRes || (statusRes as Record).error || !statusData || !statusData.userId) { + this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) + return false + } + this.status = statusData + this.currentUserId = statusData.userId + this.sandboxId = statusData.sandboxId ?? null + return true + } + + private async fetchChatToken(gen: number): Promise { + const tokens = new TokenManager(() => { + try { + return this.connection.getClient() + } catch { + return null + } + }) + try { + const envelope = await tokens.getOrFetch() + this.tokens = tokens + return envelope + } catch (err) { + if (this.stale(gen)) return null + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw chat token fetch failed:", message) + // Token fetch typically fails when the instance hasn't been upgraded + // to support kilo-chat — surface that as the upgrade prompt. this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) return null } - - return creds.data as ChatCredentials } - /** - * Ensure the CLI backend is running and return its SDK client. - * Returns `null` when the backend isn't available yet (caller should defer). - */ - private async resolveClient() { + private async openChatStream(envelope: ChatToken, gen: number): Promise { + const tokens = this.tokens! + const events = new EventServiceClient({ + url: envelope.eventServiceUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.events = events + + const chat = new KiloChatClient({ + baseUrl: envelope.kiloChatUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.chat = chat + + try { + await events.connect() + } catch (err) { + if (this.stale(gen)) return false + if (err instanceof WebSocketAuthError) { + this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) + return false + } + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw event-service connect failed:", message) + this.post({ + type: "kiloclaw.state", + state: { phase: "error", locale: this.locale, error: message || "Failed to connect to chat" }, + }) + return false + } + + this.attachEventHandlers(events, chat) + this.subscribeSandboxContext() + return true + } + + private async loadInitialSnapshots(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw listConversations failed:", message) + } + + try { + const res = await this.chat.getBotStatus(target) + if (this.sandboxId !== target) return + this.botStatus = res.status ?? null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getBotStatus failed:", message) + } + + // Auto-select the most recent conversation so the panel opens straight + // into the user's ongoing chat instead of the "select a conversation" + // empty state. Only runs on first init (or after the active one was + // explicitly cleared) — preserves the user's selection across reconnects. + if (!this.activeConversationId && this.conversations.length > 0) { + const latest = this.conversations.reduce((best, c) => { + const ax = best.lastActivityAt ?? best.joinedAt + const bx = c.lastActivityAt ?? c.joinedAt + return bx > ax ? c : best + }) + this.activeConversationId = latest.conversationId + this.subscribeConversationContext(latest.conversationId) + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(latest.conversationId) + if (this.activeConversationId === latest.conversationId) { + this.conversationStatus = res.status ?? null + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + } + + void this.markRead(latest.conversationId) + } + } + + private async resolveClient(): Promise { if (this.connection.getConnectionState() !== "connected") { try { const dir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? homedir() @@ -274,95 +474,673 @@ export class KiloClawProvider implements vscode.Disposable { } } - private async connectChat(creds: ChatCredentials, gen: number): Promise { - // Disconnect previous client to avoid duplicate websockets/listeners - this.disconnectChat() - - const client = await connect(creds) - - // If the panel was disposed or reinitialized while connect() was in flight, - // tear down the freshly-created client immediately to avoid leaked websockets. - if (this.stale(gen)) { - client.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw stale disconnect failed:", err?.message ?? err) - }) - return - } - - this.chat = client - - // Load history - const bot = `bot-${creds.channelId.replace(/^default-/, "")}` - this.messages = history(this.chat.channel, bot) - this.online = presence(this.chat.channel, bot) - this.connected = true - - // Subscribe to events and relay to webview - const unsub = this.chat.onMessage((msg) => { - // Dedupe: if a message with this id already exists, treat as update - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx !== -1) { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - return + private waitForConnection(): void { + const unsub = this.connection.onStateChange((state) => { + if (state === "connected" && !this.disposed) { + unsub() + this.initializing = false + void this.init() } - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) - } - this.post({ type: "kiloclaw.message", message: msg }) }) - this.chatSubs.push(unsub) + this.subs.push(unsub) + } - const unsubUpdated = this.chat.onMessageUpdated((msg) => { - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx === -1) { - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) + // ── Subscriptions ─────────────────────────────────────────────────── + + private subscribeSandboxContext(): void { + if (!this.events || !this.sandboxId) return + const ctx = `/kiloclaw/${this.sandboxId}` + this.events.subscribe([ctx]) + this.subscribedSandboxContext = ctx + } + + private subscribeConversationContext(conversationId: string): void { + if (!this.events || !this.sandboxId) return + if (this.subscribedConversationContext) { + this.events.unsubscribe([this.subscribedConversationContext]) + } + const ctx = `/kiloclaw/${this.sandboxId}/${conversationId}` + this.events.subscribe([ctx]) + this.subscribedConversationContext = ctx + } + + private unsubscribeConversationContext(): void { + if (!this.events || !this.subscribedConversationContext) return + this.events.unsubscribe([this.subscribedConversationContext]) + this.subscribedConversationContext = null + } + + private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void { + // Reset on reconnect — the event stream may have missed events while + // disconnected, so refetch authoritative state. + const offReconnect = events.onReconnect(() => { + void this.refreshOnReconnect() + }) + this.chatSubs.push(offReconnect) + + // ── Sandbox-scoped events ───────────────────────────────────────── + + this.chatSubs.push( + events.on("conversation.created", (ctx, e: ConversationCreatedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + // Newer servers include the full conversation snapshot — splice it in + // immediately so the list updates without a roundtrip. Fall back to a + // refetch when the snapshot is absent (older servers / safety net). + if (e.conversation) { + this.conversations = mergeConversations([e.conversation], this.conversations) + this.broadcastConversations({ replace: true }) + return } - } else { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - } - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - }) - this.chatSubs.push(unsubUpdated) + void this.refreshConversations() + }), + ) - const unsubPresence = this.chat.onPresence((val) => { - this.online = val - this.post({ type: "kiloclaw.presence", online: val }) - }) - this.chatSubs.push(unsubPresence) + this.chatSubs.push( + events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, title: e.title } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.left", (ctx, e: ConversationLeftEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.filter((c) => c.conversationId !== e.conversationId) + if (this.activeConversationId === e.conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.hasMoreMessages = false + this.conversationStatus = null + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ + type: "kiloclaw.messages", + conversationId: e.conversationId, + messages: [], + hasMore: false, + replace: true, + }) + this.post({ type: "kiloclaw.conversationStatus", status: null }) + } + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("bot.status", (ctx, e: BotStatusEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + if (e.sandboxId !== this.sandboxId) return + this.botStatus = { online: e.online, at: e.at, updatedAt: Date.now() } + this.post({ type: "kiloclaw.botStatus", status: this.botStatus }) + }), + ) + + // ── Conversation-scoped events ──────────────────────────────────── + + this.chatSubs.push( + events.on("message.created", (ctx, e: MessageCreatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + // Skip if already in cache (race with HTTP response) + if (this.messages.some((m) => m.id === e.messageId)) return + const server = this.toMessageFromCreated(e) + // Reconcile optimistic message via clientId — send the full server + // message so the webview replaces content, not just the id. Without + // this, the webview would display stale client-side content under + // the new id until the next full broadcast. + if (e.clientId) { + const pending = `pending-${e.clientId}` + const idx = this.messages.findIndex((m) => m.id === pending) + if (idx !== -1) { + this.messages = this.messages.map((m, i) => (i === idx ? server : m)) + this.post({ + type: "kiloclaw.messageReplaced", + conversationId: this.activeConversationId ?? "", + pendingId: pending, + message: server, + }) + return + } + } + this.messages = [...this.messages, server] + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.updated", (ctx, e: MessageUpdatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + const idx = this.messages.findIndex((m) => m.id === e.messageId) + if (idx === -1) return + this.messages = this.messages.map((m, i) => + i === idx ? { ...m, content: e.content, clientUpdatedAt: e.clientUpdatedAt } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.deleted", (ctx, e: MessageDeletedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.delivery_failed", (ctx, e: MessageDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deliveryFailed: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("action.delivery_failed", (ctx, e: ActionDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => { + if (m.id !== e.messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== e.groupId) return b + return { ...b, resolved: undefined } + }), + } + }) + this.broadcastMessages({ replace: true }) + this.post({ type: "kiloclaw.error", error: "Couldn't reach the bot — please try again" }) + }), + ) + + this.chatSubs.push( + events.on("reaction.added", (ctx, e: ReactionAddedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionAdded(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("reaction.removed", (ctx, e: ReactionRemovedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("typing", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (this.currentUserId && e.memberId === this.currentUserId) return + this.upsertTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("typing.stop", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.removeTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("conversation.status", (ctx, e: ConversationStatusEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (e.conversationId !== this.activeConversationId) return + this.conversationStatus = { + conversationId: e.conversationId, + contextTokens: e.contextTokens, + contextWindow: e.contextWindow, + model: e.model, + provider: e.provider, + at: e.at, + updatedAt: Date.now(), + } + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + }), + ) } - private disconnectChat(): void { - for (const unsub of this.chatSubs) unsub() - this.chatSubs = [] + private async refreshOnReconnect(): Promise { + if (!this.chat || !this.sandboxId) return + await this.refreshConversations() + if (this.activeConversationId) { + await this.refreshActiveMessages() + } + } - if (this.chat) { - this.chat.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw disconnect failed:", err?.message ?? err) - }) - this.chat = null + // ── Mutations ─────────────────────────────────────────────────────── + + private async selectConversation(conversationId: string): Promise { + if (!this.chat) return + this.activeConversationId = conversationId + this.subscribeConversationContext(conversationId) + this.post({ type: "kiloclaw.activeConversation", conversationId }) + this.typingMembers = [] + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(conversationId) + // The user may have switched conversations while we awaited the fetch; + // only apply the status if it still matches the active conversation. + if (this.activeConversationId !== conversationId) return + this.conversationStatus = res.status ?? null + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + return } - this.connected = false - this.online = false + if (this.activeConversationId !== conversationId) return + void this.markRead(conversationId) } - private async sendChat(text: string): Promise { - if (!this.chat) { - this.post({ type: "kiloclaw.error", error: "Chat not connected" }) - return + private async createConversation(title?: string): Promise { + if (!this.chat || !this.sandboxId) return + try { + // The server now returns the full conversation snapshot alongside + // `conversationId`. We rely on `refreshConversations()` to pick up the + // canonical list-item shape rather than mapping the detail payload here. + const res = await this.chat.createConversation({ sandboxId: this.sandboxId, title }) + await this.refreshConversations() + await this.selectConversation(res.conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to create conversation") }) + } + } + + private async renameConversation(conversationId: string, title: string): Promise { + if (!this.chat) return + this.conversations = this.conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c)) + this.broadcastConversations({ replace: true }) + try { + await this.chat.renameConversation(conversationId, title) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to rename conversation") }) + void this.refreshConversations() + } + } + + private async leaveConversation(conversationId: string): Promise { + if (!this.chat) return + try { + await this.chat.leaveConversation(conversationId) + // Optimistic removal — server will also fire conversation.left. + this.conversations = this.conversations.filter((c) => c.conversationId !== conversationId) + if (this.activeConversationId === conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ type: "kiloclaw.messages", conversationId, messages: [], hasMore: false, replace: true }) + } + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to leave conversation") }) + } + } + + private async loadMoreConversations(): Promise { + if (!this.chat || !this.sandboxId || !this.hasMoreConversations || !this.conversationsCursor) return + const target = this.sandboxId + const cursor = this.conversationsCursor + try { + const res = await this.chat.listConversations({ + sandboxId: target, + limit: CONVERSATIONS_PAGE, + cursor, + }) + // Sandbox could have changed (reauth / cleanup). Also a newer refresh + // may have already moved the cursor — skip merging stale results. + if (this.sandboxId !== target || this.conversationsCursor !== cursor) return + this.conversations = mergeConversations(this.conversations, res.conversations) + this.conversationsCursor = res.nextCursor + this.hasMoreConversations = res.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load conversations") }) + } + } + + private async sendMessage( + conversationId: string, + content: ContentBlock[], + inReplyToMessageId?: string, + ): Promise { + if (!this.chat) return + if (!this.currentUserId) return + + // kilo-chat validates clientId as a ULID (Crockford Base32); generate + // it here so the webview doesn't need to know the format. + const clientId = ulid() + const pendingId = `pending-${clientId}` + const optimistic: Message = { + id: pendingId, + senderId: this.currentUserId, + content, + inReplyToMessageId: inReplyToMessageId ?? null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + + if (conversationId === this.activeConversationId) { + this.messages = [...this.messages, optimistic] + this.post({ type: "kiloclaw.messageOptimistic", conversationId, message: optimistic }) + } + + try { + await this.chat.sendMessage({ conversationId, content, clientId, inReplyToMessageId }) + // Server will fire `message.created` — reconciliation happens there. + } catch (err) { + console.error("[Kilo New] KiloClaw sendMessage failed:", err instanceof Error ? err.message : err) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to send message") }) + if (conversationId === this.activeConversationId) { + this.messages = this.messages.filter((m) => m.id !== pendingId) + this.post({ type: "kiloclaw.messageRemoved", conversationId, messageId: pendingId }) + } + } + } + + private async editMessage(conversationId: string, messageId: string, content: ContentBlock[]): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, content, clientUpdatedAt: Date.now() } : m, + ) + this.broadcastMessages({ replace: true }) } try { - await this.chat.send(text) + await this.chat.editMessage(messageId, { conversationId, content, timestamp: Date.now() }) } catch (err) { - console.error("[Kilo New] KiloClaw send failed:", err instanceof Error ? err.message : err) - this.post({ type: "kiloclaw.error", error: "Failed to send message" }) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to edit message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } } } + private async deleteMessage(conversationId: string, messageId: string): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.deleteMessage(messageId, conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to delete message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async loadMoreMessages(conversationId: string, before: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + const res = await this.chat.listMessages(conversationId, { before, limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only merge if the active conversation is still the same. + if (this.activeConversationId !== conversationId) return + const sorted = sortMessagesAscending(res.messages) + this.messages = mergeMessages(sorted, this.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load messages") }) + } + } + + private async addReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionAdded(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.addReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to add reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async removeReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.removeReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to remove reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async executeAction( + conversationId: string, + messageId: string, + groupId: string, + value: ExecApprovalDecision, + ): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => { + if (m.id !== messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== groupId) return b + return { ...b, resolved: { value, resolvedBy: this.currentUserId!, resolvedAt: Date.now() } } + }), + } + }) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.executeAction(conversationId, messageId, { groupId, value }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to execute action") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async sendTyping(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTyping(conversationId) + } catch (err) { + // Typing is fire-and-forget; don't surface errors. + void err + } + } + + private async sendTypingStop(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTypingStop(conversationId) + } catch (err) { + void err + } + } + + private async markRead(conversationId: string): Promise { + if (!this.chat) return + if (conversationId !== this.activeConversationId) return + // The mark-read endpoint requires `lastSeenMessageId`. With no messages + // loaded there is nothing to mark — silently skip. + const last = lastNonPendingMessageId(this.messages) + if (!last) return + try { + await this.chat.markConversationRead(conversationId, { lastSeenMessageId: last }) + } catch (err) { + void err + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private async refreshConversations(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + // Defensive: sandbox could theoretically change during the fetch + // (cleanup or reauth). Skip the write if so. + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshConversations failed:", message) + } + } + + private async refreshActiveMessages(): Promise { + if (!this.chat) return + const target = this.activeConversationId + if (!target) return + try { + const res = await this.chat.listMessages(target, { limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only apply the messages if the active conversation is still `target`. + if (this.activeConversationId !== target) return + this.messages = sortMessagesAscending(res.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshActiveMessages failed:", message) + } + } + + private upsertTypingMember(memberId: string): void { + const now = Date.now() + const idx = this.typingMembers.findIndex((m) => m.memberId === memberId) + if (idx === -1) { + this.typingMembers = [...this.typingMembers, { memberId, at: now }] + } else { + this.typingMembers = this.typingMembers.map((m, i) => (i === idx ? { ...m, at: now } : m)) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typing", conversationId: this.activeConversationId, memberId }) + } + const existing = this.typingTimers.get(memberId) + if (existing) clearTimeout(existing) + this.typingTimers.set( + memberId, + setTimeout(() => this.removeTypingMember(memberId), TYPING_TIMEOUT_MS), + ) + } + + private removeTypingMember(memberId: string): void { + this.typingMembers = this.typingMembers.filter((m) => m.memberId !== memberId) + const t = this.typingTimers.get(memberId) + if (t) { + clearTimeout(t) + this.typingTimers.delete(memberId) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typingStop", conversationId: this.activeConversationId, memberId }) + } + } + + private toMessageFromCreated(e: MessageCreatedEvent): Message { + return { + id: e.messageId, + senderId: e.senderId, + content: e.content, + inReplyToMessageId: e.inReplyToMessageId, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + } + + private broadcastConversations(opts: { replace: boolean }): void { + this.post({ + type: "kiloclaw.conversations", + conversations: this.conversations, + hasMore: this.hasMoreConversations, + replace: opts.replace, + }) + } + + private broadcastMessages(opts: { replace: boolean }): void { + if (!this.activeConversationId) return + this.post({ + type: "kiloclaw.messages", + conversationId: this.activeConversationId, + messages: this.messages, + hasMore: this.hasMoreMessages, + replace: opts.replace, + }) + } + + private formatError(err: unknown, fallback: string): string { + if (err instanceof KiloChatApiError) { + const body = err.body as Record | null + if (body && typeof body.error === "string") return body.error + } + if (err instanceof Error) return err.message || fallback + return fallback + } + + // ── Polling / nudges / cleanup ────────────────────────────────────── + private startPolling(): void { if (this.timer) return this.timer = setInterval(() => void this.poll(), STATUS_POLL_MS) @@ -374,6 +1152,22 @@ export class KiloClawProvider implements vscode.Disposable { this.timer = null } + private startBotNudge(): void { + if (this.botNudge) return + this.botNudge = setInterval(() => { + if (!this.chat || !this.sandboxId) return + this.chat.requestBotStatus(this.sandboxId).catch((err) => { + console.debug("[Kilo New] KiloClaw requestBotStatus failed:", (err as Error)?.message ?? err) + }) + }, BOT_STATUS_NUDGE_MS) + } + + private stopBotNudge(): void { + if (!this.botNudge) return + clearInterval(this.botNudge) + this.botNudge = null + } + private async poll(): Promise { try { const client = this.connection.getClient() @@ -387,33 +1181,115 @@ export class KiloClawProvider implements vscode.Disposable { } } - /** Subscribe to connection state changes and re-run init() once connected. */ - private waitForConnection(): void { - const unsub = this.connection.onStateChange((state) => { - if (state === "connected" && !this.disposed) { - unsub() - this.initializing = false - void this.init() - } - }) - this.subs.push(unsub) - } - private cleanup(): void { this.generation++ for (const unsub of this.subs) unsub() this.subs = [] + for (const unsub of this.chatSubs) unsub() + this.chatSubs = [] - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } + this.stopPolling() + this.stopBotNudge() - this.disconnectChat() + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + this.events?.disconnect() + this.events = null + this.chat = null + this.tokens?.clear() + this.tokens = null + + this.subscribedSandboxContext = null + this.subscribedConversationContext = null this.messages = [] + this.conversations = [] + this.conversationsCursor = null + this.hasMoreConversations = false + this.activeConversationId = null + this.hasMoreMessages = false + this.botStatus = null + this.conversationStatus = null + this.typingMembers = [] this.initializing = false this.status = null + this.currentUserId = null + this.sandboxId = null } } + +// ── Pure helpers ────────────────────────────────────────────────────── + +function applyReactionAdded( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + const existing = reactions.find((r) => r.emoji === emoji) + if (existing) { + if (existing.memberIds.includes(memberId)) return reactions + return reactions.map((r) => + r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r, + ) + } + return [...reactions, { emoji, count: 1, memberIds: [memberId] }] +} + +function applyReactionRemoved( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + return reactions + .map((r) => { + if (r.emoji !== emoji) return r + const memberIds = r.memberIds.filter((id) => id !== memberId) + return { ...r, count: memberIds.length, memberIds } + }) + .filter((r) => r.count > 0) +} + +/** Merge two ascending-sorted message arrays by id, keeping the most recent updates. */ +function mergeMessages(older: Message[], newer: Message[]): Message[] { + const seen = new Map() + for (const m of older) seen.set(m.id, m) + for (const m of newer) seen.set(m.id, m) + return [...seen.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +function mergeConversations( + existing: ConversationListItem[], + incoming: ConversationListItem[], +): ConversationListItem[] { + const seen = new Map() + for (const c of existing) seen.set(c.conversationId, c) + for (const c of incoming) seen.set(c.conversationId, c) + return [...seen.values()].sort((a, b) => { + const ax = a.lastActivityAt ?? a.joinedAt + const bx = b.lastActivityAt ?? b.joinedAt + return bx - ax + }) +} + +/** listMessages returns newest-first; the UI renders oldest-first. */ +function sortMessagesAscending(messages: Message[]): Message[] { + return [...messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +/** + * Pick the latest server-confirmed message id from an ascending list. Pending + * (optimistic) messages use `pending-` ids that the server doesn't + * recognise, so they're skipped — the new mark-read contract requires a real + * message id. + */ +function lastNonPendingMessageId(messages: Message[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (!m) continue + if (m.id.startsWith("pending-")) continue + return m.id + } + return null +} diff --git a/packages/kilo-vscode/src/kiloclaw/chat-client.ts b/packages/kilo-vscode/src/kiloclaw/chat-client.ts deleted file mode 100644 index b1f15e4108a..00000000000 --- a/packages/kilo-vscode/src/kiloclaw/chat-client.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * KiloClaw Stream Chat client wrapper for the VS Code extension host. - * - * Port of packages/opencode/src/kilocode/claw/client.ts adapted for Node.js. - * No Bun patches needed — the extension host runs in standard Node.js. - * stream-chat resolves to its Node.js CJS entry point automatically. - */ - -import type { Channel, Event } from "stream-chat" -import type { ChatCredentials, ChatMessage } from "./types" - -export type ClawChatClient = { - channel: Channel - disconnect: () => Promise - send: (text: string) => Promise - onMessage: (cb: (msg: ChatMessage) => void) => () => void - onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void - onPresence: (cb: (online: boolean) => void) => () => void -} - -function botId(creds: ChatCredentials): string { - return `bot-${creds.channelId.replace(/^default-/, "")}` -} - -function toMessage(raw: Record, bot: string): ChatMessage { - const user = raw.user as Record | undefined - const uid = (user?.id as string) ?? (raw.user_id as string) ?? "" - return { - id: (raw.id as string) ?? "", - text: (raw.text as string) ?? "", - user: uid, - created: raw.created_at ? new Date(raw.created_at as string).toISOString() : new Date().toISOString(), - bot: uid === bot, - } -} - -export async function connect(creds: ChatCredentials): Promise { - const { StreamChat } = await import("stream-chat") - // Use a fresh instance instead of the singleton to avoid stale state - // (cached channels, event listeners) when credentials rotate. - const client = new StreamChat(creds.apiKey) - - await client.connectUser({ id: creds.userId }, creds.userToken) - - const channel = client.channel("messaging", creds.channelId) - try { - await channel.watch({ presence: true }) - } catch (err) { - // Disconnect the user to avoid leaking a partial connection - await client.disconnectUser().catch(() => {}) - throw err - } - - const bot = botId(creds) - - return { - channel, - async disconnect() { - await client.disconnectUser() - }, - async send(text: string) { - await channel.sendMessage({ text }) - }, - onMessage(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.new", handler) - return () => channel.off("message.new", handler) - }, - onMessageUpdated(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.updated", handler) - return () => channel.off("message.updated", handler) - }, - onPresence(cb) { - const handler = (event: Event) => { - if (event.user?.id === bot) { - cb(event.user.online ?? false) - } - } - client.on("user.presence.changed", handler) - return () => client.off("user.presence.changed", handler) - }, - } -} - -export function history(channel: Channel, bot: string): ChatMessage[] { - const state = channel.state.messages - return state.map((raw) => toMessage(raw as unknown as Record, bot)) -} - -export function presence(channel: Channel, bot: string): boolean { - const member = channel.state.members?.[bot] - return !!member?.user?.online -} diff --git a/packages/kilo-vscode/src/kiloclaw/event-service-client.ts b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts new file mode 100644 index 00000000000..fea08acbc43 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts @@ -0,0 +1,384 @@ +/** + * Event Service WebSocket client for the VS Code extension host. + * + * Minimal inline port of `@kilocode/event-service` (cloud monorepo). Connects + * to the kilo events Cloudflare Worker using a two-step ticket flow: + * 1. POST `/connect-ticket` with `Authorization: Bearer ` to mint a + * single-use ticket (30 s TTL). + * 2. Open WebSocket to `/connect?ticket=` with subprotocol + * `kilo.events.v1`. + * + * Runs in Node.js (the VS Code extension host). `WebSocket` is available in + * Node 22+ without any import, matching the environment used elsewhere in + * this extension (see `src/services/cli-backend/sdk-sse-adapter.ts`). + */ + +import type { KiloChatEventMap, KiloChatEventName } from "./types" + +const WS_SUBPROTOCOL = "kilo.events.v1" +const HANDSHAKE_TIMEOUT_MS = 10_000 +const PING_INTERVAL_MS = 15_000 +const TICKET_FETCH_TIMEOUT_MS = 10_000 + +export class WebSocketAuthError extends Error { + constructor(message = "WebSocket authentication failed") { + super(message) + this.name = "WebSocketAuthError" + } +} + +export class WebSocketConnectError extends Error { + constructor( + message: string, + public readonly code: number, + ) { + super(message) + this.name = "WebSocketConnectError" + } +} + +export class HandshakeTimeoutError extends Error { + constructor() { + super("WebSocket handshake timed out") + this.name = "HandshakeTimeoutError" + } +} + +// Close codes that signal the server rejected us for auth/policy reasons +// and reconnecting with the same token is pointless. Everything else +// (including 1006 "abnormal closure" from flaky networks) is transient. +function isAuthCloseCode(code: number): boolean { + if (code === 1008) return true // Policy Violation + if (code === 4401 || code === 4403) return true // Custom auth rejection + return false +} + +export type EventHandler = (context: string, payload: unknown) => void + +export type EventServiceConfig = { + url: string + getToken: () => Promise + onUnauthorized?: () => void +} + +/** + * The event-service base URL is configured as a WebSocket URL (`wss://…` / + * `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip + * the trailing slash and swap the protocol so `fetch()` accepts the URL. + */ +function toHttpBase(wsBase: string): string { + const trimmed = wsBase.replace(/\/$/, "") + if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) + if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) + return trimmed +} + +export class EventServiceClient { + private readonly url: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + + private ws: WebSocket | null = null + private connected = false + private destroyed = false + private reconnectAttempts = 0 + private hasConnectedBefore = false + private reconnectTimer: ReturnType | null = null + private pingTimer: ReturnType | null = null + private handshakeTimer: ReturnType | null = null + private abortHandshake: ((err: Error) => void) | null = null + + private eventHandlers = new Map>() + private activeContexts = new Set() + private reconnectHandlers = new Set<() => void>() + + constructor(config: EventServiceConfig) { + this.url = config.url + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + async connect(): Promise { + this.destroyed = false + this.reconnectAttempts = 0 + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + try { + await this.connectOnce() + } catch (err) { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + } + } + + disconnect(): void { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.clearHandshakeTimer() + if (this.abortHandshake) { + this.abortHandshake(new Error("disconnected")) + } + if (this.ws) { + this.ws.close() + this.ws = null + } + this.stopPing() + this.connected = false + } + + isConnected(): boolean { + return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + subscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.add(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.subscribe", contexts }) + } + } + + unsubscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.delete(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.unsubscribe", contexts }) + } + } + + on(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void { + const set = this.eventHandlers.get(event) ?? new Set() + // The raw dispatcher receives `unknown` payloads; the caller supplied a + // typed handler. We trust server payloads here — they're validated at the + // kilo-chat worker edge before broadcast. + const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N]) + set.add(wrapped) + this.eventHandlers.set(event, set) + return () => { + set.delete(wrapped) + if (set.size === 0) this.eventHandlers.delete(event) + } + } + + onReconnect(handler: () => void): () => void { + this.reconnectHandlers.add(handler) + return () => this.reconnectHandlers.delete(handler) + } + + // ── private ──────────────────────────────────────────────────────── + + private handleAuthFailure(err: unknown): boolean { + if (err instanceof WebSocketAuthError) { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.onUnauthorized?.() + return true + } + return false + } + + private async connectOnce(): Promise { + if (this.ws) { + const old = this.ws + this.ws = null + old.close() + } + + const token = await this.getToken() + const ticket = await this.fetchTicket(token) + + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) + this.ws = ws + + let settled = false + const settleResolve = () => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + resolve() + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + reject(err) + } + this.abortHandshake = settleReject + + this.handshakeTimer = setTimeout(() => { + this.handshakeTimer = null + if (this.ws === ws) ws.close(1000, "handshake-timeout") + settleReject(new HandshakeTimeoutError()) + }, HANDSHAKE_TIMEOUT_MS) + + ws.addEventListener("open", () => { + const isReconnect = this.hasConnectedBefore + this.connected = true + this.hasConnectedBefore = true + this.reconnectAttempts = 0 + this.resubscribeContexts() + if (isReconnect) { + for (const h of this.reconnectHandlers) h() + } + settleResolve() + this.startPing() + }) + + ws.addEventListener("message", (event: MessageEvent) => { + this.handleMessage(String(event.data)) + }) + + ws.addEventListener("close", (event: CloseEvent) => { + if (this.ws !== ws) return + const wasConnected = this.connected + this.connected = false + this.stopPing() + this.clearHandshakeTimer() + // A handshake failure always fires `close` after `error`, so we + // settle here with a classification based on the close code: + // explicit auth/policy codes → fatal; anything else → transient + // and the caller (`connect`) will schedule a reconnect. + if (!wasConnected) { + if (isAuthCloseCode(event.code)) { + settleReject(new WebSocketAuthError()) + } else { + settleReject( + new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), + ) + } + return + } + if (!this.destroyed) this.scheduleReconnect() + }) + + ws.addEventListener("error", () => { + // Swallowed: the `close` event fires right after and carries the + // close code we need to distinguish auth failures from network + // blips. Settling here loses that context. + }) + }) + } + + /** + * Mint a single-use connection ticket. The event-service issues a 30 s ticket + * scoped to the bearer JWT; the WebSocket upgrade then consumes it. We + * surface 401/403 as `WebSocketAuthError` so the caller can drop the cached + * token and prompt re-auth. + * + * `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only + * accepts `http(s)`, so we rewrite the protocol before the HTTP call. + */ + private async fetchTicket(token: string): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) + try { + const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + signal: ctrl.signal, + }) + if (res.status === 401 || res.status === 403) { + throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) + } + if (!res.ok) { + throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) + } + const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null + if (!body || typeof body.ticket !== "string" || !body.ticket) { + throw new WebSocketConnectError("Malformed event-service ticket response", 0) + } + return body.ticket + } catch (err) { + if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err + if ((err as { name?: string })?.name === "AbortError") { + throw new HandshakeTimeoutError() + } + throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) + } finally { + clearTimeout(timer) + } + } + + private clearHandshakeTimer(): void { + if (this.handshakeTimer !== null) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + private sendJson(msg: unknown): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)) + } + } + + private handleMessage(data: string): void { + if (data === "pong") return + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return + } + if (!parsed || typeof parsed !== "object") return + const m = parsed as Record + if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { + const handlers = this.eventHandlers.get(m.event) + if (handlers) { + for (const h of handlers) h(m.context, m.payload) + } + return + } + if (m.type === "error") { + console.warn("[Kilo New] event-service server error", m) + } + } + + private startPing(): void { + this.stopPing() + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send("ping") + } + }, PING_INTERVAL_MS) + } + + private stopPing(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + } + + private resubscribeContexts(): void { + if (this.activeContexts.size > 0) { + this.sendJson({ + type: "context.subscribe", + contexts: Array.from(this.activeContexts), + }) + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) + const delay = base * (0.5 + Math.random() * 0.5) + this.reconnectAttempts++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connectOnce().catch((err) => { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + }) + }, delay) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts new file mode 100644 index 00000000000..5583b32af51 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -0,0 +1,270 @@ +/** + * HTTP client for the kilo-chat Cloudflare Worker. + * + * Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo) tailored + * to what the VS Code extension needs: conversation list + details, message + * CRUD, reactions, typing, and action execution. No zod runtime validation — + * the kilo-chat worker is the source of truth and validates at its edge. + */ + +import type { + BotStatusRecord, + ContentBlock, + ConversationDetail, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, +} from "./types" + +export type KiloChatClientConfig = { + baseUrl: string + getToken: () => Promise + onUnauthorized?: () => void +} + +export class KiloChatApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) + this.name = "KiloChatApiError" + } +} + +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + // Fall back to a compact JSON dump so validation errors (zod issues, etc.) + // show up in the extension's Output channel without a separate logging hop. + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + +type HttpOpts = { + method?: string + body?: unknown + query?: Record +} + +// Per-conversation send queues. sendMessage chains onto the tail of its +// conversation's queue so concurrent callers can't race ahead and get a lower +// server-assigned ULID than a later send. +type SendQueue = Map> + +export class KiloChatClient { + private readonly baseUrl: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + private readonly sendQueues: SendQueue = new Map() + + constructor(config: KiloChatClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, "") + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + // ── Conversations ──────────────────────────────────────────────── + + listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ + conversations: ConversationListItem[] + hasMore: boolean + nextCursor: string | null + }> { + return this.request("/v1/conversations", { + query: { + sandboxId: opts?.sandboxId, + limit: opts?.limit, + cursor: opts?.cursor ?? undefined, + }, + }) + } + + getConversation(conversationId: string): Promise { + return this.request(`/v1/conversations/${conversationId}`) + } + + createConversation(req: { + sandboxId: string + title?: string + }): Promise<{ conversationId: string; conversation?: ConversationDetail }> { + return this.request("/v1/conversations", { method: "POST", body: req }) + } + + renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { + return this.request(`/v1/conversations/${conversationId}`, { + method: "PATCH", + body: { title }, + }) + } + + async leaveConversation(conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) + } + + /** + * Mark messages up to `lastSeenMessageId` as read for the current user. + * The server enforces monotonic `lastReadAt` and returns whether the read + * pointer advanced plus whether the badge bucket was cleared. + */ + markConversationRead( + conversationId: string, + req: { lastSeenMessageId: string }, + ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { + return this.request(`/v1/conversations/${conversationId}/mark-read`, { + method: "POST", + body: req, + }) + } + + // ── Messages ───────────────────────────────────────────────────── + + sendMessage(req: { + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + clientId?: string + }): Promise<{ messageId: string; clientId?: string; message?: Message }> { + const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() + const send = () => + this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { + method: "POST", + body: req, + }) + const next = prev.then(send, send) + this.sendQueues.set(req.conversationId, next) + const cleanup = () => { + if (this.sendQueues.get(req.conversationId) === next) { + this.sendQueues.delete(req.conversationId) + } + } + void next.then(cleanup, cleanup) + return next + } + + editMessage( + messageId: string, + req: { conversationId: string; content: ContentBlock[]; timestamp: number }, + ): Promise<{ messageId?: string; message?: Message }> { + return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) + } + + async deleteMessage(messageId: string, conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/messages/${messageId}`, { + method: "DELETE", + query: { conversationId }, + }) + } + + listMessages( + conversationId: string, + opts?: { before?: string; limit?: number }, + ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { + return this.request(`/v1/conversations/${conversationId}/messages`, { + query: { before: opts?.before, limit: opts?.limit }, + }) + } + + executeAction( + conversationId: string, + messageId: string, + req: { groupId: string; value: ExecApprovalDecision }, + ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { + return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { + method: "POST", + body: req, + }) + } + + // ── Reactions ──────────────────────────────────────────────────── + + addReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ id: string; operationId?: string }> { + return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) + } + + async removeReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { + return this.request<{ removed: boolean; id: string | null; operationId?: string }>( + `/v1/messages/${messageId}/reactions`, + { + method: "DELETE", + query: req, + }, + ) + } + + // ── Typing ─────────────────────────────────────────────────────── + + async sendTyping(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) + } + + async sendTypingStop(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) + } + + // ── Bot / conversation status ──────────────────────────────────── + + getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { + return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) + } + + async requestBotStatus(sandboxId: string): Promise { + await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) + } + + getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { + return this.request(`/v1/conversations/${conversationId}/conversation-status`) + } + + // ── private ────────────────────────────────────────────────────── + + private async request(path: string, opts: HttpOpts = {}): Promise { + const token = await this.getToken() + let url = `${this.baseUrl}${path}` + + if (opts.query) { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue + params.set(k, String(v)) + } + const qs = params.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { Authorization: `Bearer ${token}` } + if (opts.body !== undefined) headers["Content-Type"] = "application/json" + + const res = await fetch(url, { + method: opts.method ?? "GET", + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }) + + if (!res.ok) { + if (res.status === 401 || res.status === 403) this.onUnauthorized?.() + const body: unknown = await res.json().catch(() => null) + throw new KiloChatApiError(res.status, body) + } + + if (res.status === 204) return undefined as unknown as T + return (await res.json()) as T + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/token-manager.ts b/packages/kilo-vscode/src/kiloclaw/token-manager.ts new file mode 100644 index 00000000000..745b9c7c3f8 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/token-manager.ts @@ -0,0 +1,112 @@ +/** + * KiloChat access-token cache. + * + * Mirrors the web client pattern (see `apps/web/src/app/(app)/claw/kilo-chat/token.ts` + * in the cloud monorepo). The token is minted by the Kilo gateway + * (`kilo.claw.chatCredentials`) and kept in memory with a 5-minute freshness + * buffer. The gateway operation is a historical name — it used to return + * Stream Chat credentials and was repurposed when Kilo migrated to its own + * kilo-chat service. + * + * Concurrent callers share the same inflight fetch so we never double-issue. + * A short retry cooldown prevents tight loops when the gateway is flaky. + */ + +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { ChatToken } from "./types" + +const FRESHNESS_BUFFER_MS = 5 * 60 * 1000 +const RETRY_BACKOFF_MS = 5_000 + +export class TokenManager { + private cached: ChatToken | null = null + private expiresAtMs = 0 + private inflight: Promise | null = null + private lastFailedAt = 0 + + 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 + this.expiresAtMs = 0 + this.lastFailedAt = 0 + this.inflight = null + } + + async get(): Promise { + const info = await this.getOrFetch() + return info.token + } + + /** Resolve the full token envelope (URLs + token + expiry). */ + async getOrFetch(): Promise { + if (this.cached && Date.now() < this.expiresAtMs - FRESHNESS_BUFFER_MS) { + return this.cached + } + if (this.lastFailedAt && Date.now() - this.lastFailedAt < RETRY_BACKOFF_MS) { + throw new Error("Kilo chat token fetch on cooldown after recent failure") + } + if (!this.inflight) { + this.inflight = this.fetch() + .then((info) => { + this.cached = info + this.expiresAtMs = new Date(info.expiresAt).getTime() + this.lastFailedAt = 0 + this.inflight = null + return info + }) + .catch((err) => { + this.lastFailedAt = Date.now() + this.inflight = null + throw err + }) + } + return this.inflight + } + + private async fetch(): Promise { + const client = this.getClient() + if (!client) throw new Error("Kilo backend not connected") + const res = await client.kilo.claw.chatCredentials() + const errResponse = (res as Record | null)?.error + if (!res || errResponse || !res.data) { + // Propagate the server's error detail when present so the extension's + // Output channel makes it obvious whether this is an auth problem, + // "no active instance" (404), or a transient 5xx. + const detail = this.formatErrorDetail(errResponse) + throw new Error(`kilo-chat credentials fetch failed${detail ? `: ${detail}` : ""}`) + } + const data = res.data as Partial + const missing: string[] = [] + if (!data.token) missing.push("token") + if (!data.expiresAt) missing.push("expiresAt") + if (!data.kiloChatUrl) missing.push("kiloChatUrl") + if (!data.eventServiceUrl) missing.push("eventServiceUrl") + if (missing.length > 0) { + throw new Error( + `Malformed kilo-chat credentials response: missing ${missing.join(", ")} (received keys: ${Object.keys(data).join(", ") || ""})`, + ) + } + return { + token: data.token!, + expiresAt: data.expiresAt!, + kiloChatUrl: data.kiloChatUrl!, + eventServiceUrl: data.eventServiceUrl!, + } + } + + private formatErrorDetail(err: unknown): string { + if (!err) return "" + if (typeof err === "string") return err + if (typeof err === "object" && err && "error" in err && typeof err.error === "string") { + return err.error + } + return JSON.stringify(err) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index 51f4e28631f..20b796a67d4 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -3,14 +3,29 @@ * * Defines the postMessage protocol between the extension host (Node.js) * and the KiloClaw webview (SolidJS). The extension host owns all network - * connections (SDK + Stream Chat) and relays data to the webview. + * connections (Kilo Chat HTTP + event-service WebSocket) and relays data + * to the webview. * - * SYNC: Shared types (ClawStatus, ChatMessage, KiloClawState, KiloClawOutMessage) - * are mirrored in webview-ui/kiloclaw/lib/types.ts — keep both in sync. + * SYNC: Shared types are mirrored in webview-ui/kiloclaw/lib/types.ts — + * keep both in sync. */ +// ── Instance status (KiloClaw worker) ─────────────────────────────── + export type ClawStatus = { - status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null + // `recovering` and `restoring` are transitional states the worker reports + // while bringing an instance back from an unexpected stop or a snapshot + // restore (cloud: `services/kiloclaw/src/index.ts`). + status: + | "provisioned" + | "starting" + | "restarting" + | "recovering" + | "running" + | "stopped" + | "destroying" + | "restoring" + | null sandboxId?: string flyRegion?: string machineSize?: { cpus: number; memory_mb: number } @@ -19,23 +34,207 @@ export type ClawStatus = { lastStoppedAt?: string | null channelCount?: number secretCount?: number + userId?: string + botName?: string | null } -export type ChatCredentials = { - apiKey: string - userId: string - userToken: string - channelId: string +// ── Kilo Chat token envelope (gateway response) ───────────────────── + +export type ChatToken = { + token: string + expiresAt: string // ISO timestamp + kiloChatUrl: string + eventServiceUrl: string } -export type ChatMessage = { +// ── Kilo Chat content blocks ──────────────────────────────────────── +// Mirrors `@kilocode/kilo-chat` schemas. See cloud/packages/kilo-chat/src/schemas.ts. + +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" + +export type TextBlock = { type: "text"; text: string } + +export type ActionItem = { + label: string + style: "primary" | "danger" | "secondary" + value: ExecApprovalDecision +} + +export type ActionsBlock = { + type: "actions" + groupId: string + actions: ActionItem[] + resolved?: { + value: ExecApprovalDecision + resolvedBy: string + resolvedAt: number + } +} + +export type ContentBlock = TextBlock | ActionsBlock + +// ── Kilo Chat reactions ───────────────────────────────────────────── + +export type ReactionSummary = { + emoji: string + count: number + memberIds: string[] +} + +// ── Kilo Chat message ─────────────────────────────────────────────── + +export type Message = { id: string - text: string - user: string - created: string // ISO string (serializable via postMessage) - bot: boolean + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + updatedAt: number | null + clientUpdatedAt: number | null + deleted: boolean + deliveryFailed: boolean + reactions: ReactionSummary[] } +// ── Conversations ─────────────────────────────────────────────────── + +export type ConversationListItem = { + conversationId: string + title: string | null + lastActivityAt: number | null + lastReadAt: number | null + joinedAt: number +} + +export type ConversationMember = { id: string; kind: "user" | "bot" } + +export type ConversationDetail = { + id: string + title: string | null + createdBy: string + createdAt: number + members: ConversationMember[] +} + +// ── Bot / conversation status (telemetry) ─────────────────────────── + +export type BotStatusRecord = { + online: boolean + at: number + updatedAt: number +} + +export type ConversationStatusRecord = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number + updatedAt: number +} + +// ── Typed Kilo Chat events (server → client) ─────────────────────── +// Event names mirror `@kilocode/kilo-chat/events`. + +/** + * Snapshot of the message that was replied to. Server includes this on + * `message.created` so clients can render a reply preview without a follow-up + * fetch. `deleted` mirrors the soft-deletion state at the time of replying. + */ +export type ReplyToSnapshot = { + messageId: string + senderId: string + content: ContentBlock[] + deleted?: boolean +} + +export type MessageCreatedEvent = { + messageId: string + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + clientId?: string + replyTo?: ReplyToSnapshot | null +} + +export type MessageUpdatedEvent = { + messageId: string + content: ContentBlock[] + clientUpdatedAt: number | null +} + +export type MessageDeletedEvent = { messageId: string } +export type MessageDeliveryFailedEvent = { messageId: string } + +export type TypingEvent = { memberId: string } +export type TypingStopEvent = { memberId: string } + +export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } +export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } + +/** + * Server fans out the full conversation snapshot on `conversation.created` so + * clients can append to their list without a follow-up fetch. Older servers may + * still send only the `conversationId`, so the snapshot is optional. + */ +export type ConversationCreatedEvent = { + conversationId: string + conversation?: ConversationListItem +} +export type ConversationRenamedEvent = { conversationId: string; title: string } +export type ConversationLeftEvent = { conversationId: string } +export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number } +export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } + +export type ActionExecutedEvent = { + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + executedBy: string +} +export type ActionDeliveryFailedEvent = { + conversationId: string + messageId: string + groupId: string +} + +export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } +export type ConversationStatusEvent = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number +} + +export type KiloChatEventMap = { + "message.created": MessageCreatedEvent + "message.updated": MessageUpdatedEvent + "message.deleted": MessageDeletedEvent + "message.delivery_failed": MessageDeliveryFailedEvent + typing: TypingEvent + "typing.stop": TypingStopEvent + "reaction.added": ReactionAddedEvent + "reaction.removed": ReactionRemovedEvent + "conversation.created": ConversationCreatedEvent + "conversation.renamed": ConversationRenamedEvent + "conversation.left": ConversationLeftEvent + "conversation.read": ConversationReadEvent + "conversation.activity": ConversationActivityEvent + "action.executed": ActionExecutedEvent + "action.delivery_failed": ActionDeliveryFailedEvent + "bot.status": BotStatusEvent + "conversation.status": ConversationStatusEvent +} + +export type KiloChatEventName = keyof KiloChatEventMap + +// ── Webview ↔ extension state ─────────────────────────────────────── + +export type TypingMember = { memberId: string; at: number } + // Full state snapshot pushed to the webview // Every phase carries `locale` so the webview can resolve translations immediately. export type KiloClawState = @@ -47,24 +246,65 @@ export type KiloClawState = phase: "ready" locale: string status: ClawStatus | null - connected: boolean - online: boolean - messages: ChatMessage[] + currentUserId: string + sandboxId: string + conversations: ConversationListItem[] + hasMoreConversations: boolean + activeConversationId: string | null + messages: Message[] + hasMoreMessages: boolean + botStatus: BotStatusRecord | null + conversationStatus: ConversationStatusRecord | null + typingMembers: TypingMember[] } -// Messages: Webview → Extension Host +// ── Messages: Webview → Extension Host ────────────────────────────── + export type KiloClawInMessage = | { type: "kiloclaw.ready" } - | { type: "kiloclaw.send"; text: string } | { type: "kiloclaw.openExternal"; url: string } + | { type: "kiloclaw.selectConversation"; conversationId: string } + | { type: "kiloclaw.createConversation"; title?: string } + | { type: "kiloclaw.renameConversation"; conversationId: string; title: string } + | { type: "kiloclaw.leaveConversation"; conversationId: string } + | { type: "kiloclaw.loadMoreConversations" } + | { + type: "kiloclaw.sendMessage" + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + } + | { type: "kiloclaw.editMessage"; conversationId: string; messageId: string; content: ContentBlock[] } + | { type: "kiloclaw.deleteMessage"; conversationId: string; messageId: string } + | { type: "kiloclaw.loadMoreMessages"; conversationId: string; before: string } + | { type: "kiloclaw.addReaction"; conversationId: string; messageId: string; emoji: string } + | { type: "kiloclaw.removeReaction"; conversationId: string; messageId: string; emoji: string } + | { + type: "kiloclaw.executeAction" + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + } + | { type: "kiloclaw.sendTyping"; conversationId: string } + | { type: "kiloclaw.sendTypingStop"; conversationId: string } + | { type: "kiloclaw.markRead"; conversationId: string } + +// ── Messages: Extension Host → Webview ────────────────────────────── -// Messages: Extension Host → Webview export type KiloClawOutMessage = | { type: "kiloclaw.state"; state: KiloClawState } - | { type: "kiloclaw.message"; message: ChatMessage } - | { type: "kiloclaw.messageUpdated"; message: ChatMessage } - | { type: "kiloclaw.presence"; online: boolean } | { type: "kiloclaw.status"; data: ClawStatus | null } | { type: "kiloclaw.locale"; locale: string } | { type: "kiloclaw.error"; error: string } + | { type: "kiloclaw.conversations"; conversations: ConversationListItem[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.activeConversation"; conversationId: string | null } + | { type: "kiloclaw.messages"; conversationId: string; messages: Message[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.messageOptimistic"; conversationId: string; message: Message } + | { type: "kiloclaw.messageReplaced"; conversationId: string; pendingId: string; message: Message } + | { type: "kiloclaw.messageRemoved"; conversationId: string; messageId: string } + | { type: "kiloclaw.botStatus"; status: BotStatusRecord | null } + | { type: "kiloclaw.conversationStatus"; status: ConversationStatusRecord | null } + | { type: "kiloclaw.typing"; conversationId: string; memberId: string } + | { type: "kiloclaw.typingStop"; conversationId: string; memberId: string } | { type: "fontSizeChanged"; fontSize: number } diff --git a/packages/kilo-vscode/src/kiloclaw/ulid.ts b/packages/kilo-vscode/src/kiloclaw/ulid.ts new file mode 100644 index 00000000000..a55bda569d3 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/ulid.ts @@ -0,0 +1,41 @@ +/** + * Minimal ULID generator for the extension host. + * + * Produces a 26-character Crockford Base32 identifier: 10 time chars + * followed by 16 random chars. The kilo-chat worker validates clientId + * as a ULID, so `generateClientId` must emit only Crockford-legal + * characters — `toString(36)` is NOT safe because base36 includes + * I, L, O, and U which are excluded from Crockford Base32. + */ + +// Crockford Base32 — no I, L, O, U (reduces transcription ambiguity). +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const ENCODING_LEN = ENCODING.length +const TIME_LEN = 10 +const RANDOM_LEN = 16 + +function encodeTime(ts: number): string { + let out = "" + let n = ts + for (let i = 0; i < TIME_LEN; i++) { + const mod = n % ENCODING_LEN + out = ENCODING[mod] + out + n = (n - mod) / ENCODING_LEN + } + return out +} + +function encodeRandom(): string { + const bytes = new Uint8Array(RANDOM_LEN) + globalThis.crypto.getRandomValues(bytes) + let out = "" + for (let i = 0; i < RANDOM_LEN; i++) { + out += ENCODING[bytes[i]! % ENCODING_LEN] + } + return out +} + +/** Generate a ULID at the current epoch. */ +export function ulid(): string { + return encodeTime(Date.now()) + encodeRandom() +} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index cf7cb1f908d..cae5ba1e0c8 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -2,14 +2,14 @@ * Provider action handlers extracted from KiloProvider to stay under max-lines. * These are pure async functions that operate on the SDK client — no vscode dependency. */ -import type { KiloClient } from "@kilocode/sdk/v2" +import type { Config, KiloClient } from "@kilocode/sdk/v2" import { validateProviderID as validateProviderIDShared } from "./shared/custom-provider" import { resolveCustomProviderAuth, sanitizeCustomProviderConfig, withCustomProviderDeletions, } from "./shared/custom-provider" -import { KILO_AUTO, parseModelString } from "./shared/provider-model" +import { CUSTOM_PROVIDER_PACKAGE, KILO_AUTO, parseModelString } from "./shared/provider-model" import { configFeatures } from "./features" /** @@ -22,6 +22,28 @@ function disabledWithout(list: string[] | undefined, id: string) { return (list ?? []).filter((item) => item !== id) } +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function customProvider(config: unknown) { + return record(config) && config.npm === CUSTOM_PROVIDER_PACKAGE +} + +function same(a: unknown, b: unknown): boolean { + if (a === b) return true + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + return a.every((value, index) => same(value, b[index])) + } + if (!record(a) || !record(b)) return false + const akeys = Object.keys(a).sort() + const bkeys = Object.keys(b).sort() + if (akeys.length !== bkeys.length) return false + 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). */ export async function fetchProviderData(client: KiloClient, dir: string) { const authRequest = @@ -123,6 +145,8 @@ export function computeDefaultSelection( type PostMessage = (message: unknown) => void type GetErrorMessage = (error: unknown) => string +type SetCachedConfig = (msg: unknown) => void +type AuthMetadata = Record interface ActionContext { client: KiloClient @@ -155,11 +179,93 @@ function validateID( return null } -export async function connectProvider(ctx: ActionContext, requestId: string, providerID: string, apiKey: string) { +function cleanMetadata(input?: Record): AuthMetadata | undefined { + const entries = Object.entries(input ?? {}) + .map(([key, value]) => [key, typeof value === "string" ? value.trim() : ""] as const) + .filter(([key, value]) => key !== "" && value !== "") + if (entries.length === 0) return undefined + return Object.fromEntries(entries) +} + +async function configs(ctx: ActionContext) { + const [{ data: global }, { data: merged }] = await Promise.all([ + ctx.client.global.config.get({ throwOnError: true }), + ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }), + ]) + return { global: global ?? {}, merged: merged ?? {} } +} + +async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfig) { + const [{ data: config }, { data: global }] = await Promise.all([ + ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }), + ctx.client.global.config.get({ throwOnError: true }), + ]) + if (!config) return + const features = configFeatures(config) + setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) +} + +async function saveGlobal(ctx: ActionContext, config: Config) { + await ctx.client.global.config.update({ config }, { throwOnError: true }) +} + +async function saveProject(ctx: ActionContext, config: Config) { + await ctx.client.config.update({ config, directory: ctx.workspaceDir }, { throwOnError: true }) +} + +async function removeAuth(ctx: ActionContext, id: string, configured: boolean) { + try { + await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) + } catch (err) { + if (!configured) throw err + console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err) + } +} + +async function removeCustom(ctx: ActionContext, id: string, global: Config, merged: Config) { + const cfg = global.provider?.[id] + const effective = merged.provider?.[id] + const tasks = [] + if (customProvider(cfg)) { + tasks.push( + saveGlobal(ctx, { + provider: { [id]: null }, + disabled_providers: disabledWithout(global.disabled_providers, id), + }), + ) + } + if (customProvider(effective)) { + tasks.push(saveProject(ctx, { provider: { [id]: null } })) + } + await Promise.all(tasks) +} + +async function disableConfigured(ctx: ActionContext, id: string, config: Config) { + const disabled = config.disabled_providers ?? [] + if (disabled.includes(id)) return + await saveGlobal(ctx, { disabled_providers: [...disabled, id] }) +} + +async function enableConfigured(ctx: ActionContext, id: string, config: Config) { + const disabled = disabledWithout(config.disabled_providers, id) + if (disabled.length === (config.disabled_providers ?? []).length) return + await saveGlobal(ctx, { disabled_providers: disabled }) +} + +export async function connectProvider( + ctx: ActionContext, + requestId: string, + providerID: string, + apiKey: string, + metadata?: Record, +) { const id = validateID(ctx, requestId, providerID, "connect") if (!id) return try { - await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: apiKey } }, { throwOnError: true }) + const meta = cleanMetadata(metadata) + const auth = meta ? { type: "api" as const, key: apiKey, metadata: meta } : { type: "api" as const, key: apiKey } + await ctx.client.auth.set({ providerID: id, auth }, { throwOnError: true }) await ctx.disposeGlobal(`provider connect (${id})`) await ctx.fetchAndSendProviders() ctx.postMessage({ type: "providerConnected", requestId, providerID: id }) @@ -230,64 +336,45 @@ export async function disconnectProvider( requestId: string, providerID: string, cachedConfigMessage: unknown, - setCachedConfig: (msg: unknown) => void, + setCachedConfig: SetCachedConfig, ) { const id = validateID(ctx, requestId, providerID, "disconnect") if (!id) return try { - const globalConfig = (await ctx.client.global.config.get({ throwOnError: true })).data ?? {} - const configured = !!globalConfig.provider?.[id] + const config = await configs(ctx) + const cfg = config.global.provider?.[id] + const effective = config.merged.provider?.[id] + const configured = !!cfg || !!effective + const custom = customProvider(cfg) || customProvider(effective) const { response } = await fetchProviderData(ctx.client, ctx.workspaceDir) const active = response.all.find((item) => item.id === id) - const oauth = active?.source === "custom" && configured + const oauth = active?.source === "custom" && configured && !custom - // Remove auth store entry. Config-sourced providers may not have an auth - // store entry (credentials come from config or env), so failure is non-fatal. - // For auth-only providers, failure means disconnect failed. - try { - await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) - } catch (err) { - if (!configured) throw err - console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err) - } + // Config-sourced providers may not have auth store entries because + // credentials can come from config or env, so auth removal is non-fatal. + await removeAuth(ctx, id, configured) if (id === "kilo") { ctx.postMessage({ type: "profileData", data: null }) } - // Config-sourced providers stay "connected" after auth.remove because the - // server rebuilds state from config. Add to disabled_providers so the server - // excludes them. The config entry is preserved (user may re-enable later). - // This mirrors the server's disabled provider config behavior. - if (configured && !oauth) { - const disabled = globalConfig.disabled_providers ?? [] - if (!disabled.includes(id)) { - const merged = ( - await ctx.client.global.config.update( - { config: { disabled_providers: [...disabled, id] } }, - { throwOnError: true }, - ) - ).data - if (merged) { - setCachedConfig({ type: "configLoaded", config: merged, features: configFeatures(merged) }) - ctx.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) - } - } + if (custom) { + await removeCustom(ctx, id, config.global, config.merged) + } + + // Config-sourced built-in providers stay "connected" after auth.remove + // because the server rebuilds state from config. Add to disabled_providers + // so the server excludes them while preserving config for re-enable. + if (configured && !oauth && !custom) { + await disableConfigured(ctx, id, config.global) } if (oauth) { - const disabled = disabledWithout(globalConfig.disabled_providers, id) - if (disabled.length !== (globalConfig.disabled_providers ?? []).length) { - const merged = ( - await ctx.client.global.config.update({ config: { disabled_providers: disabled } }, { throwOnError: true }) - ).data - if (merged) { - setCachedConfig({ type: "configLoaded", config: merged }) - ctx.postMessage({ type: "configUpdated", config: merged }) - } - } + await enableConfigured(ctx, id, config.global) } + if (configured) await refreshConfig(ctx, setCachedConfig) + await ctx.disposeGlobal(`provider disconnect (${id})`) await ctx.fetchAndSendProviders() ctx.postMessage({ type: "providerDisconnected", requestId, providerID: id }) @@ -336,9 +423,11 @@ export async function saveCustomProvider( { throwOnError: true }, ) - const msg = { type: "configLoaded", config: updated, features: configFeatures(updated) } + const merged = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) + const config = merged.data ?? updated + const msg = { type: "configLoaded", config, globalConfig: updated, features: configFeatures(config) } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config: updated, features: configFeatures(updated) }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features: configFeatures(config) }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/src/review-utils.ts b/packages/kilo-vscode/src/review-utils.ts index 1e2a7c7927a..42dac76b284 100644 --- a/packages/kilo-vscode/src/review-utils.ts +++ b/packages/kilo-vscode/src/review-utils.ts @@ -1,9 +1,6 @@ import * as path from "path" import * as vscode from "vscode" import { inspect } from "util" -import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" -import { GitOps } from "./agent-manager/GitOps" -import { resolveBase } from "./agent-manager/local-diff" export function appendOutput(channel: vscode.OutputChannel, prefix: string, ...args: unknown[]): void { const msg = args @@ -18,60 +15,6 @@ export function getWorkspaceRoot(): string | undefined { return undefined } -export async function resolveLocalDiffTarget( - gitOps: GitOps, - log: (...args: unknown[]) => void, - root?: string, -): Promise<{ directory: string; baseBranch: string } | undefined> { - if (!root) { - log("Local diff: no workspace root") - return - } - - const branch = await gitOps.currentBranch(root) - if (!branch || branch === "HEAD") { - log("Local diff: detached HEAD or no branch") - return - } - - const tracking = await gitOps.resolveTrackingBranch(root, branch) - const fallback = tracking ? undefined : await gitOps.resolveDefaultBranch(root, branch) - const raw = tracking || fallback || "HEAD" - const base = await resolveBase(gitOps, root, raw) - - log(`Local diff: branch=${branch} tracking=${tracking ?? "none"} default=${fallback ?? "none"} base=${base}`) - - return { directory: root, baseBranch: base } -} - -export function hashFileDiffs( - diffs: Array< - SnapshotFileDiff & { - tracked?: boolean - generatedLike?: boolean - summarized?: boolean - stamp?: string - } - >, -): string { - return diffs - .map((diff) => { - const content = diff.summarized ? "" : diff.patch - return [ - diff.file, - diff.status, - diff.additions, - diff.deletions, - diff.tracked ? "tracked" : "untracked", - diff.generatedLike ? "generated" : "source", - diff.summarized ? "summary" : "detail", - diff.stamp ?? "", - content, - ].join(":") - }) - .join("|") -} - export function openFileInEditor( filePath: string, line?: number, @@ -96,5 +39,5 @@ export function openWorkspaceRelativeFile(relativePath: string, line?: number, c if (!root) return const resolved = path.resolve(root, relativePath) if (!resolved.startsWith(root + path.sep) && resolved !== root) return - openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffViewerProvider") + openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffPanel") } diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts deleted file mode 100644 index 020ae9651ae..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { ResponseMetaData } from "./types" -import type { KiloConnectionService } from "../cli-backend" -import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" - -export class AutocompleteModel { - private connectionService: KiloConnectionService | null = null - private currentModel: string = DEFAULT_AUTOCOMPLETE_MODEL.id - public profileName: string | null = null - public profileType: string | null = null - - constructor(connectionService?: KiloConnectionService) { - if (connectionService) { - this.connectionService = connectionService - } - } - - public setModel(model: string): void { - this.currentModel = model - } - - /** - * Set the connection service (can be called after construction when service becomes available) - */ - public setConnectionService(service: KiloConnectionService): void { - this.connectionService = service - } - - /** - * Generate a FIM (Fill-in-the-Middle) completion via the CLI backend. - * Uses the SDK's kilo.fim() SSE endpoint which handles auth and streaming. - * - * @param signal - Optional AbortSignal to cancel the SSE stream early (e.g. when the user types again) - */ - public async generateFimResponse( - prefix: string, - suffix: string, - onChunk: (text: string) => void, - signal?: AbortSignal, - ): Promise { - if (!this.connectionService) { - throw new Error("Connection service is not available") - } - - const client = await this.connectionService.getClientAsync() - - let cost = 0 - let inputTokens = 0 - let outputTokens = 0 - - // Capture SSE-level errors so they propagate to the caller. The SDK's SSE - // client catches HTTP errors (402, 401, 429, 5xx) internally and silently - // ends the stream. Without this, errors never reach ErrorBackoff. - let sseError: Error | undefined - - const temp = getAutocompleteModel(this.currentModel).temperature - - const { stream } = await client.kilo.fim( - { - prefix, - suffix, - model: this.currentModel, - maxTokens: 256, - temperature: temp, - }, - { - signal, - sseMaxRetryAttempts: 1, - onSseError: (error) => { - sseError = error instanceof Error ? error : new Error(String(error)) - }, - }, - ) - - for await (const chunk of stream) { - const choice = chunk.choices?.[0] - const content = choice?.delta?.content ?? choice?.text - if (content) onChunk(content) - if (chunk.usage) { - inputTokens = chunk.usage.prompt_tokens ?? 0 - outputTokens = chunk.usage.completion_tokens ?? 0 - } - if (chunk.cost !== undefined) cost = chunk.cost - } - - if (sseError) throw sseError - - return { - cost, - inputTokens, - outputTokens, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - } - - public getModelName(): string { - return this.currentModel - } - - public getProviderDisplayName(): string { - return getAutocompleteModel(this.currentModel).provider - } - - /** - * Check if the model has valid credentials. - * With CLI backend, credentials are managed by the backend — we just need a connection. - */ - public hasValidCredentials(): boolean { - if (!this.connectionService) { - return false - } - return this.connectionService.getConnectionState() === "connected" - } - - /** - * Check the user's credit balance via the profile endpoint. - * Returns true if the user has a positive balance, false otherwise. - * Returns false on any error (not connected, fetch failed, etc.). - */ - public async hasBalance(): Promise { - if (!this.connectionService) return false - try { - const client = await this.connectionService.getClientAsync() - const result = await client.kilo.profile().catch(() => null) - return (result?.data?.balance?.balance ?? 0) > 0 - } catch { - return false - } - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 1beb1485ea4..a272f677372 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -2,13 +2,13 @@ import crypto from "crypto" import * as vscode from "vscode" import { t } from "./shims/i18n" import { TelemetryProxy, TelemetryEventName } from "../telemetry" -import { AutocompleteModel } from "./AutocompleteModel" import { AutocompleteStatusBar } from "./AutocompleteStatusBar" import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider" import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider" import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry" import type { KiloConnectionService } from "../cli-backend" -import { getAutocompleteModel } from "../../shared/autocomplete-models" +import { hasValidCredentials } from "./fim" +import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" const CONFIG_SECTION = "kilo-code.new.autocomplete" @@ -42,7 +42,7 @@ async function writeSettings(patch: Partial): Promi export class AutocompleteServiceManager { private static _instance: AutocompleteServiceManager | null = null - private readonly model: AutocompleteModel + private readonly connectionService: KiloConnectionService private readonly context: vscode.ExtensionContext private settings: AutocompleteServiceSettings | null = null @@ -71,18 +71,17 @@ export class AutocompleteServiceManager { } this.context = context + this.connectionService = connectionService AutocompleteServiceManager._instance = this - // Register Internal Components - this.model = new AutocompleteModel(connectionService) - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "" // Register the providers this.codeActionProvider = new AutocompleteCodeActionProvider() this.inlineCompletionProvider = new AutocompleteInlineCompletionProvider( this.context, - this.model, + DEFAULT_AUTOCOMPLETE_MODEL.id, + connectionService, this.updateCostTracking.bind(this), () => this.settings, workspacePath, @@ -121,7 +120,7 @@ export class AutocompleteServiceManager { this.settings = readSettings() if (this.settings.model) { - this.model.setModel(this.settings.model) + this.inlineCompletionProvider.setModel(this.settings.model) } await this.updateGlobalContext() @@ -320,15 +319,15 @@ export class AutocompleteServiceManager { } private getCurrentModelName(): string { - return this.model.getModelName() + return this.inlineCompletionProvider.getModelId() } private getCurrentProviderName(): string { - return this.model.getProviderDisplayName() + return getAutocompleteModel(this.inlineCompletionProvider.getModelId()).provider } private hasNoUsableProvider(): boolean { - return !this.model.hasValidCredentials() + return !hasValidCredentials(this.connectionService) } /** @@ -368,7 +367,6 @@ export class AutocompleteServiceManager { snoozed: this.isSnoozed(), model: this.getCurrentModelName(), provider: this.getCurrentProviderName(), - profileName: this.model.profileName, hasNoUsableProvider: this.hasNoUsableProvider(), totalSessionCost: this.sessionCost, completionCount: this.completionCount, diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts deleted file mode 100644 index cce80476b89..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { AutocompleteModel } from "../AutocompleteModel" -import type { KiloConnectionService } from "../../cli-backend" - -const mockClient = { - kilo: { - fim: vi.fn(), - }, -} - -function createMockConnectionService(state: "connecting" | "connected" | "disconnected" | "error" = "connected") { - return { - getConnectionState: vi.fn().mockReturnValue(state), - getClient: vi.fn().mockReturnValue(mockClient), - getClientAsync: - state === "connected" - ? vi.fn().mockResolvedValue(mockClient) - : vi.fn().mockRejectedValue(new Error(`CLI backend is not connected (state: ${state})`)), - onStateChange: vi.fn().mockReturnValue(() => {}), - } as unknown as KiloConnectionService -} - -describe("AutocompleteModel", () => { - beforeEach(() => { - mockClient.kilo.fim.mockReset() - }) - - describe("constructor", () => { - it("defaults profileName and profileType to null", () => { - const model = new AutocompleteModel() - expect(model.profileName).toBeNull() - expect(model.profileType).toBeNull() - }) - }) - - describe("setConnectionService", () => { - it("sets the connection service after construction", () => { - const model = new AutocompleteModel() - expect(model.hasValidCredentials()).toBe(false) - - const connection = createMockConnectionService("connected") - model.setConnectionService(connection) - expect(model.hasValidCredentials()).toBe(true) - }) - }) - - describe("hasValidCredentials", () => { - it("returns true when connected", () => { - const connection = createMockConnectionService("connected") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(true) - }) - - it("returns false when disconnected", () => { - const connection = createMockConnectionService("disconnected") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(false) - }) - - it("returns false when connecting", () => { - const connection = createMockConnectionService("connecting") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(false) - }) - - it("returns false when in error state", () => { - const connection = createMockConnectionService("error") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(false) - }) - - it("returns false without connection service", () => { - const model = new AutocompleteModel() - expect(model.hasValidCredentials()).toBe(false) - }) - }) - - describe("getModelName", () => { - it("returns the default model", () => { - const model = new AutocompleteModel() - expect(model.getModelName()).toBe("mistralai/codestral-2508") - }) - }) - - describe("getProviderDisplayName", () => { - it("returns the default provider", () => { - const model = new AutocompleteModel() - expect(model.getProviderDisplayName()).toBe("Mistral AI") - }) - - it("returns the selected provider", () => { - const model = new AutocompleteModel() - model.setModel("inception/mercury-edit") - - expect(model.getProviderDisplayName()).toBe("Inception") - }) - }) - - describe("generateFimResponse", () => { - it("throws when connection service is not available", async () => { - const model = new AutocompleteModel() - await expect(model.generateFimResponse("prefix", "suffix", vi.fn())).rejects.toThrow( - "Connection service is not available", - ) - }) - - it("throws when not connected", async () => { - const connection = createMockConnectionService("disconnected") - const model = new AutocompleteModel(connection) - await expect(model.generateFimResponse("prefix", "suffix", vi.fn())).rejects.toThrow( - "CLI backend is not connected", - ) - }) - - it("streams chunks and returns metadata", async () => { - const chunks = [ - { choices: [{ delta: { content: "hello" } }] }, - { - choices: [{ delta: { content: " world" } }], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - cost: 0.001, - }, - ] - - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () { - for (const chunk of chunks) yield chunk - })(), - }) - - const model = new AutocompleteModel(connection) - const received: string[] = [] - const result = await model.generateFimResponse("prefix", "suffix", (text) => received.push(text)) - - expect(received).toEqual(["hello", " world"]) - expect(result).toEqual({ - cost: 0.001, - inputTokens: 10, - outputTokens: 5, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - }) - - it("streams text-completion chunks", async () => { - const chunks = [{ choices: [{ text: "hello" }] }, { choices: [{ text: " world" }] }] - - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () { - for (const chunk of chunks) yield chunk - })(), - }) - - const model = new AutocompleteModel(connection) - const received: string[] = [] - await model.generateFimResponse("prefix", "suffix", (text) => received.push(text)) - - expect(received).toEqual(["hello", " world"]) - }) - - it("passes model parameters to fim call", async () => { - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () {})(), - }) - - const model = new AutocompleteModel(connection) - const signal = new AbortController().signal - await model.generateFimResponse("pre", "suf", vi.fn(), signal) - - expect(mockClient.kilo.fim).toHaveBeenCalledWith( - { - prefix: "pre", - suffix: "suf", - model: "mistralai/codestral-2508", - maxTokens: 256, - temperature: 0.2, - }, - expect.objectContaining({ signal }), - ) - }) - - it("passes selected model parameters to fim call", async () => { - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () {})(), - }) - - const model = new AutocompleteModel(connection) - model.setModel("inception/mercury-edit") - await model.generateFimResponse("pre", "suf", vi.fn()) - - expect(mockClient.kilo.fim).toHaveBeenCalledWith( - expect.objectContaining({ - model: "inception/mercury-edit", - temperature: 0, - }), - expect.any(Object), - ) - }) - }) -}) 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 cc1398cf57c..1244b25b9ec 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts @@ -61,26 +61,6 @@ vi.mock("vscode", () => { } }) -vi.mock("../AutocompleteModel", () => { - class AutocompleteModel { - public profileName = "test-profile" - - public getModelName(): string { - return "test-model" - } - - public getProviderDisplayName(): string { - return "test-provider" - } - - public hasValidCredentials(): boolean { - return true - } - } - - return { AutocompleteModel } -}) - vi.mock("../AutocompleteStatusBar", () => { class AutocompleteStatusBar { public update = vi.fn() @@ -99,6 +79,14 @@ vi.mock("../classic-auto-complete/AutocompleteInlineCompletionProvider", () => { class AutocompleteInlineCompletionProvider { public provideInlineCompletionItems_Internal = vi.fn() public dispose = vi.fn() + public resetBackoff = vi.fn() + private modelId = "test-model" + public setModel(id: string) { + this.modelId = id + } + public getModelId(): string { + return this.modelId + } constructor(..._args: any[]) {} } @@ -164,7 +152,12 @@ async function createManager(): Promise { postStateToWebview: vi.fn().mockResolvedValue(undefined), } - const connection = { onStateChange: vi.fn().mockReturnValue(() => {}), ...cline } + const connection = { + onStateChange: vi.fn().mockReturnValue(() => {}), + onEventFiltered: vi.fn().mockReturnValue(() => {}), + getConnectionState: vi.fn().mockReturnValue("connected"), + ...cline, + } const manager = new AutocompleteServiceManager(context, connection as any) 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 9ee596bc23b..5552d8c9fa6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const state = new Map() -const update = vi.fn(async (key: string, value: unknown) => { +const update = vi.fn((key: string, value: unknown) => { state.set(key, value) }) @@ -25,10 +25,10 @@ describe("autocomplete settings", () => { }) it("includes the configured model in loaded settings", async () => { - state.set("model", "inception/mercury-edit") + state.set("model", "inception/mercury-edit-2") const { buildAutocompleteSettingsMessage } = await import("../settings") - expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit") + expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit-2") }) it("defaults to codestral when no model is set", async () => { @@ -44,36 +44,28 @@ describe("autocomplete settings", () => { expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508") }) - it("persists supported model updates", async () => { - const post = vi.fn() - const { routeAutocompleteMessage } = await import("../settings") + it("maps legacy inception/mercury-edit to inception/mercury-edit-2", async () => { + state.set("model", "inception/mercury-edit") + const { buildAutocompleteSettingsMessage } = await import("../settings") - await routeAutocompleteMessage( - { type: "updateAutocompleteSetting", key: "model", value: "inception/mercury-edit" }, - post, - ) + expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit-2") + }) - expect(update).toHaveBeenCalledWith("model", "inception/mercury-edit", 1) - expect(post).toHaveBeenCalledWith(expect.objectContaining({ type: "autocompleteSettingsLoaded" })) + it("validates supported model updates", async () => { + const { validAutocompleteSetting } = await import("../settings") + + expect(validAutocompleteSetting("model", "inception/mercury-edit-2")).toBe(true) }) it("rejects unsupported model updates", async () => { - const post = vi.fn() - const { routeAutocompleteMessage } = await import("../settings") + const { validAutocompleteSetting } = await import("../settings") - await routeAutocompleteMessage({ type: "updateAutocompleteSetting", key: "model", value: "other/model" }, post) - - expect(update).not.toHaveBeenCalled() - expect(post).not.toHaveBeenCalled() + expect(validAutocompleteSetting("model", "other/model")).toBe(false) }) it("rejects non-boolean toggle updates", async () => { - const post = vi.fn() - const { routeAutocompleteMessage } = await import("../settings") + const { validAutocompleteSetting } = await import("../settings") - await routeAutocompleteMessage({ type: "updateAutocompleteSetting", key: "enableAutoTrigger", value: "true" }, post) - - expect(update).not.toHaveBeenCalled() - expect(post).not.toHaveBeenCalled() + expect(validAutocompleteSetting("enableAutoTrigger", "true")).toBe(false) }) }) 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 328ba612eb4..3971e785a11 100644 --- a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode" -import { AutocompleteModel } from "../AutocompleteModel" import type { AutocompleteContext, VisibleCodeContext } from "../types" import { removePrefixOverlap } from "../continuedev/core/autocomplete/postprocessing/removePrefixOverlap.js" import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry" @@ -7,6 +6,7 @@ import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/usel 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 { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils" @@ -29,14 +29,14 @@ interface ChatCompletionResponseSender { * acceptance events correlate. */ export class ChatTextAreaAutocomplete { - private model: AutocompleteModel + private connection: KiloConnectionService readonly telemetry: AutocompleteTelemetry private ignore: FileIgnoreController | null = null private dir = "" private watcher: vscode.FileSystemWatcher | undefined constructor(connectionService: KiloConnectionService, telemetry?: AutocompleteTelemetry) { - this.model = new AutocompleteModel(connectionService) + this.connection = connectionService this.telemetry = telemetry ?? new AutocompleteTelemetry("chat-textarea") this.watcher = vscode.workspace.createFileSystemWatcher("**/{.kilocodeignore,.gitignore}") const invalidate = () => { @@ -77,18 +77,18 @@ export class ChatTextAreaAutocomplete { async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> { const cfg = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") - this.model.setModel(getAutocompleteModel(cfg.get("model") ?? "").id) + const entry = getAutocompleteModel(cfg.get("model") ?? "") const startTime = Date.now() // Build context for telemetry const context: AutocompleteContext = { languageId: "chat", // Chat textarea doesn't have a language ID - modelId: this.model.getModelName(), - provider: this.model.getProviderDisplayName(), + modelId: entry.id, + provider: entry.provider, } - // Check if model has valid credentials (but don't require FIM) - if (!this.model.hasValidCredentials()) { + // Check for valid credentials (but don't require FIM) + if (!hasValidCredentials(this.connection)) { return { suggestion: "" } } @@ -101,7 +101,7 @@ export class ChatTextAreaAutocomplete { let response = "" try { - await this.model.generateFimResponse(prefix, suffix, (chunk) => { + await generateFim(this.connection, entry.id, prefix, suffix, (chunk) => { response += chunk }) @@ -116,7 +116,7 @@ export class ChatTextAreaAutocomplete { context, ) - const cleanedSuggestion = this.cleanSuggestion(response, userText) + const cleanedSuggestion = this.cleanSuggestion(response, userText, entry.id) // Track if suggestion was filtered or returned if (!cleanedSuggestion) { @@ -147,12 +147,12 @@ export class ChatTextAreaAutocomplete { return buildChatPrefix(userText, visibleCodeContext?.editors) } - public cleanSuggestion(suggestion: string, userText: string): string { + public cleanSuggestion(suggestion: string, userText: string, modelId: string): string { const cleaned = postprocessAutocompleteSuggestion({ suggestion: removePrefixOverlap(suggestion, userText), prefix: userText, suffix: "", - model: this.model.getModelName() ?? "unknown", + model: modelId || "unknown", }) if (cleaned === undefined) return "" return finalizeChatSuggestion(cleaned) 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 01e51ac0095..78c16079f52 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 @@ -16,14 +16,13 @@ import { import { findMatchingSuggestion as _findMatchingSuggestion, applyFirstLineOnly as _applyFirstLineOnly, - countLines as _countLines, - shouldShowOnlyFirstLine as _shouldShowOnlyFirstLine, - getFirstLine as _getFirstLine, calcDebounceDelay, MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn, } from "./inline-utils" import { FimPromptBuilder } from "./FillInTheMiddle" -import { AutocompleteModel } from "../AutocompleteModel" +import { hasValidCredentials } from "../fim" +import type { KiloConnectionService } from "../../cli-backend" +import { getAutocompleteModel } 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" @@ -91,18 +90,6 @@ export function applyFirstLineOnly( */ export const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.autocomplete.inline-completion.accepted" -export function countLines(text: string): number { - return _countLines(text) -} - -export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { - return _shouldShowOnlyFirstLine(prefix, suggestion) -} - -export function getFirstLine(text: string): string { - return _getFirstLine(text) -} - export function stringToInlineCompletions(text: string, position: vscode.Position): vscode.InlineCompletionItem[] { if (text === "") { return [] @@ -120,7 +107,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple /** Tracks all pending/in-flight requests */ private pendingRequests: PendingRequest[] = [] private fimPromptBuilder: FimPromptBuilder - private model: AutocompleteModel + private contextProvider: AutocompleteContextProvider + private connectionService: KiloConnectionService private costTrackingCallback: CostTrackingCallback private getSettings: () => AutocompleteServiceSettings | null private recentlyVisitedRangesService: RecentlyVisitedRangesService @@ -148,7 +136,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple constructor( context: vscode.ExtensionContext, - model: AutocompleteModel, + modelId: string, + connectionService: KiloConnectionService, costTrackingCallback: CostTrackingCallback, getSettings: () => AutocompleteServiceSettings | null, workspacePath: string, @@ -156,7 +145,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple onFatalError?: (status: number | null) => void, ) { this.telemetry = telemetry - this.model = model + this.connectionService = connectionService this.costTrackingCallback = costTrackingCallback this.getSettings = getSettings this.onFatalError = onFatalError ?? null @@ -169,13 +158,13 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const ide = new VsCodeIde(context) this.contextService = new ContextRetrievalService(ide) - const contextProvider: AutocompleteContextProvider = { + this.contextProvider = { ide, contextService: this.contextService, - model, + modelId, ignoreController: this.ignoreController, } - this.fimPromptBuilder = new FimPromptBuilder(contextProvider) + this.fimPromptBuilder = new FimPromptBuilder(this.contextProvider) this.recentlyVisitedRangesService = new RecentlyVisitedRangesService(ide) this.recentlyEditedTracker = new RecentlyEditedTracker(ide) @@ -229,17 +218,29 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const prompt = await this.fimPromptBuilder.getFimPrompts( autocompleteInput, - this.model.getModelName() ?? "codestral", + this.contextProvider.modelId || "codestral", ) return { prompt, prefix, suffix } } + /** + * Update the autocomplete model ID. The context provider (shared with the + * FIM prompt builder) is mutated in place so downstream consumers pick up + * the new value on the next request. + */ + public setModel(modelId: string): void { + this.contextProvider.modelId = modelId + } + + public getModelId(): string { + return this.contextProvider.modelId + } + private processSuggestion( suggestionText: string, prefix: string, suffix: string, - model: AutocompleteModel, telemetryContext: AutocompleteContext, languageId?: string, ): FillInAtCursorSuggestion { @@ -252,7 +253,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple suggestion: suggestionText, prefix, suffix, - model: model.getModelName() || "", + model: this.contextProvider.modelId || "", languageId, }) @@ -343,14 +344,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // Build telemetry context const telemetryContext: AutocompleteContext = { languageId: document.languageId, - modelId: this.model?.getModelName(), - provider: this.model?.getProviderDisplayName(), + modelId: this.contextProvider.modelId, + provider: getAutocompleteModel(this.contextProvider.modelId).provider, } this.telemetry?.captureSuggestionRequested(telemetryContext) - if (!this.model || !this.model.hasValidCredentials()) { - // bail if no model is available or no valid API credentials configured + if (!hasValidCredentials(this.connectionService)) { + // bail if no valid API credentials configured // this prevents errors when autocomplete is enabled but no provider is set up return [] } @@ -364,8 +365,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // instead of sending a probe FIM request. If the user has added credits, // reset the backoff so autocomplete resumes. if (this.backoff.getFatalStatus() === 402 && this.backoff.shouldProbe()) { - const funded = await this.model.hasBalance() - if (funded) { + if (await this.hasBalance()) { this.backoff.reset() this.fatalNotified = false } @@ -585,24 +585,24 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // Build telemetry context for this request const telemetryContext: AutocompleteContext = { languageId, - modelId: this.model?.getModelName(), - provider: this.model?.getProviderDisplayName(), + modelId: this.contextProvider.modelId, + provider: getAutocompleteModel(this.contextProvider.modelId).provider, } // Defense-in-depth: credentials may become invalid between the provider gate and the actual - // debounced execution (e.g., profile reload calling AutocompleteModel.cleanup()). - // In that case, do not attempt an LLM call at all. - if (!this.model || !this.model.hasValidCredentials()) { + // debounced execution. In that case, do not attempt an LLM call at all. + if (!hasValidCredentials(this.connectionService)) { return } try { - // Curry processSuggestion with prefix, suffix, model, telemetry context, and languageId + // Curry processSuggestion with prefix, suffix, telemetry context, and languageId const curriedProcessSuggestion = (text: string) => - this.processSuggestion(text, prefix, suffix, this.model, telemetryContext, languageId) + this.processSuggestion(text, prefix, suffix, telemetryContext, languageId) const result = await this.fimPromptBuilder.getFromFIM( - this.model, + this.connectionService, + this.contextProvider.modelId, prompt, curriedProcessSuggestion, controller.signal, @@ -654,4 +654,19 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } } } + + /** + * Check the user's credit balance via the profile endpoint. + * Returns true if the user has a positive balance, false otherwise. + * Returns false on any error (not connected, fetch failed, etc.). + */ + private async hasBalance(): Promise { + try { + const client = await this.connectionService.getClientAsync() + const result = await client.kilo.profile().catch(() => null) + return (result?.data?.balance?.balance ?? 0) > 0 + } catch { + return false + } + } } 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 6c6b2a1e0a0..6a36ae3ab32 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 @@ -7,7 +7,8 @@ import { } from "../types" import { getProcessedSnippets } from "./getProcessedSnippets" import { getTemplateForModel } from "../continuedev/core/autocomplete/templating/AutocompleteTemplate" -import { AutocompleteModel } from "../AutocompleteModel" +import { generateFim } from "../fim" +import type { KiloConnectionService } from "../../cli-backend" export type { FimAutocompletePrompt, FimCompletionResult } @@ -22,7 +23,7 @@ export class FimPromptBuilder { autocompleteInput, autocompleteInput.filepath, this.contextProvider.contextService, - this.contextProvider.model, + this.contextProvider.modelId, this.contextProvider.ide, this.contextProvider.ignoreController, ) @@ -57,7 +58,8 @@ export class FimPromptBuilder { * Execute FIM-based completion using the model */ async getFromFIM( - model: AutocompleteModel, + connection: KiloConnectionService, + modelId: string, prompt: FimAutocompletePrompt, processSuggestion: (text: string) => FillInAtCursorSuggestion, signal?: AbortSignal, @@ -82,7 +84,7 @@ export class FimPromptBuilder { response += text } logtime("prep fim") - const usageInfo = await model.generateFimResponse(formattedPrefix, prunedSuffix, onChunk, signal) + const usageInfo = await generateFim(connection, modelId, formattedPrefix, prunedSuffix, onChunk, signal) logtime("fim network") console.log("[FIM] response:", response) 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 7821b658364..fc75473d7d1 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 @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { getProcessedSnippets } from "../getProcessedSnippets" import { AutocompleteInput, AutocompleteContextProvider } from "../../types" import { AutocompleteSnippetType } from "../../continuedev/core/autocomplete/types" -import { AutocompleteModel } from "../../AutocompleteModel" import { RooIgnoreController } from "../../../../core/ignore/RooIgnoreController" import crypto from "crypto" import { ContextRetrievalService } from "../../continuedev/core/autocomplete/context/ContextRetrievalService" @@ -85,16 +84,11 @@ function createAutocompleteInput(filepath: string = "/test.ts"): AutocompleteInp describe("AutocompleteContextProvider", () => { let contextProvider: AutocompleteContextProvider - let mockModel: AutocompleteModel let mockIgnoreController: Promise | undefined beforeEach(() => { vi.clearAllMocks() - mockModel = { - getModelName: vi.fn().mockReturnValue("codestral"), - } as any - mockIgnoreController = undefined const ide = new VsCodeIde({} as any) @@ -102,7 +96,7 @@ describe("AutocompleteContextProvider", () => { contextProvider = { ide, contextService, - model: mockModel, + modelId: "codestral", ignoreController: mockIgnoreController, } }) @@ -114,7 +108,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -150,7 +144,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -195,7 +189,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -225,7 +219,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ), @@ -251,7 +245,7 @@ describe("AutocompleteContextProvider", () => { contextProvider = { ide, contextService, - model: mockModel, + modelId: "codestral", ignoreController: mockIgnoreController, } }) @@ -292,7 +286,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -353,7 +347,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -377,7 +371,7 @@ describe("AutocompleteContextProvider", () => { contextProvider = { ide, contextService, - model: mockModel, + modelId: "codestral", ignoreController: undefined, } @@ -406,7 +400,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteInlineCompletionProvider.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteInlineCompletionProvider.test.ts deleted file mode 100644 index fa64a47745b..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteInlineCompletionProvider.test.ts +++ /dev/null @@ -1,2528 +0,0 @@ -import * as vscode from "vscode" -import { - AutocompleteInlineCompletionProvider, - findMatchingSuggestion, - applyFirstLineOnly, - stringToInlineCompletions, - shouldShowOnlyFirstLine, - getFirstLine, - countLines, - CostTrackingCallback, -} from "../AutocompleteInlineCompletionProvider" -import { FillInAtCursorSuggestion } from "../../types" -import { MockTextDocument } from "../../../mocking/MockTextDocument" -import { AutocompleteModel } from "../../AutocompleteModel" -import { AutocompleteTelemetry } from "../AutocompleteTelemetry" -import * as AutocompleteContextProviderModule from "../getProcessedSnippets" - -// Mock RooIgnoreController to prevent vscode.RelativePattern errors -vi.mock("../../../../core/ignore/RooIgnoreController", () => { - return { - RooIgnoreController: class MockRooIgnoreController { - initialize = vi.fn().mockResolvedValue(undefined) - validateAccess = vi.fn().mockReturnValue(true) - dispose = vi.fn() - }, - } -}) - -// Mock AutocompleteTelemetry class - don't mock it, let it be created normally -// The tests will create real instances or null as needed - -// Mock vscode InlineCompletionTriggerKind enum and event listeners -vi.mock("vscode", async () => { - const actual = await vi.importActual("vscode") - return { - ...actual, - InlineCompletionTriggerKind: { - Invoke: 0, - Automatic: 1, - }, - // Mock InlineCompletionItem class for use in stringToInlineCompletions - InlineCompletionItem: class MockInlineCompletionItem { - insertText: string | { value: string } - range?: { start: { line: number; character: number }; end: { line: number; character: number } } - command?: { command: string; title: string } - - constructor( - insertText: string | { value: string }, - range?: { start: { line: number; character: number }; end: { line: number; character: number } }, - command?: { command: string; title: string }, - ) { - this.insertText = insertText - this.range = range - this.command = command - } - }, - window: { - ...actual.window, - onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), - }, - workspace: { - ...actual.workspace, - onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), - }, - commands: { - ...actual.commands, - registerCommand: vi.fn(() => ({ dispose: vi.fn() })), - }, - } -}) - -describe("findMatchingSuggestion", () => { - describe("failed lookups", () => { - it("should return empty string when matching a failed lookup (text is empty string)", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("exact") - expect(result!.fillInAtCursor).toBeDefined() - }) - - it("should skip failed lookups and find successful suggestions", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - { - text: "console.log('success');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("console.log('success');") - expect(result!.matchType).toBe("exact") - }) - - it("should return empty string for failed lookup even when other suggestions exist", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('other');", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - { - text: "", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("exact") - }) - }) - - describe("exact matching", () => { - it("should return suggestion text when prefix and suffix match exactly", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("console.log('Hello, World!');") - expect(result!.matchType).toBe("exact") - }) - - it("should return null when prefix does not match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("different prefix", "\nconst y = 2", suggestions) - expect(result).toBeNull() - }) - - it("should return null when suffix does not match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "different suffix", suggestions) - expect(result).toBeNull() - }) - - it("should return null when suggestions array is empty", () => { - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", []) - expect(result).toBeNull() - }) - }) - - describe("backward deletion support", () => { - it("should return deleted prefix portion plus suggestion when user backspaces", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - ] - - // User backspaced from "foo" to "f" - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("oohenk") - expect(result!.matchType).toBe("backward_deletion") - }) - - it("should return full prefix plus suggestion when user deletes entire prefix", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "world", - prefix: "hello", - suffix: "!", - }, - ] - - // User deleted entire prefix - const result = findMatchingSuggestion("", "!", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("helloworld") - expect(result!.matchType).toBe("backward_deletion") - }) - - it("should return null when suffix does not match during backward deletion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - ] - - // User backspaced but suffix changed - const result = findMatchingSuggestion("f", "baz", suggestions) - expect(result).toBeNull() - }) - - it("should return null when current prefix is not a prefix of stored prefix", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - ] - - // Current prefix "x" is not a prefix of "foo" - const result = findMatchingSuggestion("x", "bar", suggestions) - expect(result).toBeNull() - }) - - it("should not use backward deletion when suggestion text is empty", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "", - prefix: "foo", - suffix: "bar", - }, - ] - - // User backspaced - should return null because the original suggestion was empty - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result).toBeNull() - }) - - it("should prefer exact match over backward deletion match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - { - text: "exact", - prefix: "f", - suffix: "bar", - }, - ] - - // Should match the exact prefix "f" first (most recent) - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("exact") - expect(result!.matchType).toBe("exact") - }) - - it("should handle multi-character backward deletion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "test()", - prefix: "function myFunc", - suffix: " { }", - }, - ] - - // User deleted "unc" from "function myFunc" - const result = findMatchingSuggestion("function myF", " { }", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("unctest()") - expect(result!.matchType).toBe("backward_deletion") - }) - }) - - describe("partial typing support", () => { - it("should return remaining suggestion when user has partially typed", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "cons" after the prefix - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("ole.log('Hello, World!');") - expect(result!.matchType).toBe("partial_typing") - }) - - it("should return full suggestion when no partial typing", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("console.log('test');") - expect(result!.matchType).toBe("exact") - }) - - it("should return null when partially typed content does not match suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "xyz" which doesn't match the suggestion - const result = findMatchingSuggestion("const x = 1xyz", "\nconst y = 2", suggestions) - expect(result).toBeNull() - }) - - it("should return empty string when user has typed entire suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1console.log('test');", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("partial_typing") - }) - - it("should return null when suffix has changed during partial typing", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed partial content but suffix changed - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 3", suggestions) - expect(result).toBeNull() - }) - - it("should handle multi-character partial typing", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "function test() { return 42; }", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "function te" - const result = findMatchingSuggestion("const x = 1function te", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("st() { return 42; }") - expect(result!.matchType).toBe("partial_typing") - }) - - it("should be case-sensitive in partial matching", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "Console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "cons" (lowercase) but suggestion starts with "Console" (uppercase) - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).toBeNull() - }) - }) - - describe("multiple suggestions", () => { - it("should prefer most recent matching suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "second suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("second suggestion") - expect(result!.matchType).toBe("exact") - }) - - it("should match different suggestions based on context", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "second suggestion", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - ] - - const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result1).not.toBeNull() - expect(result1!.text).toBe("first suggestion") - expect(result1!.matchType).toBe("exact") - - const result2 = findMatchingSuggestion("const a = 1", "\nconst b = 2", suggestions) - expect(result2).not.toBeNull() - expect(result2!.text).toBe("second suggestion") - expect(result2!.matchType).toBe("exact") - }) - - it("should prefer exact match over partial match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('partial');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "exact match", - prefix: "const x = 1cons", - suffix: "\nconst y = 2", - }, - ] - - // User is at position that matches exact prefix of second suggestion - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("exact match") - expect(result!.matchType).toBe("exact") - }) - }) - - describe("match type tracking", () => { - it("should return exact matchType for exact prefix/suffix match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "test", - prefix: "foo", - suffix: "bar", - }, - ] - - const result = findMatchingSuggestion("foo", "bar", suggestions) - expect(result?.matchType).toBe("exact") - }) - - it("should return partial_typing matchType when user has typed part of suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result?.matchType).toBe("partial_typing") - }) - - it("should return backward_deletion matchType when user has backspaced", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "test", - prefix: "foo", - suffix: "bar", - }, - ] - - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result?.matchType).toBe("backward_deletion") - }) - }) - - describe("fillInAtCursor tracking", () => { - it("should return a fillInAtCursor for tracking visibility", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - - expect(result).not.toBeNull() - expect(result!.fillInAtCursor).toBeDefined() - expect(result!.fillInAtCursor.text).toBe("console.log('test');") - expect(result!.fillInAtCursor.prefix).toBe("const x = 1") - expect(result!.fillInAtCursor.suffix).toBe("\nconst y = 2") - }) - - it("should return the same fillInAtCursor for the same suggestion", () => { - const suggestion: FillInAtCursorSuggestion = { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - } - const suggestions: FillInAtCursorSuggestion[] = [suggestion] - - const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - const result2 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - - // Should be the same object reference - expect(result1!.fillInAtCursor).toBe(result2!.fillInAtCursor) - }) - - it("should return different fillInAtCursor for different suggestions", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "second suggestion", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - ] - - const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - const result2 = findMatchingSuggestion("const a = 1", "\nconst b = 2", suggestions) - - expect(result1!.fillInAtCursor).not.toBe(result2!.fillInAtCursor) - }) - }) -}) - -describe("shouldShowOnlyFirstLine", () => { - it.each([ - ["\\n", "\nconst y = 2"], - ["\\r\\n", "\r\nconst y = 2"], - ])("returns false when suggestion starts with %s", (_label, suggestion) => { - expect(shouldShowOnlyFirstLine("const x = foo", suggestion)).toBe(false) - }) - - it("returns true when cursor is mid-line (current line has non-whitespace)", () => { - expect(shouldShowOnlyFirstLine("const x = 1\nconst y = foo", "bar\nbaz")).toBe(true) - }) - - it("returns false at start of line when suggestion is 3+ lines but current line has no word characters", () => { - // When current line has only whitespace (no word characters), show the whole block - expect(shouldShowOnlyFirstLine("const x = 1\n ", "l1\nl2\nl3")).toBe(false) - }) - - it("returns true at start of line when suggestion is 3+ lines and current line has word characters", () => { - // When current line has word characters (e.g., partial code), show only first line - expect(shouldShowOnlyFirstLine("const x = 1\n const", "l1\nl2\nl3")).toBe(true) - }) - - it("returns false at start of line when suggestion is 2 lines", () => { - expect(shouldShowOnlyFirstLine("const x = 1\n", "l1\nl2")).toBe(false) - }) - - it("returns false when current line has only non-word characters (e.g., indentation after comma)", () => { - // Simulates typing comma + Enter in an array of objects, then getting a multi-line suggestion - // The prefix ends with whitespace-only line (indentation), suggestion is the next object block - const prefix = "const items = [\n\t{ name: 'first' },\n\t" - const suggestion = "{ name: 'second' },\n\t{ name: 'third' }," - expect(shouldShowOnlyFirstLine(prefix, suggestion)).toBe(false) - }) -}) - -describe("countLines", () => { - it("returns 0 for empty string", () => { - expect(countLines("")).toBe(0) - }) - - it("counts mixed \\n and \\r\\n correctly", () => { - expect(countLines("l1\nl2\r\nl3")).toBe(3) - }) - - it("does not count trailing newline as an additional line", () => { - expect(countLines("l1\nl2\n")).toBe(2) - }) -}) - -describe("getFirstLine", () => { - it("returns the entire text when there is no newline", () => { - expect(getFirstLine("console.log('test');")).toBe("console.log('test');") - }) - - it.each([ - ["\\n", "first line\nsecond line"], - ["\\r\\n", "first line\r\nsecond line"], - ])("returns first line for %s line endings", (_label, text) => { - expect(getFirstLine(text)).toBe("first line") - }) - - it("returns empty string when text starts with a newline", () => { - expect(getFirstLine("\nsecond line")).toBe("") - }) -}) - -describe("applyFirstLineOnly", () => { - it("returns null when input is null", () => { - expect(applyFirstLineOnly(null, "const x = foo")).toBeNull() - }) - - it("returns result unchanged when text is empty", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "", - prefix: "const x = ", - suffix: ";", - } - const input = { text: "", matchType: "exact" as const, fillInAtCursor } - const result = applyFirstLineOnly(input, "const x = foo") - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("exact") - }) - - it("truncates to first line and preserves matchType when enabled", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "line1\nline2\nline3", - prefix: "const x = ", - suffix: ";", - } - const result = applyFirstLineOnly( - { - text: "line1\nline2\nline3", - matchType: "partial_typing", - fillInAtCursor, - }, - "const x = foo", - ) - expect(result).not.toBeNull() - expect(result!.text).toBe("line1") - expect(result!.matchType).toBe("partial_typing") - }) - - it("preserves original fillInAtCursor when truncating (for consistent telemetry keys)", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "line1\nline2\nline3", - prefix: "const x = ", - suffix: ";", - } - const result = applyFirstLineOnly( - { - text: "line1\nline2\nline3", - matchType: "exact", - fillInAtCursor, - }, - "const x = foo", // mid-line prefix triggers truncation - ) - expect(result).not.toBeNull() - expect(result!.text).toBe("line1") - // The fillInAtCursor should remain unchanged for consistent telemetry tracking - // (same suggestion should generate same key regardless of truncation) - expect(result!.fillInAtCursor.text).toBe("line1\nline2\nline3") - // prefix and suffix should be preserved - expect(result!.fillInAtCursor.prefix).toBe("const x = ") - expect(result!.fillInAtCursor.suffix).toBe(";") - }) - - it("preserves fillInAtCursor when no truncation occurs", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "singleLine", - prefix: "prefix", - suffix: "suffix", - } - const result = applyFirstLineOnly( - { - text: "singleLine", - matchType: "exact", - fillInAtCursor, - }, - "const x = foo", // mid-line but single-line suggestion - ) - expect(result).not.toBeNull() - expect(result!.text).toBe("singleLine") - // fillInAtCursor should be unchanged since no truncation happened - expect(result!.fillInAtCursor).toStrictEqual(fillInAtCursor) - }) - - it("does not truncate when suggestion starts with newline", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "\nline1\nline2", - prefix: "const x = ", - suffix: ";", - } - const result = applyFirstLineOnly({ text: "\nline1\nline2", matchType: "exact", fillInAtCursor }, "const x = foo") - expect(result).not.toBeNull() - expect(result!.text).toBe("\nline1\nline2") - expect(result!.matchType).toBe("exact") - }) -}) - -describe("stringToInlineCompletions", () => { - it("should return empty array when text is empty string", () => { - const position = new vscode.Position(0, 10) - const result = stringToInlineCompletions("", position) - - expect(result).toEqual([]) - }) - - it("should return inline completion item when text is non-empty", () => { - const position = new vscode.Position(0, 10) - const text = "console.log('test');" - const result = stringToInlineCompletions(text, position) - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe(text) - expect(result[0].range).toEqual(new vscode.Range(position, position)) - }) - - it("should create range at the specified position", () => { - const position = new vscode.Position(5, 20) - const text = "some code" - const result = stringToInlineCompletions(text, position) - - expect(result[0].range).toEqual(new vscode.Range(position, position)) - }) - - it("should handle multi-line text", () => { - const position = new vscode.Position(0, 0) - const text = "line1\nline2\nline3" - const result = stringToInlineCompletions(text, position) - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe(text) - }) -}) - -describe("AutocompleteInlineCompletionProvider", () => { - let provider: AutocompleteInlineCompletionProvider - let mockDocument: vscode.TextDocument - let mockPosition: vscode.Position - let mockContext: vscode.InlineCompletionContext - let mockToken: vscode.CancellationToken - let mockModel: AutocompleteModel - let mockCostTrackingCallback: CostTrackingCallback - let mockSettings: { enableAutoTrigger: boolean } | null - let mockExtensionContext: vscode.ExtensionContext - let mockClineProvider: { cwd: string } - let mockTelemetry: AutocompleteTelemetry - - // Helper to call provideInlineCompletionItems and advance timers - // With leading edge debounce, first call executes immediately, subsequent calls wait for 300ms of inactivity - async function provideWithDebounce( - doc: vscode.TextDocument, - pos: vscode.Position, - ctx: vscode.InlineCompletionContext, - token: vscode.CancellationToken, - ) { - const promise = provider.provideInlineCompletionItems(doc, pos, ctx, token) - await vi.advanceTimersByTimeAsync(300) // Advance past debounce delay for any pending calls - return promise - } - - beforeEach(() => { - vi.useFakeTimers() - mockDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - mockPosition = new vscode.Position(0, 11) // After "const x = 1" - mockContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Invoke, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - mockToken = {} as vscode.CancellationToken - mockSettings = { enableAutoTrigger: true } - - // Create mock extension context - mockExtensionContext = { - subscriptions: [], - workspaceState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn().mockReturnValue([]), - }, - globalState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn().mockReturnValue([]), - setKeysForSync: vi.fn(), - }, - extensionPath: "/mock/extension/path", - extensionUri: vscode.Uri.file("/mock/extension/path"), - storagePath: "/mock/storage/path", - storageUri: vscode.Uri.file("/mock/storage/path"), - globalStoragePath: "/mock/global/storage/path", - globalStorageUri: vscode.Uri.file("/mock/global/storage/path"), - logPath: "/mock/log/path", - logUri: vscode.Uri.file("/mock/log/path"), - extensionMode: 1, // Development - asAbsolutePath: vi.fn((relativePath: string) => `/mock/extension/path/${relativePath}`), - secrets: { - get: vi.fn(), - store: vi.fn(), - delete: vi.fn(), - onDidChange: vi.fn(), - }, - environmentVariableCollection: {} as any, - extension: {} as any, - languageModelAccessInformation: {} as any, - } as unknown as vscode.ExtensionContext - - vi.spyOn(AutocompleteContextProviderModule, "getProcessedSnippets").mockResolvedValue({ - filepathUri: "file:///test.ts", - helper: { - filepath: "file:///test.ts", - lang: { name: "typescript", singleLineComment: "//" }, - prunedPrefix: "const x = 1", - prunedSuffix: "\nconst y = 2", - }, - snippetsWithUris: [], - workspaceDirs: [], - }) - - // Create mock dependencies - mockModel = { - generateFimResponse: vi.fn().mockResolvedValue({ - cost: 0, - inputTokens: 0, - outputTokens: 0, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }), - getModelName: vi.fn().mockReturnValue("test-model"), - getProviderDisplayName: vi.fn().mockReturnValue("test-provider"), - hasValidCredentials: vi.fn().mockReturnValue(true), // Default to true for tests - } as unknown as AutocompleteModel - mockCostTrackingCallback = vi.fn() as CostTrackingCallback - mockClineProvider = { cwd: "/test/workspace" } - mockTelemetry = new AutocompleteTelemetry() - - provider = new AutocompleteInlineCompletionProvider( - mockExtensionContext, - mockModel, - mockCostTrackingCallback, - () => mockSettings, - mockClineProvider as any, - mockTelemetry, - ) - }) - - afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - }) - - describe("provideInlineCompletionItems", () => { - it("should return empty array when no suggestions are set", async () => { - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result).toHaveLength(0) - }) - - it("should return empty array when suggestions have no FIM content", async () => { - provider.updateSuggestions({ - text: "", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should return inline completion item when FIM content is available and prefix/suffix match", async () => { - const fimContent = { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - } - provider.updateSuggestions(fimContent) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe(fimContent.text) - expect(result[0].range).toEqual(new vscode.Range(mockPosition, mockPosition)) - // Command is attached to track acceptance telemetry - expect(result[0].command).toEqual({ - command: "kilocode.autocomplete.inline-completion.accepted", - title: "Autocomplete Accepted", - }) - }) - - it("should truncate cached multi-line suggestions to first line when cursor is mid-line", async () => { - provider.updateSuggestions({ - text: "line1\nline2\nline3", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("line1") - }) - - it("should return empty array when prefix does not match", async () => { - const fimContent = { - text: "console.log('Hello, World!');", - prefix: "different prefix", - suffix: "\nconst y = 2", - } - provider.updateSuggestions(fimContent) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should return empty array when suffix does not match", async () => { - const fimContent = { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "different suffix", - } - provider.updateSuggestions(fimContent) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should update suggestions when called multiple times", async () => { - provider.updateSuggestions({ - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - let result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("first suggestion") - - provider.updateSuggestions({ - text: "second suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("second suggestion") - }) - it("should maintain a rolling window of suggestions and match from most recent", async () => { - // Add first suggestion - provider.updateSuggestions({ - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add second suggestion with different context - provider.updateSuggestions({ - text: "second suggestion", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }) - - // Should match the first suggestion when context matches - let result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("first suggestion") - - // Should match the second suggestion when context matches - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - result = (await provideWithDebounce( - mockDocument2, - mockPosition2, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("second suggestion") - }) - - it("should prefer most recent matching suggestion when multiple match", async () => { - // Add first suggestion - provider.updateSuggestions({ - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add second suggestion with same context - provider.updateSuggestions({ - text: "second suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Should return the most recent (second) suggestion - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("second suggestion") - }) - - it("should maintain only the last 20 suggestions (FIFO)", async () => { - // Add 25 suggestions - for (let i = 0; i < 25; i++) { - provider.updateSuggestions({ - text: `suggestion ${i}`, - prefix: `const x${i} = 1`, - suffix: `\nconst y${i} = 2`, - }) - } - - // The first 5 suggestions should be removed (0-4) - // Try to match suggestion 0 (should not be found, so LLM is called and returns empty) - const mockDocument0 = new MockTextDocument(vscode.Uri.file("/test0.ts"), "const x0 = 1\nconst y0 = 2") - const mockPosition0 = new vscode.Position(0, 12) - let result = (await provideWithDebounce( - mockDocument0, - mockPosition0, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result).toHaveLength(0) - - // Try to match suggestion 10 (should be found - it's in the middle of the window) - const mockDocument10 = new MockTextDocument(vscode.Uri.file("/test10.ts"), "const x10 = 1\nconst y10 = 2") - const mockPosition10 = new vscode.Position(0, 13) - result = (await provideWithDebounce( - mockDocument10, - mockPosition10, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - // Suggestion 10 should be found (it's in the cache window) - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("suggestion 10") - - // Try to match suggestion 24 (should be found - it's the most recent) - const mockDocument24 = new MockTextDocument(vscode.Uri.file("/test24.ts"), "const x24 = 1\nconst y24 = 2") - const mockPosition24 = new vscode.Position(0, 13) - result = (await provideWithDebounce( - mockDocument24, - mockPosition24, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("suggestion 24") - }) - it("should not add duplicate suggestions", async () => { - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Try to add the same suggestion again - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add a different suggestion - provider.updateSuggestions({ - text: "console.log('different')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Should return the most recent non-duplicate suggestion - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should get the different suggestion (suggestions3), not the duplicate - expect(result[0].insertText).toBe("console.log('different')") - }) - - it("should allow same text with different prefix/suffix", async () => { - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Same text but different context - should be added - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }) - - // Should match the second suggestion when context matches - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const result = (await provideWithDebounce( - mockDocument2, - mockPosition2, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result[0].insertText).toBe("console.log('test')") - }) - - describe("partial typing support", () => { - it("should return remaining suggestion when user has partially typed the suggestion", async () => { - // Set up a suggestion - provider.updateSuggestions({ - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Simulate user typing "cons" after the prefix - const partialDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 2") - const partialPosition = new vscode.Position(0, 15) // After "const x = 1cons" - - const result = (await provideWithDebounce( - partialDocument, - partialPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - // Should return the remaining part after "cons" - expect(result[0].insertText).toBe("ole.log('Hello, World!');") - }) - - it("should return full suggestion when user has typed nothing after prefix", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User is at exact prefix position (no partial typing) - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("console.log('test');") - }) - - it("should return empty when partially typed content does not match suggestion", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed "xyz" which doesn't match the suggestion - const mismatchDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1xyz\nconst y = 2") - const mismatchPosition = new vscode.Position(0, 14) - - const result = (await provideWithDebounce( - mismatchDocument, - mismatchPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should return empty string when user has typed entire suggestion", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User has typed the entire suggestion - cursor is at the end of typed text - // Position 31 is right after the semicolon, before the newline - const completeDocument = new MockTextDocument( - vscode.Uri.file("/test.ts"), - "const x = 1console.log('test');\nconst y = 2", - ) - const completePosition = new vscode.Position(0, 31) // After the semicolon, before newline - - const result = (await provideWithDebounce( - completeDocument, - completePosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array since everything is typed (empty string match) - expect(result).toHaveLength(0) - }) - - it("should not match when suffix has changed", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed partial content but suffix changed - const changedSuffixDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 3") - const changedSuffixPosition = new vscode.Position(0, 15) - - const result = (await provideWithDebounce( - changedSuffixDocument, - changedSuffixPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should prefer exact match over partial match", async () => { - // Add a suggestion that would match partially - provider.updateSuggestions({ - text: "console.log('partial');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add a suggestion with exact match (more recent) - provider.updateSuggestions({ - text: "exact match", - prefix: "const x = 1cons", - suffix: "\nconst y = 2", - }) - - // User is at position that matches exact prefix of second suggestion - const document = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 2") - const position = new vscode.Position(0, 15) - - const result = (await provideWithDebounce( - document, - position, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - // Should return exact match (most recent), not partial - expect(result[0].insertText).toBe("exact match") - }) - - it("should handle multi-character partial typing", async () => { - provider.updateSuggestions({ - text: "function test() { return 42; }", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed "function te" - const partialDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1function te\nconst y = 2") - const partialPosition = new vscode.Position(0, 22) - - const result = (await provideWithDebounce( - partialDocument, - partialPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("st() { return 42; }") - }) - - it("should handle case-sensitive partial matching", async () => { - provider.updateSuggestions({ - text: "Console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed "cons" (lowercase) but suggestion starts with "Console" (uppercase) - const partialDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 2") - const partialPosition = new vscode.Position(0, 15) - - const result = (await provideWithDebounce( - partialDocument, - partialPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should not match due to case difference, so LLM is called and returns empty - expect(result).toHaveLength(0) - }) - }) - - describe("dispose", () => { - it("should clear pending debounce timer when disposed", async () => { - // First call executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Second call should set a debounce timer - const promise = provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Verify timer is set for trailing edge - const timerCountBeforeDispose = vi.getTimerCount() - expect(timerCountBeforeDispose).toBeGreaterThan(0) - - // Dispose the provider before timer fires - provider.dispose() - - // Verify timer is cleared and the pending request settles - const timerCountAfterDispose = vi.getTimerCount() - expect(timerCountAfterDispose).toBeLessThan(timerCountBeforeDispose) - await expect(promise).resolves.toEqual([]) - }) - }) - }) - - describe("updateSuggestions", () => { - it("should accept new suggestions state", async () => { - provider.updateSuggestions({ - text: "new content", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("new content") - }) - }) - - describe("auto-trigger settings", () => { - it("should respect enableAutoTrigger setting when auto-triggered", async () => { - // Set auto-trigger to false - mockSettings = { enableAutoTrigger: false } - - // Change context to automatic trigger - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Should return empty array because auto-trigger is disabled - expect(result).toEqual([]) - // Model should not be called - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should block manual trigger when auto-trigger is disabled (defense in depth)", async () => { - // Set auto-trigger to false - mockSettings = { enableAutoTrigger: false } - - // Manual trigger (Invoke) - const manualContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Invoke, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - const result = await provideWithDebounce(mockDocument, mockPosition, manualContext, mockToken) - - // Should return empty array as defense in depth, even for manual triggers - // The provider should be deregistered at the manager level when disabled - expect(result).toEqual([]) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should read settings dynamically on each call", async () => { - // Start with auto-trigger enabled - mockSettings = { enableAutoTrigger: true } - - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - // First call with auto-trigger enabled - await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Change settings to disable auto-trigger - mockSettings = { enableAutoTrigger: false } - - // Second call should respect the new settings - const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Should not call model again because auto-trigger is now disabled - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - expect(result).toEqual([]) - }) - - it("should handle null settings gracefully", async () => { - // Set settings to null - mockSettings = null - - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Should default to false (disabled) when settings are null - expect(result).toEqual([]) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should allow auto-trigger when explicitly enabled", async () => { - // Set auto-trigger to true - mockSettings = { enableAutoTrigger: true } - - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Model should be called because auto-trigger is enabled - expect(mockModel.generateFimResponse).toHaveBeenCalled() - }) - }) - - describe("failed lookups cache", () => { - it("should cache failed LLM lookups and not call LLM again for same prefix/suffix", async () => { - // Mock the model to return empty suggestions - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - - // First call - should invoke LLM - const result1 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result1).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - expect(mockCostTrackingCallback).toHaveBeenCalledWith(0.01, 100, 50) - - // Second call with same prefix/suffix - should NOT invoke LLM - vi.mocked(mockModel.generateFimResponse).mockClear() - vi.mocked(mockCostTrackingCallback).mockClear() - - const result2 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result2).toHaveLength(0) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - expect(mockCostTrackingCallback).not.toHaveBeenCalled() - }) - - it("should not cache successful LLM lookups in failed cache", async () => { - // Mock the model to return a successful suggestion using proper COMPLETION format - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - // Simulate streaming FIM response - if (onChunk) { - onChunk("console.log('success');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - should invoke LLM and get a suggestion - const result1 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result1.length).toBeGreaterThan(0) - expect(callCount).toBe(1) - - // Second call with same prefix/suffix - should use suggestion cache, not failed cache - const result2 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result2.length).toBeGreaterThan(0) - // Should still be 1 - not called again - expect(callCount).toBe(1) - }) - - it("should cache different prefix/suffix combinations separately", async () => { - // Mock the model to return empty suggestions - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call with first prefix/suffix - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call with different prefix/suffix - should invoke LLM - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - - await provideWithDebounce(mockDocument2, mockPosition2, mockContext, mockToken) - expect(callCount).toBe(2) - - // Third call with first prefix/suffix again - should NOT invoke LLM (cached in failed cache) - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(2) - - // Fourth call with second prefix/suffix again - should NOT invoke LLM (cached in failed cache) - await provideWithDebounce(mockDocument2, mockPosition2, mockContext, mockToken) - expect(callCount).toBe(2) - }) - - it("should maintain only the last 50 failed lookups (FIFO)", async () => { - // Mock the model to return empty suggestions - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0, - inputTokens: 0, - outputTokens: 0, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // Add 55 failed lookups - for (let i = 0; i < 55; i++) { - const doc = new MockTextDocument(vscode.Uri.file(`/test${i}.ts`), `const x${i} = 1\nconst y${i} = 2`) - // Position is after "const x{i} = 1" which is 11 + length of i - const pos = new vscode.Position(0, 11 + i.toString().length) - await provideWithDebounce(doc, pos, mockContext, mockToken) - } - - expect(callCount).toBe(55) - - // The first 5 failed lookups should be removed (0-4) - // Try lookup 0 again - should invoke LLM (not cached anymore) - const doc0 = new MockTextDocument(vscode.Uri.file("/test0.ts"), "const x0 = 1\nconst y0 = 2") - const pos0 = new vscode.Position(0, 12) // After "const x0 = 1" - await provideWithDebounce(doc0, pos0, mockContext, mockToken) - expect(callCount).toBe(56) // Should have been called again - - // Try lookup 5 - should NOT invoke LLM (still cached) - const doc5 = new MockTextDocument(vscode.Uri.file("/test5.ts"), "const x5 = 1\nconst y5 = 2") - const pos5 = new vscode.Position(0, 12) // After "const x5 = 1" - await provideWithDebounce(doc5, pos5, mockContext, mockToken) - // Note: This actually gets called because the exact prefix/suffix combination is slightly different - // due to how positions are calculated, but that's okay - the important thing is that - // entries 0-4 were evicted and entry 5 is still in the cache (even if recalculated) - expect(callCount).toBe(57) - - // Try lookup 54 (most recent) - should NOT invoke LLM (still cached) - const doc54 = new MockTextDocument(vscode.Uri.file("/test54.ts"), "const x54 = 1\nconst y54 = 2") - const pos54 = new vscode.Position(0, 13) // After "const x54 = 1" - await provideWithDebounce(doc54, pos54, mockContext, mockToken) - expect(callCount).toBe(57) // Should not have been called (but gets called due to position mismatch) - }) - - it("should not add duplicate failed lookups", async () => { - // Mock the model to return empty suggestions - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0, - inputTokens: 0, - outputTokens: 0, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - - // First call - adds to failed cache - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Second call - should use cache, not add duplicate - vi.mocked(mockModel.generateFimResponse).mockClear() - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - - // Third call - should still use cache - vi.mocked(mockModel.generateFimResponse).mockClear() - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should return empty result with zero cost when using failed cache", async () => { - // Mock the model to return empty suggestions - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 10, - cacheReadTokens: 20, - }) - - // First call - should invoke LLM - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockCostTrackingCallback).toHaveBeenCalledWith(0.01, 100, 50) - - // Second call - should use failed cache with zero cost - vi.mocked(mockCostTrackingCallback).mockClear() - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockCostTrackingCallback).not.toHaveBeenCalled() - }) - }) - - describe("useless suggestion filtering", () => { - it("should refuse suggestions that match the end of prefix", async () => { - // Mock the model to return a suggestion that matches the end of prefix - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("= 1") // This matches the end of "const x = 1" - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array because the suggestion is useless - expect(result).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should refuse suggestions that match the start of suffix", async () => { - // Mock the model to return a suggestion that matches the start of suffix - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("\nconst") // This matches the start of "\nconst y = 2" - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array because the suggestion is useless - expect(result).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should accept useful suggestions that don't match prefix end or suffix start", async () => { - // Mock the model to return a useful suggestion - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("\nconsole.log('useful');") // Useful suggestion - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return the suggestion because it's useful - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("\nconsole.log('useful');") - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should cache refused suggestions as empty to avoid repeated LLM calls", async () => { - // Mock the model to return a useless suggestion - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("= 1") // Matches end of prefix - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - should invoke LLM and refuse the suggestion - const result1 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result1).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Second call with same prefix/suffix - should use cache, not call LLM - vi.mocked(mockModel.generateFimResponse).mockClear() - const result2 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result2).toHaveLength(0) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - }) - - describe("credentials validation", () => { - it("should return empty array when model has no valid credentials", async () => { - // Set hasValidCredentials to return false - vi.mocked(mockModel.hasValidCredentials).mockReturnValue(false) - - // Set up a suggestion that would normally be returned - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array because credentials are not valid - expect(result).toHaveLength(0) - // Model should not be called - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should not attempt an LLM call if credentials become invalid before the debounced fetch executes", async () => { - // First call executes immediately (leading edge) - just to move provider into debounced mode - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Second call will be debounced. Simulate a model reload happening before the timer fires. - vi.mocked(mockModel.hasValidCredentials).mockReturnValue(false) - - const promise = provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Let the trailing-edge debounce fire - await vi.advanceTimersByTimeAsync(300) - await promise - - // If fetch-time validation is working, we do not call generateFimResponse again. - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should return suggestions when model has valid credentials", async () => { - // Ensure hasValidCredentials returns true - vi.mocked(mockModel.hasValidCredentials).mockReturnValue(true) - - // Set up a suggestion - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return the suggestion because credentials are valid - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("console.log('test');") - }) - }) - - describe("untitled document handling", () => { - it("should provide completions for untitled documents", async () => { - // Create an untitled document using MockTextDocument - const untitledDocument = new MockTextDocument(vscode.Uri.parse("untitled:Untitled-1"), "const x = 1\nconst y = 2") - // Override isUntitled property - Object.defineProperty(untitledDocument, "isUntitled", { - value: true, - writable: false, - }) - - // Set up a suggestion - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - untitledDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return the completion because untitled documents are always allowed - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("console.log('test');") - }) - }) - - describe("pending request reuse", () => { - it("should reuse pending request when user types forward (prefix extends, suffix unchanged)", async () => { - // Mock the model to track call count - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: user at "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) // After "const x = 1" - - // Start first request but don't await it yet - const promise1 = provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - - // Advance time partially (not past debounce) - await vi.advanceTimersByTimeAsync(100) - - // Second request: user typed "c" - prefix extended, suffix unchanged - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1c\nconst y = 2") - const pos2 = new vscode.Position(0, 12) // After "const x = 1c" - - // Start second request - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // Advance time past debounce to let requests complete - await vi.advanceTimersByTimeAsync(500) - - // Wait for both promises - await promise1 - await promise2 - - // The model should only have been called once because the second request - // should have reused the pending request from the first - expect(callCount).toBe(1) - }) - - it("should reuse slow leading-edge request when user types forward before it completes", async () => { - // Mock the model with a slow response that takes 500ms - let callCount = 0 - let resolvers: Array<() => void> = [] - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - // Simulate a slow FIM response — wait for manual resolution - await new Promise((resolve) => { - resolvers.push(resolve) - }) - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: leading edge fires immediately for "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) - const promise1 = provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - - // Leading edge should have started the request immediately - expect(callCount).toBe(1) - - // User types "c" while the leading-edge request is still in-flight - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1c\nconst y = 2") - const pos2 = new vscode.Position(0, 12) - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // The second request should NOT have triggered a new FIM call — - // it should reuse the leading-edge pending request since the prefix - // extends and the suffix is unchanged - expect(callCount).toBe(1) - - // Now resolve the FIM response - resolvers[0]() - await vi.advanceTimersByTimeAsync(500) - - await promise1 - await promise2 - - // Only one FIM request should have been made total - expect(callCount).toBe(1) - }) - - it("should NOT reuse pending request when suffix changes", async () => { - // Mock the model to track call count - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: user at "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) - - // Start first request - with leading edge, this executes immediately - await provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - expect(callCount).toBe(1) // First call executed immediately (leading edge) - - // Second request: suffix changed (different text after cursor) - // This cannot reuse the first request because suffix changed - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst z = 3") - const pos2 = new vscode.Position(0, 11) - - // Start second request - this will be debounced (not leading edge anymore) - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance time past debounce to let the second request complete - await vi.advanceTimersByTimeAsync(300) - await promise2 - - // The model should have been called twice: - // 1. First request executed immediately (leading edge) - // 2. Second request executed after debounce (different suffix, couldn't reuse) - // The key point is that the second request was NOT reused from the first - // because the suffix changed - it started a new debounce cycle - expect(callCount).toBe(2) - }) - - it("should settle canceled debounced requests when the timer is reset", async () => { - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst z = 3") - const promise2 = provider.provideInlineCompletionItems(doc2, mockPosition, mockContext, mockToken) - - await vi.advanceTimersByTimeAsync(100) - - const doc3 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst q = 4") - const promise3 = provider.provideInlineCompletionItems(doc3, mockPosition, mockContext, mockToken) - - await expect(promise2).resolves.toEqual([]) - - await vi.advanceTimersByTimeAsync(500) - await promise3 - }) - - it("should NOT reuse pending request when user backspaces (prefix shrinks)", async () => { - // Mock the model to track call count - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: user at "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) - - // Start first request (don't await - it will be cancelled by the second request) - provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - - // Advance time partially (not past debounce) - await vi.advanceTimersByTimeAsync(100) - - // Second request: user backspaced - prefix is now shorter - // This will cancel the first request's debounce timer and start a new one - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = \nconst y = 2") - const pos2 = new vscode.Position(0, 10) // After "const x = " - - // Start second request - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // Advance time past debounce to let the second request complete - await vi.advanceTimersByTimeAsync(500) - - await promise2 - - // The model should have been called once (only the second request fires, - // the first was cancelled by the debounce) - // But the key point is that the second request was NOT reused from the first - // because the prefix shrunk - it started a new debounce cycle - expect(callCount).toBe(1) - }) - }) - - describe("debounce with leading edge behavior", () => { - it("should execute immediately on first call (leading edge)", async () => { - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - - // First call should execute immediately without waiting - const promise = provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Model should be called immediately (no timer needed) - await promise - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should debounce subsequent calls (wait for 300ms of inactivity)", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call immediately after - should be debounced - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance time past debounce delay - await vi.advanceTimersByTimeAsync(300) - await promise2 - - // Now it should have been called - expect(callCount).toBe(2) - }) - - it("should reset debounce timer on each call (only execute after 300ms of inactivity)", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Multiple rapid calls - each resets the debounce timer - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - - const mockDocument3 = new MockTextDocument(vscode.Uri.file("/test3.ts"), "const c = 1\nconst d = 2") - const mockPosition3 = new vscode.Position(0, 11) - - // First debounced call - provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - expect(callCount).toBe(1) - - // Advance 150ms (half the debounce time) - await vi.advanceTimersByTimeAsync(150) - expect(callCount).toBe(1) - - // Second debounced call - resets the timer - const promise3 = provider.provideInlineCompletionItems(mockDocument3, mockPosition3, mockContext, mockToken) - expect(callCount).toBe(1) - - // Advance another 150ms (total 300ms from first debounced call, but only 150ms from second) - await vi.advanceTimersByTimeAsync(150) - expect(callCount).toBe(1) // Still not called because timer was reset - - // Advance remaining 150ms to complete the debounce from the last call - await vi.advanceTimersByTimeAsync(150) - await promise3 - expect(callCount).toBe(2) // Now it should be called - }) - - it("should allow immediate execution after debounce completes (new leading edge)", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call - debounced - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Wait for debounce to complete - await vi.advanceTimersByTimeAsync(300) - await promise2 - expect(callCount).toBe(2) - - // Third call after debounce completed - should execute immediately (new leading edge) - const mockDocument3 = new MockTextDocument(vscode.Uri.file("/test3.ts"), "const c = 1\nconst d = 2") - const mockPosition3 = new vscode.Position(0, 11) - await provider.provideInlineCompletionItems(mockDocument3, mockPosition3, mockContext, mockToken) - - // Should have executed immediately without waiting - expect(callCount).toBe(3) - }) - }) - - describe("adaptive debounce delay", () => { - it("should start with initial debounce delay of 300ms", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call - should be debounced with initial 300ms delay - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance 200ms - should still be waiting - await vi.advanceTimersByTimeAsync(200) - expect(callCount).toBe(1) - - // Advance remaining 100ms to complete the 300ms debounce - await vi.advanceTimersByTimeAsync(100) - await promise2 - expect(callCount).toBe(2) - }) - - it("should record latency and not update debounce delay until 10 samples collected", () => { - // Record 9 latencies - should not update debounce delay yet - for (let i = 0; i < 9; i++) { - provider.recordLatency(100 + i * 10) // 100, 110, 120, ..., 180 - } - - // Access private field via any cast for testing - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(9) - expect(providerAny.debounceDelayMs).toBe(300) // Still initial value - }) - - it("should update debounce delay to average after exceeding 10 samples", () => { - // Record 10 latencies of 200ms each - debounce delay not updated yet - for (let i = 0; i < 10; i++) { - provider.recordLatency(200) - } - - // Access private field via any cast for testing - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(10) - expect(providerAny.debounceDelayMs).toBe(300) // Still initial value (not updated until > 10) - - // Record 11th latency - now debounce delay is updated - provider.recordLatency(200) - expect(providerAny.latencyHistory.length).toBe(10) // Still 10 (oldest removed) - expect(providerAny.debounceDelayMs).toBe(200) // Now updated to average - }) - - it("should maintain rolling window of 10 latencies", () => { - // Record 15 latencies - for (let i = 0; i < 15; i++) { - provider.recordLatency(100 + i * 10) // 100, 110, 120, ..., 240 - } - - // Access private field via any cast for testing - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(10) // Only last 10 kept - - // Last 10 values should be 150, 160, 170, 180, 190, 200, 210, 220, 230, 240 - // Average = (150+160+170+180+190+200+210+220+230+240) / 10 = 195 - expect(providerAny.debounceDelayMs).toBe(195) - }) - - it("should update debounce delay on each new latency after exceeding 10 samples", () => { - // Record 11 latencies of 200ms each (need > 10 to trigger update) - for (let i = 0; i < 11; i++) { - provider.recordLatency(200) - } - - const providerAny = provider as any - expect(providerAny.debounceDelayMs).toBe(200) - - // Add one more latency of 300ms - // New average = (200*9 + 300) / 10 = 210 - provider.recordLatency(300) - expect(providerAny.debounceDelayMs).toBe(210) - - // Add another latency of 400ms - // New average = (200*8 + 300 + 400) / 10 = 230 - provider.recordLatency(400) - expect(providerAny.debounceDelayMs).toBe(230) - }) - - it("should use adaptive debounce delay after collecting enough samples", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // Record 11 latencies of 150ms each to set debounce delay to 150ms - // (need > 10 to trigger update) - for (let i = 0; i < 11; i++) { - provider.recordLatency(150) - } - - const providerAny = provider as any - expect(providerAny.debounceDelayMs).toBe(150) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call - should be debounced with adaptive 150ms delay - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance 100ms - should still be waiting (150ms debounce) - await vi.advanceTimersByTimeAsync(100) - expect(callCount).toBe(1) - - // Advance remaining 50ms to complete the 150ms debounce - await vi.advanceTimersByTimeAsync(50) - await promise2 - expect(callCount).toBe(2) - }) - - it("should record latency from LLM requests", async () => { - // Mock the model to simulate a delay - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - // Simulate some processing time - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(0) - - // Make a request that will record latency - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Latency should have been recorded - expect(providerAny.latencyHistory.length).toBe(1) - }) - }) - - describe("telemetry tracking", () => { - it("should track acceptance when suggestion is accepted via command", async () => { - // Capture the registered command callback by setting up mock before provider creation - let acceptCallback: (() => void) | undefined - const originalMock = vi.mocked(vscode.commands.registerCommand) - originalMock.mockImplementation((cmd, callback) => { - if (cmd === "kilocode.autocomplete.inline-completion.accepted") { - acceptCallback = callback as () => void - } - return { dispose: vi.fn() } - }) - - // Create new telemetry instance for this test - const testTelemetry = new AutocompleteTelemetry() - vi.spyOn(testTelemetry, "captureAcceptSuggestion") - - // Create new provider to capture the command - const testProvider = new AutocompleteInlineCompletionProvider( - mockExtensionContext, - mockModel, - mockCostTrackingCallback, - () => mockSettings, - mockClineProvider as any, - testTelemetry, - ) - - // Verify callback was captured - expect(acceptCallback).toBeDefined() - - // Set up and show a suggestion - testProvider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Call provideInlineCompletionItems to trigger trackSuggestionShown - const promise = testProvider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - await vi.advanceTimersByTimeAsync(300) - const result = await promise - - // Verify we got a suggestion (which means trackSuggestionShown was called) - expect(Array.isArray(result) ? result.length : 0).toBeGreaterThan(0) - - // Simulate accepting the suggestion - acceptCallback!() - - expect(testTelemetry.captureAcceptSuggestion).toHaveBeenCalled() - - // Cleanup - testProvider.dispose() - }) - - it("should work without telemetry when null is passed", async () => { - // Create provider without telemetry - const testProvider = new AutocompleteInlineCompletionProvider( - mockExtensionContext, - mockModel, - mockCostTrackingCallback, - () => mockSettings, - mockClineProvider as any, - null, - ) - - // Set up a suggestion - testProvider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Should work without errors - const promise = testProvider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - await vi.advanceTimersByTimeAsync(300) - const result = await promise - - // Should still return suggestions - expect(Array.isArray(result) ? result.length : 0).toBeGreaterThan(0) - - // Cleanup - testProvider.dispose() - }) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts index b2cd98dbfce..0e0902e2371 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts @@ -47,7 +47,7 @@ describe("postprocessAutocompleteSuggestion", () => { suggestion: "const x = 42", prefix: "const x = ", suffix: "", - model: "inception/mercury-edit", + model: "inception/mercury-edit-2", }) expect(result).toBe("42") }) 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 b82d06d4abd..68b874a0b17 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 @@ -7,7 +7,6 @@ import { getAllSnippetsWithoutRace } from "../continuedev/core/autocomplete/snip 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 { AutocompleteModel } from "../AutocompleteModel" import { FileIgnoreController } from "../shims/FileIgnoreController" import { AutocompleteSnippet, AutocompleteSnippetType } from "../continuedev/core/autocomplete/types" @@ -68,7 +67,7 @@ export async function getProcessedSnippets( autocompleteInput: AutocompleteInput, filepath: string, contextService: ContextRetrievalService, - model: AutocompleteModel, + modelId: string, ide: VsCodeIde, ignoreController?: Promise, ): Promise<{ @@ -92,7 +91,7 @@ export async function getProcessedSnippets( filepath: filepathUri, } - const modelName = model.getModelName() ?? "codestral" + const modelName = modelId || "codestral" const helper = await HelperVars.create(helperInput as any, DEFAULT_AUTOCOMPLETE_OPTS, modelName, ide) const snippetPayload = await getAllSnippetsWithoutRace({ 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 93c4de15e63..229fdbf5e35 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 @@ -1,6 +1,6 @@ // Fill in the middle prompts // -// We only expose Codestral and Mercury Edit as autocomplete models — every +// We only expose Codestral and Mercury Edit 2 as autocomplete models — every // other FIM template in the upstream continuedev list is unreachable. import { CompletionOptions } from "../../index.js" diff --git a/packages/kilo-vscode/src/services/autocomplete/fim.ts b/packages/kilo-vscode/src/services/autocomplete/fim.ts new file mode 100644 index 00000000000..8e5fcb429c6 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/fim.ts @@ -0,0 +1,77 @@ +import { ResponseMetaData } from "./types" +import type { KiloConnectionService } from "../cli-backend" +import { getAutocompleteModel } from "../../shared/autocomplete-models" + +/** + * Generate a FIM (Fill-in-the-Middle) completion via the CLI backend. + * Uses the SDK's kilo.fim() SSE endpoint which handles auth and streaming. + * + * @param signal - Optional AbortSignal to cancel the SSE stream early (e.g. when the user types again) + */ +export async function generateFim( + connectionService: KiloConnectionService, + modelId: string, + prefix: string, + suffix: string, + onChunk: (text: string) => void, + signal?: AbortSignal, +): Promise { + const client = await connectionService.getClientAsync() + + let cost = 0 + let inputTokens = 0 + let outputTokens = 0 + + // Capture SSE-level errors so they propagate to the caller. The SDK's SSE + // client catches HTTP errors (402, 401, 429, 5xx) internally and silently + // ends the stream. Without this, errors never reach ErrorBackoff. + let sseError: Error | undefined + + const temp = getAutocompleteModel(modelId).temperature + + const { stream } = await client.kilo.fim( + { + prefix, + suffix, + model: modelId, + maxTokens: 256, + temperature: temp, + }, + { + signal, + sseMaxRetryAttempts: 1, + onSseError: (error) => { + sseError = error instanceof Error ? error : new Error(String(error)) + }, + }, + ) + + for await (const chunk of stream) { + const choice = chunk.choices?.[0] + const content = choice?.delta?.content ?? choice?.text + if (content) onChunk(content) + if (chunk.usage) { + inputTokens = chunk.usage.prompt_tokens ?? 0 + outputTokens = chunk.usage.completion_tokens ?? 0 + } + if (chunk.cost !== undefined) cost = chunk.cost + } + + if (sseError) throw sseError + + return { + cost, + inputTokens, + outputTokens, + cacheWriteTokens: 0, + cacheReadTokens: 0, + } +} + +/** + * Check if the CLI backend is connected. The CLI manages credentials internally, + * so a connected state means we can issue FIM requests. + */ +export function hasValidCredentials(connectionService: KiloConnectionService): boolean { + return connectionService.getConnectionState() === "connected" +} diff --git a/packages/kilo-vscode/src/services/autocomplete/settings.ts b/packages/kilo-vscode/src/services/autocomplete/settings.ts index 029d4921291..9ec341406dc 100644 --- a/packages/kilo-vscode/src/services/autocomplete/settings.ts +++ b/packages/kilo-vscode/src/services/autocomplete/settings.ts @@ -1,12 +1,8 @@ import * as vscode from "vscode" import { AUTOCOMPLETE_MODELS, getAutocompleteModel } from "../../shared/autocomplete-models" -const keys = new Set(["enableAutoTrigger", "enableSmartInlineTaskKeybinding", "enableChatAutocomplete", "model"]) - type Message = { type: string - key?: unknown - value?: unknown } type Post = (msg: unknown) => void @@ -17,13 +13,6 @@ export async function routeAutocompleteMessage(message: Message, post: Post): Pr return true } - if (message.type === "updateAutocompleteSetting") { - if (await update(message.key, message.value)) { - post(buildAutocompleteSettingsMessage()) - } - return true - } - return false } @@ -49,23 +38,15 @@ export function watchAutocompleteConfig(post: Post): vscode.Disposable { }) } -async function update(key: unknown, value: unknown) { - if (typeof key !== "string") return false - if (!keys.has(key)) return false - if (!valid(key, value)) return false - - await vscode.workspace - .getConfiguration("kilo-code.new.autocomplete") - .update(key, value, vscode.ConfigurationTarget.Global) - - return true -} - -function valid(key: string, value: unknown) { +export function validAutocompleteSetting(key: string, value: unknown) { if (key === "model") { if (typeof value !== "string") return false return AUTOCOMPLETE_MODELS.some((m) => m.id === value) } - return typeof value === "boolean" + if (key === "enableAutoTrigger") return typeof value === "boolean" + if (key === "enableSmartInlineTaskKeybinding") return typeof value === "boolean" + if (key === "enableChatAutocomplete") return typeof value === "boolean" + + return false } diff --git a/packages/kilo-vscode/src/services/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/types.ts index 1fddf8b01e8..765f1d6f4b9 100644 --- a/packages/kilo-vscode/src/services/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/types.ts @@ -4,7 +4,6 @@ import type { Position, Range, RangeInFile } from "./continuedev/core" import type { FileIgnoreController } from "./shims/FileIgnoreController" import type { ContextRetrievalService } from "./continuedev/core/autocomplete/context/ContextRetrievalService" import type { VsCodeIde } from "./continuedev/core/vscode-test-harness/src/VSCodeIde" -import type { AutocompleteModel } from "./AutocompleteModel" export interface ResponseMetaData { cost: number @@ -81,7 +80,6 @@ export interface AutocompleteStatusBarStateProps { snoozed?: boolean model?: string provider?: string - profileName?: string | null hasNoUsableProvider?: boolean totalSessionCost: number completionCount: number @@ -212,6 +210,6 @@ export function contextToAutocompleteInput(context: AutocompleteSuggestionContex export interface AutocompleteContextProvider { contextService: ContextRetrievalService ide: VsCodeIde - model: AutocompleteModel + modelId: string ignoreController?: Promise } diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts new file mode 100644 index 00000000000..29b53f093da --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -0,0 +1,39 @@ +import * as fs from "fs" +import * as path from "path" + +const dir = "tree-sitter" +const runtime = "tree-sitter.wasm" + +function paths(file: string) { + if (/^[a-z]:[\\/]/i.test(file) || file.includes("\\")) return path.win32 + return path +} + +export function treeSitterDirForBinary(file: string): string { + const p = paths(file) + return p.join(p.dirname(file), dir) +} + +export function treeSitterDirForExtension(root: string): string { + return paths(root).join(root, "bin", dir) +} + +export function resolveTreeSitterEnv(root: string): Record { + return { KILO_TREE_SITTER_WASM_DIR: treeSitterDirForExtension(root) } +} + +export function hasTreeSitterResources(file: string): boolean { + return fs.existsSync(path.join(treeSitterDirForBinary(file), runtime)) +} + +export async function copyTreeSitterResources(source: string, target: string): Promise { + const from = treeSitterDirForBinary(source) + const to = treeSitterDirForBinary(target) + + if (!fs.existsSync(path.join(from, runtime))) { + throw new Error(`CLI tree-sitter resources not found at ${from}`) + } + + await fs.promises.rm(to, { recursive: true, force: true }) + await fs.promises.cp(from, to, { recursive: true }) +} 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 17b4137d7fd..40a132e8eaa 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -4,6 +4,7 @@ import * as crypto from "crypto" import * as fs from "fs" import * as path from "path" import * as vscode from "vscode" +import { resolveTreeSitterEnv } from "./cli-resources" import { t } from "./i18n" import { parseServerPort } from "./server-utils" @@ -104,6 +105,12 @@ export class ServerManager { ...(extraCaCerts && { NODE_EXTRA_CA_CERTS: extraCaCerts }), ...(!proxyStrictSSL && { NODE_TLS_REJECT_UNAUTHORIZED: "0" }), ...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 + // HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars that Bun's fetch and + // most HTTP clients already respect. + ...buildProxyEnv(), // Force mimalloc (the allocator Bun ships with) to return freed pages // to the OS immediately instead of retaining them in its arenas. // Without this, Bun.spawn's piped stdio accumulates ~2 MB of native @@ -125,6 +132,7 @@ export class ServerManager { KILO_VSCODE_VERSION: vscode.version, KILOCODE_EDITOR_NAME: `${vscode.env.appName} ${vscode.version}`, ...(!claudeCompat && { KILO_DISABLE_CLAUDE_CODE: "true" }), + ...resolveTreeSitterEnv(this.context.extensionPath), }, stdio: ["ignore", "pipe", "pipe"], detached: true, @@ -257,6 +265,72 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, "") } +/** + * Translate VS Code's `http.proxy` / `http.noProxy` / `http.proxySupport` + * settings into the standard proxy env vars, so the spawned CLI honors the + * user's proxy configuration. Returns an empty object when no override is + * needed, so callers can spread unconditionally. + * + * `http.proxySupport: "off"` is VS Code's opt-in way to disable proxy support + * entirely; when set, we explicitly clear the env vars so ambient shell + * HTTP_PROXY/http_proxy doesn't leak into the spawned child. + */ +export function buildProxyEnv(): Record { + const httpConfig = vscode.workspace.getConfiguration("http") + const proxyInfo = httpConfig.inspect("proxy") + const noProxyInfo = httpConfig.inspect("noProxy") + const proxySupport = httpConfig.get("proxySupport") + + if (proxySupport === "off") { + return { HTTP_PROXY: "", HTTPS_PROXY: "", NO_PROXY: "", http_proxy: "", https_proxy: "", no_proxy: "" } + } + + const proxy = httpConfig.get("proxy") + const noProxy = httpConfig.get("noProxy") + const proxySet = + proxyInfo !== undefined && + [ + proxyInfo.globalValue, + proxyInfo.workspaceValue, + proxyInfo.workspaceFolderValue, + proxyInfo.globalLanguageValue, + proxyInfo.workspaceLanguageValue, + proxyInfo.workspaceFolderLanguageValue, + ].some((value) => value !== undefined) + const noProxySet = + noProxyInfo !== undefined && + [ + noProxyInfo.globalValue, + noProxyInfo.workspaceValue, + noProxyInfo.workspaceFolderValue, + noProxyInfo.globalLanguageValue, + noProxyInfo.workspaceLanguageValue, + noProxyInfo.workspaceFolderLanguageValue, + ].some((value) => value !== undefined) + const env: Record = {} + if (proxy && proxy.trim() !== "") { + env.HTTP_PROXY = proxy + env.HTTPS_PROXY = proxy + env.http_proxy = proxy + env.https_proxy = proxy + } + if (proxySet && proxy !== undefined && proxy.trim() === "") { + env.HTTP_PROXY = "" + env.HTTPS_PROXY = "" + env.http_proxy = "" + env.https_proxy = "" + } + if (Array.isArray(noProxy) && noProxy.length > 0) { + env.NO_PROXY = noProxy.join(",") + env.no_proxy = noProxy.join(",") + } + if (noProxySet && Array.isArray(noProxy) && noProxy.length === 0) { + env.NO_PROXY = "" + env.no_proxy = "" + } + return env +} + export function toErrorMessage( error: string, stderrLines: string[], diff --git a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index 832cbc419a4..79114ac578a 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -10,9 +10,13 @@ export function registerCodeActions( agentManager?: AgentManagerProvider, ): void { const target = () => (agentManager?.isActive() ? agentManager : provider) + const reveal = async () => { + await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await provider.waitForReady() + } context.subscriptions.push( - vscode.commands.registerCommand("kilo-code.new.explainCode", () => { + vscode.commands.registerCommand("kilo-code.new.explainCode", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("EXPLAIN", { @@ -22,10 +26,11 @@ export function registerCodeActions( selectedText: ctx.selectedText, userInput: "", }) + await reveal() provider.postMessage({ type: "triggerTask", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.fixCode", () => { + vscode.commands.registerCommand("kilo-code.new.fixCode", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("FIX", { @@ -36,10 +41,11 @@ export function registerCodeActions( diagnostics: ctx.diagnostics, userInput: "", }) + await reveal() provider.postMessage({ type: "triggerTask", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.improveCode", () => { + vscode.commands.registerCommand("kilo-code.new.improveCode", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("IMPROVE", { @@ -49,10 +55,11 @@ export function registerCodeActions( selectedText: ctx.selectedText, userInput: "", }) + await reveal() provider.postMessage({ type: "triggerTask", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.addToContext", () => { + vscode.commands.registerCommand("kilo-code.new.addToContext", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("ADD_TO_CONTEXT", { @@ -61,11 +68,19 @@ export function registerCodeActions( endLine: String(ctx.endLine), selectedText: ctx.selectedText, }) - target().postMessage({ type: "appendChatBoxMessage", text: prompt }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "appendChatBoxMessage", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => { - target().postMessage({ type: "action", action: "focusInput" }) + vscode.commands.registerCommand("kilo-code.new.focusChatInput", async () => { + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "action", action: "focusInput" }) }), ) } diff --git a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts index b04a7db5a95..f0d2c89dc4e 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts @@ -10,6 +10,10 @@ export function registerTerminalActions( agentManager?: AgentManagerProvider, ): void { const target = () => (agentManager?.isActive() ? agentManager : provider) + const reveal = async () => { + await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await provider.waitForReady() + } context.subscriptions.push( vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", async (args: any) => { @@ -25,8 +29,12 @@ export function registerTerminalActions( terminalContent: content, userInput: "", }) - target().postMessage({ type: "appendChatBoxMessage", text: prompt }) - target().postMessage({ type: "action", action: "focusInput" }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "appendChatBoxMessage", text: prompt }) + view.postMessage({ type: "action", action: "focusInput" }) }), vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", async (args: any) => { @@ -42,7 +50,11 @@ export function registerTerminalActions( terminalContent: content, userInput: "", }) - target().postMessage({ type: "triggerTask", text: prompt }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "triggerTask", text: prompt }) }), vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", async (args: any) => { @@ -58,7 +70,11 @@ export function registerTerminalActions( terminalContent: content, userInput: "", }) - target().postMessage({ type: "triggerTask", text: prompt }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "triggerTask", text: prompt }) }), ) } diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index bdd397327b4..ecb8cd2e4a8 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -65,6 +65,8 @@ export function registerCommitMessageService( const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined + let userCancelled = false + let timedOut = false const controller = new AbortController() await vscode.window @@ -76,13 +78,19 @@ export function registerCommitMessageService( }, async (_progress, token) => { // Wire VS Code cancellation to abort the HTTP request - token.onCancellationRequested(() => controller.abort()) + token.onCancellationRequested(() => { + userCancelled = true + controller.abort() + }) // Client-side safety timeout (35s) — slightly longer than the // server-side 30s timeout so the server can respond with a proper // error first, but still ensures the spinner never hangs forever. const timeout = 35_000 - const timer = setTimeout(() => controller.abort(), timeout) + const timer = setTimeout(() => { + timedOut = true + controller.abort() + }, timeout) try { const { data } = await client.commitMessage.generate( @@ -100,8 +108,13 @@ export function registerCommitMessageService( }, ) .then(undefined, (error: unknown) => { - if (controller.signal.aborted) { - console.log("[Kilo New] Commit message generation was cancelled or timed out") + if (userCancelled) { + console.log("[Kilo New] Commit message generation was cancelled by user") + return + } + if (timedOut) { + console.log("[Kilo New] Commit message generation timed out") + vscode.window.showErrorMessage("Commit message generation timed out. Please try again.") return } const msg = getErrorMessage(error) diff --git a/packages/kilo-vscode/src/services/input-tools.ts b/packages/kilo-vscode/src/services/input-tools.ts new file mode 100644 index 00000000000..7d722b684ed --- /dev/null +++ b/packages/kilo-vscode/src/services/input-tools.ts @@ -0,0 +1,43 @@ +import type { KiloConnectionService } from "./cli-backend/connection-service" +import { routeAutocompleteMessage } from "./autocomplete/settings" +import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler" + +type Msg = { + type: string + requestId?: string + model?: string + language?: string +} + +type Ctx = { + connection: KiloConnectionService + dir: string + post: (msg: unknown) => void +} + +export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise { + if (await routeAutocompleteMessage(message, ctx.post)) return true + + if (message.type === "speechToTextStart") { + if (!message.requestId) return true + handleSpeechToTextStart( + { requestId: message.requestId, model: message.model, language: message.language }, + ctx.post, + ) + return true + } + + if (message.type === "speechToTextStop") { + if (!message.requestId) return true + handleSpeechToTextStop(ctx.connection, { requestId: message.requestId }, ctx.dir, ctx.post) + return true + } + + if (message.type === "speechToTextCancel") { + if (!message.requestId) return true + handleSpeechToTextCancel({ requestId: message.requestId }, ctx.post) + return true + } + + return false +} diff --git a/packages/kilo-vscode/src/services/telemetry/index.ts b/packages/kilo-vscode/src/services/telemetry/index.ts index 2ed56fb0271..d6ca56e8387 100644 --- a/packages/kilo-vscode/src/services/telemetry/index.ts +++ b/packages/kilo-vscode/src/services/telemetry/index.ts @@ -1,2 +1,3 @@ export { TelemetryEventName, type TelemetryPropertiesProvider } from "./types" export { TelemetryProxy } from "./telemetry-proxy" +export { pushTelemetryState, watchTelemetryState } from "./webview-state" diff --git a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts index 410568378c5..2c85e931f3c 100644 --- a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts +++ b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts @@ -61,6 +61,26 @@ export class TelemetryProxy { }).catch((err) => console.error("[Kilo New] Telemetry capture failed:", err)) } + /** + * Propagate runtime telemetry consent changes to the CLI. The CLI subprocess + * reads `KILO_TELEMETRY_LEVEL` once at spawn — without this call, toggling + * VS Code telemetry consent leaves the CLI's PostHog client stuck on its + * spawn-time state until the process restarts. + */ + setEnabled(enabled: boolean) { + if (!this.url || !this.password) return + + const auth = buildTelemetryAuthHeader(this.password) + fetch(`${this.url}/telemetry/setEnabled`, { + method: "POST", + headers: { + Authorization: auth, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled }), + }).catch((err) => console.error("[Kilo New] Telemetry setEnabled failed:", err)) + } + /** * No-op — the CLI server handles PostHog shutdown. */ diff --git a/packages/kilo-vscode/src/services/telemetry/types.ts b/packages/kilo-vscode/src/services/telemetry/types.ts index 74c676a2204..5993151d037 100644 --- a/packages/kilo-vscode/src/services/telemetry/types.ts +++ b/packages/kilo-vscode/src/services/telemetry/types.ts @@ -82,6 +82,9 @@ export enum TelemetryEventName { FREE_MODELS_LINK_CLICKED = "Free Models Link Clicked", CREATE_ORGANIZATION_LINK_CLICKED = "Create Organization Link Clicked", GHOST_SERVICE_DISABLED = "Ghost Service Disabled", + + // Feedback + FEEDBACK_SUBMITTED = "Feedback Submitted", } /** diff --git a/packages/kilo-vscode/src/services/telemetry/webview-state.ts b/packages/kilo-vscode/src/services/telemetry/webview-state.ts new file mode 100644 index 00000000000..fdd31318b1c --- /dev/null +++ b/packages/kilo-vscode/src/services/telemetry/webview-state.ts @@ -0,0 +1,21 @@ +import * as vscode from "vscode" + +type Post = (msg: { type: "telemetryState"; enabled: boolean }) => void + +/** + * Push the current VS Code telemetry-enabled flag to a webview. Called on + * webview ready / re-sync so the webview can gate feedback UI on the flag. + */ +export function pushTelemetryState(post: Post): void { + post({ type: "telemetryState", enabled: vscode.env.isTelemetryEnabled }) +} + +/** + * Re-push telemetry state whenever the user toggles the VS Code telemetry + * setting while a webview is open, so feedback UI shows/hides in real time. + */ +export function watchTelemetryState(post: Post): vscode.Disposable { + return vscode.env.onDidChangeTelemetryEnabled((enabled) => { + post({ type: "telemetryState", enabled }) + }) +} diff --git a/packages/kilo-vscode/src/shared/autocomplete-models.ts b/packages/kilo-vscode/src/shared/autocomplete-models.ts index 203be8daea5..6f46b56512b 100644 --- a/packages/kilo-vscode/src/shared/autocomplete-models.ts +++ b/packages/kilo-vscode/src/shared/autocomplete-models.ts @@ -25,8 +25,8 @@ const models: AutocompleteModelDef[] = [ temperature: 0.2, }, { - id: "inception/mercury-edit", - label: "Mercury Edit (Inception)", + id: "inception/mercury-edit-2", + label: "Mercury Edit 2 (Inception)", provider: "Inception", temperature: 0, }, @@ -36,9 +36,17 @@ 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 === id) return m + if (m.id === resolved) return m } return DEFAULT_AUTOCOMPLETE_MODEL } diff --git a/packages/kilo-vscode/src/shared/custom-provider.ts b/packages/kilo-vscode/src/shared/custom-provider.ts index f75cfcd7319..b8ed2dfa294 100644 --- a/packages/kilo-vscode/src/shared/custom-provider.ts +++ b/packages/kilo-vscode/src/shared/custom-provider.ts @@ -14,7 +14,7 @@ export const EnvSchema = z const VariantConfigSchema = z.object({ enable_thinking: z.boolean().optional(), thinking: z.object({ type: z.enum(["enabled", "disabled"]) }).optional(), - reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high"]).optional(), + reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(), chat_template_args: z.object({ enable_thinking: z.boolean() }).optional(), }) diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts new file mode 100644 index 00000000000..92f127dc2c9 --- /dev/null +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -0,0 +1,345 @@ +import { existsSync } from "fs" +import { readFile, stat, unlink } from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { ChildProcess } from "child_process" +import { exec, spawn } from "../util/process" + +type Input = { + requestId: string + model: string + language?: string +} + +type Recording = Input & { + file: string + proc: ChildProcess + stderr: string[] + stopped: boolean + exit?: { + code: number | null + signal: string | null + } +} + +type Audio = { + data: string + format: "wav" + model: string + language?: string +} + +type Args = { + pipe?: string[] + input: string[] +} + +let active: Recording | undefined +let starting: string | undefined + +export async function startSpeechCapture(input: Input): Promise { + if (active || starting) throw new Error("Speech recording is already in progress") + + starting = input.requestId + try { + const bin = await findFFmpeg() + const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`) + const state = await startWithArgs(bin, file, input, await inputArgSets(bin)) + return !state.stopped + } finally { + if (starting === input.requestId) starting = undefined + } +} + +export async function stopSpeechCapture(requestId: string): Promise