diff --git a/.changeset/agent-manager-selector-clicks.md b/.changeset/agent-manager-selector-clicks.md new file mode 100644 index 00000000000..63de29ee661 --- /dev/null +++ b/.changeset/agent-manager-selector-clicks.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager branch, mode, and model selectors usable in the new worktree dialog. diff --git a/.changeset/compact-thinking-level.md b/.changeset/compact-thinking-level.md new file mode 100644 index 00000000000..a00bd74f635 --- /dev/null +++ b/.changeset/compact-thinking-level.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Preserve the selected thinking level after compacting a session. diff --git a/.changeset/current-tabs-menu.md b/.changeset/current-tabs-menu.md new file mode 100644 index 00000000000..d764940e7e0 --- /dev/null +++ b/.changeset/current-tabs-menu.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a searchable current-tabs menu to Agent Manager. diff --git a/.changeset/fix-inline-diff-previews.md b/.changeset/fix-inline-diff-previews.md new file mode 100644 index 00000000000..f9cdb8f35bf --- /dev/null +++ b/.changeset/fix-inline-diff-previews.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@opencode-ai/ui": patch +--- + +Restore inline diff previews after session reloads while keeping large file contents stripped from VS Code webview messages. 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-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-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-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/kiloclaw-kilo-chat-migration.md b/.changeset/kiloclaw-kilo-chat-migration.md new file mode 100644 index 00000000000..59454053337 --- /dev/null +++ b/.changeset/kiloclaw-kilo-chat-migration.md @@ -0,0 +1,8 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +"@kilocode/kilo-gateway": minor +"@kilocode/sdk": minor +--- + +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. diff --git a/.changeset/message-feedback.md b/.changeset/message-feedback.md new file mode 100644 index 00000000000..080c200bd34 --- /dev/null +++ b/.changeset/message-feedback.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +--- + +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. diff --git a/.changeset/native-tab-titles.md b/.changeset/native-tab-titles.md new file mode 100644 index 00000000000..2b583182b80 --- /dev/null +++ b/.changeset/native-tab-titles.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Update regular Kilo editor tab titles to follow the current session title. diff --git a/.changeset/permission-overrides-ask-plan.md b/.changeset/permission-overrides-ask-plan.md new file mode 100644 index 00000000000..6c994da35d6 --- /dev/null +++ b/.changeset/permission-overrides-ask-plan.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Honor configured permission overrides in Ask and Plan modes, including persisted always-allow rules. diff --git a/.changeset/permission-prompt-shortcuts.md b/.changeset/permission-prompt-shortcuts.md new file mode 100644 index 00000000000..88901f97af0 --- /dev/null +++ b/.changeset/permission-prompt-shortcuts.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support accepting command permission prompts with Enter and denying them with Escape. 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/remove-custom-providers.md b/.changeset/remove-custom-providers.md new file mode 100644 index 00000000000..b3ffa357735 --- /dev/null +++ b/.changeset/remove-custom-providers.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +"@kilocode/sdk": patch +--- + +Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled. diff --git a/.changeset/steady-agents-think.md b/.changeset/steady-agents-think.md new file mode 100644 index 00000000000..c3278a9d444 --- /dev/null +++ b/.changeset/steady-agents-think.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. diff --git a/.changeset/terminal-context-active.md b/.changeset/terminal-context-active.md new file mode 100644 index 00000000000..3e364d57398 --- /dev/null +++ b/.changeset/terminal-context-active.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Capture the active VS Code terminal when using `@terminal` in Agent Manager. diff --git a/.changeset/unified-diff-panel.md b/.changeset/unified-diff-panel.md new file mode 100644 index 00000000000..c7c42d5ede4 --- /dev/null +++ b/.changeset/unified-diff-panel.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +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. 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/beta.yml b/.github/workflows/beta.yml index 5c5ca6159a6..90fd1021893 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -14,8 +14,9 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: + persist-credentials: false # kilocode_change fetch-depth: 0 - name: Setup Bun diff --git a/.github/workflows/check-md-table-padding.yml b/.github/workflows/check-md-table-padding.yml index 51d6706597d..18c3c7aa962 100644 --- a/.github/workflows/check-md-table-padding.yml +++ b/.github/workflows/check-md-table-padding.yml @@ -14,7 +14,7 @@ jobs: if: github.repository == 'Kilo-Org/kilocode' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change with: ref: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index e1588c0fc44..810371d6d3e 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -21,7 +21,7 @@ jobs: if: github.repository == 'Kilo-Org/kilocode' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -37,3 +37,8 @@ jobs: else echo "No PR base SHA available (workflow_dispatch without PR context) — skipping." fi + + # kilocode_change start + - name: Check workflow allowlist + run: bun run script/check-workflows.ts + # kilocode_change end diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index 04b6ae7ac80..aba47e131f1 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -12,7 +12,7 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change - uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 03bc1867873..a261d6f5b3b 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -26,7 +26,7 @@ jobs: OWNER: ${{ github.repository_owner }} # kilocode_change end - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change - uses: ./.github/actions/setup-bun diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/disabled/compliance-close.yml.disabled similarity index 96% rename from .github/workflows/compliance-close.yml rename to .github/workflows/disabled/compliance-close.yml.disabled index d56d3b4d971..c3bcf9f686f 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/disabled/compliance-close.yml.disabled @@ -13,7 +13,6 @@ permissions: jobs: close-non-compliant: - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo runs-on: ubuntu-latest steps: - name: Close non-compliant issues and PRs after 2 hours diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/disabled/daily-issues-recap.yml.disabled similarity index 95% rename from .github/workflows/daily-issues-recap.yml rename to .github/workflows/disabled/daily-issues-recap.yml.disabled index 185de2f5b3e..789712ef519 100644 --- a/.github/workflows/daily-issues-recap.yml +++ b/.github/workflows/disabled/daily-issues-recap.yml.disabled @@ -8,9 +8,6 @@ on: jobs: daily-recap: - # kilocode_change start - disabled for kilo-cli fork (OpenCode Discord integration) - if: false - # kilocode_change end runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read @@ -24,7 +21,7 @@ jobs: - uses: ./.github/actions/setup-bun - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash + run: curl -fsSL https://kilo.ai/install | bash - name: Generate daily issues recap id: recap @@ -46,7 +43,7 @@ jobs: # Get today's date range TODAY=$(date -u +%Y-%m-%d) - opencode run -m kilo/anthropic/claude-sonnet-4.5 "Generate a daily issues recap for the OpenCode repository. + opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the Kilo repository. TODAY'S DATE: ${TODAY} diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/disabled/daily-pr-recap.yml.disabled similarity index 95% rename from .github/workflows/daily-pr-recap.yml rename to .github/workflows/disabled/daily-pr-recap.yml.disabled index 7049531b5dd..ff026917f81 100644 --- a/.github/workflows/daily-pr-recap.yml +++ b/.github/workflows/disabled/daily-pr-recap.yml.disabled @@ -8,9 +8,6 @@ on: jobs: pr-recap: - # kilocode_change start - disabled for kilo-cli fork (OpenCode Discord integration) - if: false - # kilocode_change end runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read @@ -24,7 +21,7 @@ jobs: - uses: ./.github/actions/setup-bun - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash + run: curl -fsSL https://kilo.ai/install | bash - name: Generate daily PR recap id: recap @@ -45,7 +42,7 @@ jobs: run: | TODAY=$(date -u +%Y-%m-%d) - opencode run -m kilo/anthropic/claude-sonnet-4.5 "Generate a daily PR activity recap for the OpenCode repository. + opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the Kilo repository. TODAY'S DATE: ${TODAY} diff --git a/.github/workflows/pr-management.yml b/.github/workflows/disabled/pr-management.yml.disabled similarity index 73% rename from .github/workflows/pr-management.yml rename to .github/workflows/disabled/pr-management.yml.disabled index 7abe0a0ac18..1e331bd6f2e 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/disabled/pr-management.yml.disabled @@ -5,20 +5,7 @@ on: types: [opened] jobs: - # kilocode_change start - check-author: - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo - uses: ./.github/workflows/check-org-member.yml - with: - username: ${{ github.event.pull_request.user.login }} - secrets: - kilo-maintainer-app-id: ${{ secrets.KILO_MAINTAINER_APP_ID }} - kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }} - check-duplicates: - needs: check-author - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo - # kilocode_change end runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read @@ -48,10 +35,9 @@ jobs: if: steps.team-check.outputs.is_team != 'true' run: bun install - # kilocode_change start - - name: Setup Kilo - uses: ./.github/actions/setup-kilo - # kilocode_change end + - name: Install opencode + if: steps.team-check.outputs.is_team != 'true' + run: curl -fsSL https://kilo.ai/install | bash - name: Build prompt if: steps.team-check.outputs.is_team != 'true' @@ -73,10 +59,7 @@ jobs: - name: Check for duplicate PRs if: steps.team-check.outputs.is_team != 'true' env: - # kilocode_change start - KILO_API_KEY: ${{ secrets.KILO_API_KEY }} - KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} - # kilocode_change end + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | @@ -89,8 +72,7 @@ jobs: fi add-contributor-label: - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo - runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change + runs-on: ubuntu-latest permissions: pull-requests: write issues: write diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/disabled/pr-standards.yml.disabled similarity index 90% rename from .github/workflows/pr-standards.yml rename to .github/workflows/disabled/pr-standards.yml.disabled index 75fb650144d..1edbd5d061d 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/disabled/pr-standards.yml.disabled @@ -5,22 +5,10 @@ on: types: [opened, edited, synchronize] jobs: - # kilocode_change start - check-author: - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo - uses: ./.github/workflows/check-org-member.yml - with: - username: ${{ github.event.pull_request.user.login }} - secrets: - kilo-maintainer-app-id: ${{ secrets.KILO_MAINTAINER_APP_ID }} - kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }} - check-standards: - needs: check-author - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo - # kilocode_change end - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - name: Check PR standards @@ -28,6 +16,30 @@ jobs: with: script: | const pr = context.payload.pull_request; + const login = pr.user.login; + + // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC) + const cutoff = new Date('2026-02-19T00:00:00Z'); + const prCreated = new Date(pr.created_at); + if (prCreated < cutoff) { + console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`); + return; + } + + // Check if author is a team member or bot + if (login === 'opencode-agent[bot]') return; + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev' + }); + const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); + if (members.includes(login)) { + console.log(`Skipping: ${login} is a team member`); + return; + } + const title = pr.title; async function addLabel(label) { @@ -59,10 +71,10 @@ jobs: repo: context.repo.repo, issue_number: pr.number }); - + const existing = comments.find(c => c.body.includes(markerText)); if (existing) return; - + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -88,9 +100,9 @@ jobs: - \`refactor:\` or \`refactor(scope):\` code refactoring - \`test:\` or \`test(scope):\` adding or updating tests - Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`kilo\`). + Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`). - See [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md#pr-titles) for details.`); + See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`); return; } @@ -133,7 +145,7 @@ jobs: 1. Open an issue describing the bug/feature (if one doesn't exist) 2. Add \`Fixes #\` or \`Closes #\` to this PR description - See [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md#issue-first-policy) for details.`); + See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`); return; } @@ -141,7 +153,6 @@ jobs: console.log('PR meets all standards'); check-compliance: - if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/storybook.yml b/.github/workflows/disabled/storybook.yml.disabled similarity index 98% rename from .github/workflows/storybook.yml rename to .github/workflows/disabled/storybook.yml.disabled index 9cd606324a1..6d143a8a22f 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/disabled/storybook.yml.disabled @@ -25,7 +25,6 @@ concurrency: jobs: build: - if: false name: storybook build runs-on: blacksmith-4vcpu-ubuntu-2404 steps: diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index b485662e482..316fa748384 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -16,7 +16,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/docs-check-links.yml b/.github/workflows/docs-check-links.yml index 75b47a67077..4eb00f444af 100644 --- a/.github/workflows/docs-check-links.yml +++ b/.github/workflows/docs-check-links.yml @@ -17,7 +17,7 @@ jobs: if: github.repository == 'Kilo-Org/kilocode' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change - name: Link Checker uses: Kilo-Org/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index dfc5039ef24..cf02d8e0a48 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -13,7 +13,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: fetch-depth: 1 @@ -128,7 +128,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: fetch-depth: 1 diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 25b03a7bfc2..2a3f221c176 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -14,7 +14,11 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + 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/nix-hashes.yml b/.github/workflows/nix-hashes.yml index cfa4ee633a3..be48bb895e1 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -73,7 +73,7 @@ jobs: echo "Computed hash for ${SYSTEM}: $HASH" - name: Upload hash - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 # kilocode_change with: name: hash-${{ matrix.system }} path: hash.txt @@ -86,7 +86,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: persist-credentials: false fetch-depth: 0 @@ -106,7 +106,7 @@ jobs: git pull --rebase --autostash origin "$GITHUB_REF_NAME" - name: Download hash artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 # kilocode_change with: path: hashes pattern: hash-* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 346972018df..34f35c22ce0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,7 +42,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'Kilo-Org/kilocode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 # kilocode_change with: fetch-depth: 0 @@ -78,7 +78,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'Kilo-Org/kilocode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 # kilocode_change with: fetch-tags: true @@ -123,11 +123,11 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'Kilo-Org/kilocode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 # kilocode_change - uses: ./.github/actions/setup-bun - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 # kilocode_change with: node-version: "24" registry-url: "https://registry.npmjs.org" @@ -156,7 +156,7 @@ jobs: KILO_PRE_RELEASE: ${{ inputs.pre_release }} GH_REPO: ${{ github.repository }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 # kilocode_change with: name: kilo-vscode path: packages/kilo-vscode/out @@ -183,7 +183,11 @@ jobs: - smoke-test runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 # kilocode_change + # kilocode_change start + with: + persist-credentials: false + # kilocode_change end - uses: ./.github/actions/setup-bun @@ -200,7 +204,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 # kilocode_change with: node-version: "24" registry-url: "https://registry.npmjs.org" @@ -235,7 +239,7 @@ jobs: path: packages/kilo-vscode/out - name: Cache apt packages (AUR) - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: /var/cache/apt/archives key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }} diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index a041bc72569..674235483a3 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -47,7 +47,7 @@ jobs: KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} steps: - name: Checkout kilo-bench - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: repository: Kilo-Org/kilo-bench token: ${{ secrets.BENCH_GITHUB_TOKEN }} @@ -96,6 +96,13 @@ 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. 600s gives enough headroom for transient + # mirror/cdn slowness while still finishing well under timeout-minutes. - name: Run smoke test — hello-world env: KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }} @@ -104,7 +111,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 \ + --agent-setup-timeout 600 - name: Run smoke test — log-summary-date-ranges env: @@ -115,18 +123,26 @@ 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 \ + --agent-setup-timeout 600 - 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@v4 + uses: actions/upload-artifact@v7 # kilocode_change with: name: smoke-test-results 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/source-check-links.yml b/.github/workflows/source-check-links.yml index 60df85b4b54..659de3d09ba 100644 --- a/.github/workflows/source-check-links.yml +++ b/.github/workflows/source-check-links.yml @@ -16,7 +16,7 @@ jobs: if: github.repository == 'Kilo-Org/kilocode' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/test-vscode.yml b/.github/workflows/test-vscode.yml index a3e66fff459..60569585848 100644 --- a/.github/workflows/test-vscode.yml +++ b/.github/workflows/test-vscode.yml @@ -27,7 +27,7 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c7bd4e5decd..559d65f0a9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,21 +28,21 @@ jobs: matrix: settings: - name: linux - host: blacksmith-8vcpu-ubuntu-2404 + host: blacksmith-4vcpu-ubuntu-2404 # kilocode_change - name: windows - host: blacksmith-8vcpu-windows-2025 + host: blacksmith-4vcpu-windows-2025 # kilocode_change runs-on: ${{ matrix.settings.host }} defaults: run: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 # kilocode_change with: node-version: "24" @@ -55,7 +55,7 @@ jobs: git config --global user.name "kilo-maintainer[bot]" - name: Cache Turbo - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: node_modules/.cache/turbo key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} @@ -80,7 +80,7 @@ jobs: - name: Upload unit artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 # kilocode_change with: name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} include-hidden-files: true diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index b767380365e..b467817c71b 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -13,7 +13,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: fetch-depth: 1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 894f5dac6fe..6c7ae420953 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 4f49c4aa461..d059175adc8 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -12,7 +12,7 @@ jobs: matched: ${{ steps.filter.outputs.matched }} is_fork: ${{ steps.fork-check.outputs.is_fork }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # kilocode_change - uses: Kilo-Org/paths-filter@master id: filter with: @@ -47,7 +47,7 @@ jobs: steps: - name: Checkout (internal) if: needs.check-paths.outputs.is_fork != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: lfs: true token: ${{ secrets.BOT_PAT }} @@ -55,7 +55,7 @@ jobs: - name: Checkout (fork) if: needs.check-paths.outputs.is_fork == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: lfs: true @@ -77,7 +77,7 @@ jobs: bun-version: latest - name: Cache Bun modules - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: ~/.bun/install/cache key: bun-${{ hashFiles('bun.lock') }} @@ -87,7 +87,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('packages/kilo-ui/package.json') }} @@ -103,7 +103,7 @@ jobs: - name: Cache Storybook build id: storybook-cache - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: packages/kilo-ui/storybook-static key: storybook-${{ hashFiles('packages/kilo-ui/src/**', 'packages/kilo-ui/.storybook/**', 'packages/ui/src/**', 'packages/kilo-ui/package.json') }} @@ -195,7 +195,7 @@ jobs: - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 # kilocode_change with: name: visual-regression-results path: packages/kilo-ui/test-results/ @@ -211,7 +211,7 @@ jobs: steps: - name: Checkout (internal) if: needs.check-paths.outputs.is_fork != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: lfs: true token: ${{ secrets.BOT_PAT }} @@ -219,7 +219,7 @@ jobs: - name: Checkout (fork) if: needs.check-paths.outputs.is_fork == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 # kilocode_change with: lfs: true @@ -241,7 +241,7 @@ jobs: bun-version: latest - name: Cache Bun modules - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: ~/.bun/install/cache key: bun-${{ hashFiles('bun.lock') }} @@ -251,7 +251,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache-vscode - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: ~/.cache/ms-playwright key: playwright-vscode-${{ hashFiles('packages/kilo-vscode/package.json') }} @@ -267,7 +267,7 @@ jobs: - name: Cache Storybook build id: storybook-cache-vscode - uses: actions/cache@v4 + uses: actions/cache@v5 # kilocode_change with: path: packages/kilo-vscode/storybook-static key: storybook-vscode-${{ hashFiles('packages/kilo-vscode/webview-ui/src/**', 'packages/kilo-vscode/.storybook/**', 'packages/kilo-ui/src/**', 'packages/ui/src/**', 'packages/kilo-vscode/package.json') }} @@ -386,7 +386,7 @@ jobs: - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 # kilocode_change with: name: visual-regression-vscode-results path: packages/kilo-vscode/test-results/ diff --git a/.kilo/command/upstream-manual-merge.md b/.kilo/command/upstream-manual-merge.md index 6cf67bee377..cebc4045353 100644 --- a/.kilo/command/upstream-manual-merge.md +++ b/.kilo/command/upstream-manual-merge.md @@ -10,67 +10,254 @@ 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. -Workflow: +## Workflow -1. Inspect the current merge state: - - `git status --short` - - `git diff --name-only --diff-filter=U` - - `upstream-merge-report-.md` when present - - `.worktrees/opencode-merge/auto-merge` for the automated merge snapshot when present -2. Before editing, write a concise plan in the chat: - - file-by-file strategy - - expected resolution kind: `hybrid`, `take-ours`, `take-theirs`, `regenerated`, `removed`, `renamed`, or `other` - - risk level: `low`, `medium`, or `high` - - verification commands you expect to run -3. Ask the user to approve the plan before applying any manual conflict - resolution. Do not resolve a file until the user has approved that file's - strategy. -4. Resolve each conflict carefully, one file at a time. +### 1. Inspect the current merge state - **Reference worktrees when present:** - - `.worktrees/opencode-merge/opencode` is the pristine upstream opencode tree - - `.worktrees/opencode-merge/kilo-main` is the Kilo base snapshot - - `.worktrees/opencode-merge/auto-merge` is the automated merge snapshot and the original conflict reference +- `git status --short` +- `git diff --name-only --diff-filter=U` +- `upstream-merge-report-.md` when present +- `.worktrees/opencode-merge/auto-merge` for the automated merge snapshot when present - **Inspect conflicts (optional):** - - `script/upstream/find-conflict-markers.sh ` on the working tree - - `script/upstream/find-conflict-markers.sh .worktrees/opencode-merge/auto-merge/` on the auto-merge snapshot +### 2. Read every conflicted file end-to-end before planning - **Apply the resolution rules:** - - prefer upstream code and architecture whenever compatible with Kilo behavior - - preserve Kilo-specific behavior marked with `kilocode_change` - - keep `kilocode_change` markers around Kilo-specific changes in shared opencode files - - keep Kilo-specific text, code, and marker comments the same as the auto-merge conflict snapshot unless a refactor is required - - if Kilo-specific code must be refactored to fit new upstream architecture, explain the refactor in the final summary - - if upstream moved the relevant logic to another file, port the Kilo behavior there and list both paths in the final summary - - if upstream deleted a file, analyze whether the Kilo behavior should be ported elsewhere or removed rather than restoring the deleted file - - if tests fail only because upstream intentionally removed behavior, remove or update the obsolete tests rather than adding the old file back - - do not modify unrelated files -5. After each file is resolved, verify the decision before moving on: - - inspect the resolved file and confirm it has no conflict markers - - compare against the opencode, kilo-main, and auto-merge references when present - - run the smallest relevant check for that file when practical - - summarize the exact resolution, tradeoff, and verification result in chat - - ask the user to approve the resolved file before staging it or resolving the next file -6. Run the appropriate checks: - - stage resolved files with `git add -A` so git no longer reports unmerged paths - - if `packages/opencode/` shared files changed, run `bun run script/check-opencode-annotations.ts` - - run targeted typechecks/tests when practical for touched packages - - run `bun run typecheck` from the repo root before declaring the merge ready -7. Finish with: - - files resolved - - resolution choices and rationale - - checks run and results - - any remaining high-risk areas for reviewer attention +Use `script/upstream/find-conflict-markers.sh ` to jump to each region, +then read enough surrounding lines to understand the code — not just the +conflict hunk. Specifically check: -Every manual merge decision requires explicit user approval before applying and -again after verification. Be especially cautious when a decision is destructive, -changes auth, billing, data deletion, public API compatibility, config schema -behavior, migrations, provider routing, or security posture. +- is this a plain 3-way on a single expression, or a structural refactor? +- does upstream rename/move something that invalidates a HEAD-only declaration? +- does a `kilocode_change` marker in HEAD encode a bug fix, a feature, or a + defensive check? +- is the conflicted block referenced by *non-conflicted* code elsewhere in the + same file (imports, signatures, call sites) that will break if we drop it? -Common pitfalls to watch for: -- auto-merged code can reference declarations that still live inside conflict blocks -- related sibling files can need edits even when they are not listed as unmerged -- `renamed` should be used only when behavior moves to a different file -- function signatures can drift across conflict boundaries -- full repo typecheck is the catch-all for non-conflicted call-site breakage +When HEAD includes a non-obvious Kilo-specific wrapper (e.g. a helper in +`packages/opencode/src/kilocode/`), find out why it exists before deciding to +keep or bypass it: + +```bash +git log --all --oneline -S "" -- packages/opencode/src/kilocode/ +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. + +### 3. Write a plan in chat and get approval + +For every conflicted file (and any adjacent file the resolution forces you to +touch — see §6) include: + +- expected resolution kind: `hybrid`, `take-ours`, `take-theirs`, `regenerated`, + `removed`, `renamed`, or `other` +- risk level: `low`, `medium`, or `high` +- one-sentence rationale (what Kilo behaviour is preserved, what upstream + feature is adopted, what is dropped) +- verification commands you expect to run (targeted tests, typecheck) + +Group files by risk level. Ask the user which batch to start with. You can +resolve an entire `low` batch in one pass if the user approves the batch, but +resolve `medium` and `high` files one at a time. + +**Do not resolve a file until the user has approved that file's (or batch's) +strategy.** + +### 4. Before every edit, explain reasoning before showing the diff + +The user needs to review intent, not just the raw change. For each file, in +order: + +1. Show the conflict's surrounding context (10–30 lines around each conflict + region, in chat). +2. Explain what each of the three sides (HEAD, merge-base, upstream) is doing. +3. State which Kilo behaviour must survive and why (reference PR numbers / + `kilocode_change` comments when possible). +4. State the resolution and why it is better than the alternatives. +5. Then apply the edit. The tool will display the diff — the user only has to + verify the diff matches the reasoning. + +Do not lead with the diff. A diff without reasoning forces the user to +reverse-engineer the decision. + +### 5. Apply resolution rules + +Reference worktrees when present: + +- `.worktrees/opencode-merge/opencode` — pristine upstream tree +- `.worktrees/opencode-merge/kilo-main` — Kilo base snapshot +- `.worktrees/opencode-merge/auto-merge` — automated merge snapshot (original + conflict reference) + +Apply in order: + +- prefer upstream code and architecture whenever compatible with Kilo behaviour +- preserve Kilo-specific behaviour marked with `kilocode_change` +- keep `kilocode_change` markers around Kilo-specific code in shared opencode + files +- when upstream refactors a region that HEAD had annotated with + `kilocode_change`, **check whether the marker encodes a bug fix or a feature + delta**. Bug fixes (missing `await`, defensive null-check, error capture) + usually need to be re-applied on top of the upstream refactor. Example from + v1.14.30: `Workspace.isSyncing` was missing an `await` — upstream's Effect + refactor reintroduced the same bug, so we had to port the fix into the new + `Effect.gen` block. +- when a `take-theirs` drops a line that was the target of a Kilo pre-filter, + the upstream line may be actively wrong for Kilo — e.g. an inner `continue` + filter whose condition collides with an outer filter Kilo added. Re-read the + surrounding 20 lines before committing to `take-theirs`. +- if Kilo-specific code must be refactored to fit new upstream architecture, + explain the refactor in the final summary +- if upstream moved the relevant logic to another file, port the Kilo behaviour + 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 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 + or update the obsolete tests rather than adding the old file back +- do not modify unrelated files + +When removing code that existed in one side of a conflict, prefer +**commenting it out with `kilocode_change` markers** over deletion when the +surrounding structure (an `if`, a loop) still makes sense. That keeps the +intent visible to the next merger. Example: + +```ts +} else if (input?.scope !== "project" && !Flag.KILO_EXPERIMENTAL_WORKSPACES) { + // kilocode_change start - directory filtering handled by KiloSession.filters above + // if (input?.directory) { + // conditions.push(eq(SessionTable.directory, input.directory)) + // } + // kilocode_change end +} +``` + +Use `TODO:` not `NOTE:` for follow-ups. `TODO` is searchable and implies an +owner will act on it; `NOTE` reads as permanent commentary. + +### 6. Look for adjacent files the conflict forces you to touch + +Upstream restructures sometimes split one file into several (e.g. `permission.ts` +→ `groups/permission.ts` + `handlers/permission.ts`). Only the *renamed* file +shows up in `git diff --diff-filter=U`; the new sibling may need a Kilo feature +ported in too. After resolving the flagged file, check: + +- files that import from the resolved file — do they compile? +- files at paths implied by new imports (e.g. `../middleware/*`, `./handlers/*`) +- `kilocode_change` comments in the *auto-merge* snapshot that didn't end up in + the working tree because the hosting file was renamed + +Add any such files to the plan as `hybrid` or `take-ours` with the same +approval flow. + +### 7. Verify each resolution before moving on + +- confirm `script/upstream/find-conflict-markers.sh ` prints nothing +- read the final file region (the new shape after edit) and sanity-check imports +- for apparently-unused symbols upstream introduced, `grep` the file and the + rest of the package before deleting — they may be called from non-conflicted + code elsewhere. Example: `isTheme` in `theme.tsx` looked unused at the + resolution site but was called twice further down. +- run the smallest relevant check (single `bun test` file, or `bun run + typecheck` in the touched package) +- summarise the exact resolution, tradeoff, and verification result in chat +- ask the user to approve the resolved file before staging it or resolving the + next one (for `medium` / `high`; `low` batches can be staged together) + +### 8. Run the full checks once everything is resolved + +- `git diff --name-only --diff-filter=U` returns empty +- `bun run typecheck` from `packages/opencode/` (targeted) and from repo root + (catches non-conflicted call-site breakage) +- relevant targeted tests. Tests that hang or time out in an unrelated part of + the graph may be pre-existing — note them, don't block the merge on them +- `bun run script/check-opencode-annotations.ts` if `packages/opencode/` shared + 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) + +### 9. Commit with the standard message + +Per `script/upstream/README.md`: + +```bash +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. + +### 10. Resync version strings in a separate commit + +Upstream stamps its own version into shared files — notably +`packages/extensions/zed/extension.toml` (version field + 5 Kilo-Org download +URLs), and any `package.json` that upstream bumped in the same release window. +After the merge this leaves parts of the tree pointing at upstream's version +(e.g. `1.14.30`), whose release tag does not exist on Kilo's pipeline, so the +Zed download URLs silently 404. + +Fix this in a dedicated commit *after* `resolve merge conflicts`: + +```bash +bun run script/sync-versions.ts # uses root package.json version +# or, to target an explicit version: +bun run script/sync-versions.ts 7.2.41 +git add -A +git commit -m "chore: resync versions after upstream merge" +``` + +The script rewrites every top-level `"version"` in `package.json` files +(excluding `node_modules`, hidden dirs, and `packages/kilo-jetbrains/` which +tracks its own cadence), plus the Zed extension toml. It is idempotent — rerun +it any time to rebase the version back onto Kilo main (useful during +long-running upstream merges where `main` releases in the meantime). + +Keeping this in its own commit makes reviewers' job easier: the merge commit +only contains behavioural resolutions, and the version resync is a trivial +diff they can skim in one glance. + +### 11. Write the PR body + +Structure the description so reviewers can skim: + +- **Non-trivial merge decisions**: a short section per file (or group of + related files) that required more than a mechanical `take-ours`/`take-theirs`. + Focus on *what Kilo behaviour survived* and *what upstream features were + adopted*. Link to Kilo PRs when a `kilocode_change` encodes a specific fix. +- **Notable auto-merged changes**: new columns, new helper files, renamed + middleware — anything reviewers should eyeball even though git didn't flag + it. +- **What to test**: explicit, scenario-level test steps for each non-trivial + change. Don't list tests; list *user-visible behaviour* so a tester who + doesn't read the diff can exercise it. +- **CI guards to watch**: typecheck, knip, annotation check, visual regression. +- **Follow-ups**: any `TODO:` you left in code, as a bullet list with links. + +## User-approval checkpoints + +Every manual merge decision requires explicit user approval **before applying** +and **again after verification**. Be especially cautious when a decision is +destructive, changes auth, billing, data deletion, public API compatibility, +config schema behaviour, migrations, provider routing, or security posture. + +## Common pitfalls + +- Auto-merged code can reference declarations that still live inside conflict + blocks. +- Related sibling files can need edits even when they are not listed as + unmerged — especially after upstream structural splits. +- `renamed` should be used only when behaviour moves to a different file. +- Function signatures can drift across conflict boundaries (args added, return + types widened). Grep for every call site before finalising. +- Full-repo typecheck is the catch-all for non-conflicted call-site breakage. +- Upstream can reintroduce bugs a Kilo `kilocode_change` had already fixed — + during big refactors check every `kilocode_change` the refactor touched. +- "Take-theirs" on an inner conditional is often wrong when Kilo added an outer + pre-filter whose whole point was to widen what makes it to the inner block. +- Apparently-unused upstream-added declarations may be called from + non-conflicted code elsewhere. Grep before deleting. +- 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. 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/AGENTS.md b/AGENTS.md index ecfd388986b..cc5eda0d054 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale. - **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing. - **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. +- **workflow allowlist**: `bun run script/check-workflows.ts` from repo root. CI runs this as part of the annotations workflow — any `.yml` / `.yaml` file added to or removed from `.github/workflows/` must be reflected in the hardcoded list in `script/check-workflows.ts`. Prevents upstream-merged workflows from silently starting to run in our CI. - **Backend/SDK programmatic testing**: see [TESTING.md](./TESTING.md) for spawning the local main-branch backend (`bun dev serve`) and driving it via `curl` — use this instead of `kilo serve` (prod binary) when testing backend fixes. ## Quality Checks @@ -255,6 +256,10 @@ We regularly merge upstream changes from opencode. To minimize merge conflicts a The goal is to keep our diff from upstream as small as possible, making regular merges straightforward and reducing the risk of conflicts. +### Git conflict style + +`bun install` sets `merge.conflictStyle=zdiff3` repo-locally via `script/setup-git.ts` (wired into `postinstall`). Conflicts include the common ancestor between `|||||||` and `=======`, which is what `script/upstream/` and `mergiraf` rely on for structural resolution and what makes manual resolution on shared opencode files tractable. If you've overridden it in your user config, the repo-local setting takes precedence — don't override it back. + ### Kilocode Change Markers To minimize merge conflicts when syncing with upstream, mark Kilo Code-specific changes in shared code with `kilocode_change` comments. diff --git a/bun.lock b/bun.lock index 5ea5e1bf75e..1dcf304d67f 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.40", + "version": "7.2.42", "bin": { "opencode": "./bin/opencode", }, @@ -68,7 +68,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -97,7 +97,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -133,7 +133,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.40", + "version": "7.2.42", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -143,7 +143,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -177,7 +177,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -191,7 +191,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", @@ -226,7 +226,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -251,7 +251,6 @@ "quick-lru": "^7.0.0", "simple-git": "3.35.2", "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 +286,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.40", + "version": "7.2.42", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -358,6 +357,7 @@ "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", "bun-pty": "0.4.8", + "chardet": "2.1.1", "chokidar": "4.0.3", "cli-sound": "1.1.3", "clipboardy": "4.0.0", @@ -376,7 +376,6 @@ "iconv-lite": "0.7.2", "ignore": "7.0.5", "immer": "11.1.4", - "jschardet": "3.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", @@ -392,7 +391,6 @@ "semver": "^7.6.3", "simple-git": "3.35.2", "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 +444,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -461,8 +459,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 +469,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +480,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "cross-spawn": "catalog:", }, @@ -497,7 +495,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.40", + "version": "7.2.42", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -520,7 +518,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.40", + "version": "7.2.42", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -580,7 +578,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", @@ -608,10 +605,12 @@ "@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", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1504,21 +1503,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 +2133,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=="], @@ -2426,7 +2423,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 +2491,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=="], @@ -3300,8 +3297,6 @@ "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], - "jschardet": ["jschardet@3.1.4", "", {}, "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], @@ -3406,8 +3401,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=="], @@ -4158,8 +4151,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=="], @@ -4952,6 +4943,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=="], @@ -5076,6 +5069,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=="], @@ -5190,6 +5187,8 @@ "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=="], + "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=="], + "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=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -5264,7 +5263,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=="], @@ -5602,6 +5601,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=="], @@ -5802,6 +5821,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/install b/install index c4186688256..c6d0e37e0b6 100755 --- a/install +++ b/install @@ -385,6 +385,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 +462,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 +477,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 diff --git a/nix/hashes.json b/nix/hashes.json index e6ab26053e9..948a66c017f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-1SpPwQiU4XdqJNJhw9Abf2IiBMxv22waspgDTylL2yg=", - "aarch64-linux": "sha256-g+wFoSCCfz0lZym83gHvAPj1R9vIpF4/7jK3bUGCMP4=", - "aarch64-darwin": "sha256-aGNmAN6RWL+4V6kxckMrOPJIgvhnomwILcNBI27VapA=", - "x86_64-darwin": "sha256-KXPaxTzaypPSjBvmUJVICAcjg6XVnG4EKCRFCcUZ3oI=" + "x86_64-linux": "sha256-ZInDUMIVu0aBcf0GufNsBEBdL8pQ6GvlQmQq18mFzso=", + "aarch64-linux": "sha256-I9oJ145BsbXUVTzaIWAZsG8YijmO/gUDO6DwinUwA3w=", + "aarch64-darwin": "sha256-tr8lRQvUMToux9JyiyTk6rYDFmPMm6LNai/IQ8Qk23c=", + "x86_64-darwin": "sha256-ffyoj7ubIzbUXhcm7/yOQBZA42BIt+fEBQs4khQqaSg=" } } diff --git a/package.json b/package.json index fe7327a31fc..a770fe97a44 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "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:desktop": "bun --cwd packages/desktop-electron dev", + "dev:web": "bun --cwd packages/app dev", + "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", @@ -31,8 +33,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", @@ -74,6 +76,8 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" @@ -82,7 +86,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 +96,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" }, @@ -143,9 +147,8 @@ "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.2.42", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 26ae1f6220f..4f5b23705da 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.2.42", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -47,5 +47,6 @@ }, "overrides": { "drizzle-orm": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ff01b402d0b..aca02597566 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -54,7 +54,9 @@ export const Flag = { // Experimental KILO_EXPERIMENTAL, - KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), + KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( + Config.withDefault(false), + ), KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( Config.withDefault(false), ), diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a514d48d93b..1c7003408e8 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,6 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { markNoIndex } from "./kilocode/spotlight" // kilocode_change +import { Flag } from "./flag/flag" const app = "kilo" // kilocode_change // kilocode_change start @@ -21,6 +22,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 +34,7 @@ const paths = { cache, config, state, + tmp, } export const Path = paths @@ -42,6 +45,7 @@ await Promise.all([ fs.mkdir(Path.data, { recursive: true }), fs.mkdir(Path.config, { recursive: true }), fs.mkdir(Path.state, { recursive: true }), + fs.mkdir(Path.tmp, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), ]) @@ -58,23 +62,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/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/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..b7679756f20 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.2.42" 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.2.42/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.2.42/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.2.42/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.2.42/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.2.42/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index 3668c18ef72..7a0abc28dd5 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -80,6 +80,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..ef52759b953 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -43,4 +43,6 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # 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/package.json b/packages/kilo-docs/package.json index 1623d1511fb..10d60618a3f 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.2.42", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md index a6d22e2c3cd..746d12dc738 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -11,17 +11,17 @@ Kilo automatically detects the text encoding of each file it reads and preserves - UTF-8, with or without BOM - UTF-16 LE and UTF-16 BE, **with a BOM** +- UTF-32 LE and UTF-32 BE, **with a BOM** - Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR - Windows-1251, KOI8-R - The ISO-8859 family -- Other common legacy Latin and CJK encodings detected by [jschardet](https://github.com/aadsm/jschardet) and decoded by [iconv-lite](https://github.com/ashtuchkin/iconv-lite) +- Other common legacy Latin and CJK encodings detected by [chardet](https://github.com/runk/node-chardet) and decoded by [iconv-lite](https://github.com/ashtuchkin/iconv-lite) New files Kilo creates are always UTF-8 without a BOM. Encoding detection only runs when Kilo reads or overwrites an existing file. ## Not Supported -- **UTF-16 without a BOM.** The byte pattern is ambiguous and cannot be distinguished reliably from other encodings. Save the file with a BOM or convert it to UTF-8. -- **UTF-32.** Extremely rare in practice; convert to UTF-8 if you need Kilo to work with it. +- **UTF-16 or UTF-32 without a BOM.** The byte pattern is ambiguous and cannot be distinguished reliably from other encodings. Save the file with a BOM or convert it to UTF-8. {% callout type="info" %} Encoding detection is statistical. Very short files, or files whose byte patterns happen to look like a different encoding, may occasionally be misidentified. If that happens, converting the file to UTF-8 is the most reliable workaround. 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..d03c6d1faef --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/features/message-feedback.md @@ -0,0 +1,76 @@ +--- +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/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 6c39aa9a1b4..c959cf3f9e1 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 @@ -598,7 +598,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] ``` 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/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png index 4aa53ac29e4..e4954fe821a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c715bd9f470078fea565d3884395009981235a5e6de068c7c529f77f9dd9ebec -size 26962 +oid sha256:f0af006973871a81b4c0ae2fc6c98c88151655afbd9e63d77e970c18db861355 +size 26371 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/agent-behaviour-agents-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png index 795674c1ad6..9a61e5d6f18 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e9f04f72f6227b08bff321ab4af7a794fa754dc58bd504eb88259cd244ba51b -size 27146 +oid sha256:182b21d0047fc054243eee3c21914d20fd125f946c9766fbc203b3817797d668 +size 26645 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png index eac24c76ff1..19e7ee32a14 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44f3c539accb9ef8b41ed54181990f23dba2447db0b0fafee1775522d19b7ab2 -size 46878 +oid sha256:b74f0ca7296bb81df65fe6628bc8709d743bf46f99b4c7cda67d5d1ae7c55d22 +size 47647 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 af2c78e53c1..2283986b267 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:ed6e2cb028e565af2022447c99651bdab8874183a06e64d6ecab6187acffeb92 -size 57319 +oid sha256:f2130db1ea14a5e8fbf879e96529198592a785b8460d29e12746175e102d50d5 +size 53617 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png index 0b37bc8089e..171ce19dea7 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d7505312deae1654e37f2916d26f82b47eb6dc7eb2831e11481de6197172a94 -size 46679 +oid sha256:1eaae02845079c9955e95b258a137a5aae7718a0aa369f59a037a73a613514bb +size 51958 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png index f6e37711f27..2a22e74f26d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7dc43983d81b4cc88568dd71b7b312e73336df029f445a9c00175c9393f4331c -size 56736 +oid sha256:801756a5707206d70b5c3fdcc994df12727c7488cfbfa3e7385e0452110d2e12 +size 54350 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png index 996569f48fb..cbcae5fd2ad 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c87ac5179843ad5dadf73023f87d1f0b0589a81420e9ef406534a335598a0a2 -size 28082 +oid sha256:25f9a2c9499801c7526c6e0dd60a17ecacb873e69e02e7073f33e07b4c96841d +size 28094 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index 2a9f410b3bb..77079da8613 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16e602e6a870e4b66a466a0895f92e9df117f6137809c79ed797f24d2b6a1e88 -size 31028 +oid sha256:73a34a22b24e42731510f9ecd3ea8827a0e39fa9b335294c45c4e8780df9a1f4 +size 35853 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index c75789b1dc0..9cd64a85b00 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.2.42", "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..0cc4931710d 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` diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 0282e2f94bc..269427b7f44 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -8,7 +8,13 @@ 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 { + KILO_API_BASE, + KILO_CHAT_URL, + KILO_EVENT_SERVICE_URL, + 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" @@ -32,7 +38,7 @@ interface KiloRoutesDeps extends ImportDeps { Auth: Auth ModelCache: ModelCache z: Z - Instance: ImportDeps["Instance"] & { disposeAll(): Promise } + InstanceStore: { disposeAllInstances(): Promise } } const FIM_TIMEOUT_MS = 30_000 @@ -79,6 +85,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { SessionCreatedEvent, Identifier, ModelCache, + InstanceStore, } = deps const Organization = z.object({ @@ -210,7 +217,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { ModelCache.clear("kilo") clearModesCache() - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() return c.json(true) }, @@ -524,8 +531,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 +559,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { channelCount: z.number().optional(), secretCount: z.number().optional(), userId: z.string().optional(), + botName: z.string().nullable().optional(), }), ), }, @@ -578,57 +602,52 @@ 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 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 - } + // For OAuth, expires is a millisecond epoch we already track. For + // API tokens we don't have a verified expiry locally — the JWT is + // signed by the cloud and validated by kilo-chat/event-service on + // every request. Use a far-future placeholder so the client cache + // doesn't refetch unnecessarily; on 401 the client clears the + // cache and prompts re-auth. + const expiresAtMs = auth.type === "oauth" ? auth.expires : Date.now() + 365 * 24 * 60 * 60 * 1000 - 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({ + token, + expiresAt: new Date(expiresAtMs).toISOString(), + kiloChatUrl: KILO_CHAT_URL, + eventServiceUrl: KILO_EVENT_SERVICE_URL, + }) }, ) .get( diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 688bbab96d6..8be4c754977 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.2.42", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 857501d6d69..7dd1f9304fa 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -92,4 +92,9 @@ export const dict = { "أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.", "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", + + // 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..e79ef39bf30 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -92,4 +92,9 @@ export const dict = { "Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.", "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", + + // 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..e23e964bbd5 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -97,4 +97,9 @@ export const dict = { "Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.", "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", + + // 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..2a086970b40 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -92,4 +92,9 @@ export const dict = { "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", + + // 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..437f5fc649f 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -92,4 +92,9 @@ export const dict = { "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.", "ui.messagePart.openInDiffViewer": "Im Diff-Viewer ö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..bd80d92857d 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -99,4 +99,9 @@ export const dict = { // Edit-tool header: hover-revealed action opening the diff in a full tab. "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", + + // 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..8b05522cb9f 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -92,4 +92,9 @@ export const dict = { "Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.", "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", + + // 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..b64ace8f1f9 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -92,4 +92,9 @@ export const dict = { "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", + + // 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..9d5214c7a48 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -90,4 +90,9 @@ export const dict = { "このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。", "ui.messagePart.openInDiffViewer": "差分ビューアで開く", + + // 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..6308cf9fc68 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -90,4 +90,9 @@ export const dict = { "이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.", "ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기", + + // 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..4fb55eddca5 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -94,4 +94,9 @@ export const dict = { "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", + + // 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..915801390af 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -92,4 +92,9 @@ export const dict = { "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", + + // 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..67b83e46c29 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -92,4 +92,9 @@ export const dict = { "Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.", "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", + + // 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..d9cff803db5 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -92,4 +92,9 @@ export const dict = { "Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.", "ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий", + + // 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..be90eddc10a 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -92,4 +92,9 @@ export const dict = { "ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง", "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", + + // 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..0ebf2a73f24 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -92,4 +92,9 @@ export const dict = { "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ç", + + // 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..3e325a33f7d 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -92,4 +92,9 @@ export const dict = { "Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.", "ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей", + + // 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..29a57c5290d 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -87,4 +87,9 @@ export const dict = { "关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。", "ui.messagePart.openInDiffViewer": "在差异查看器中打开", + + // 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..08582812567 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -87,4 +87,9 @@ export const dict = { "關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。", "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", + + // 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..67919b6d799 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.2.42", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 51f9a1522ae..3a73539b72e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -6,6 +6,7 @@ import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -49,6 +50,7 @@ class KiloBackendChatManager( "session.turn.close", "session.error", "session.status", + "session.updated", "session.idle", "session.compacted", "session.diff", @@ -156,6 +158,37 @@ class KiloBackendChatManager( } } + // ------ compact ------ + + fun compact(id: String, dir: String, model: ModelSelectionDto) { + log.info("${ChatLogSummary.sid(id)} kind=compact ${ChatLogSummary.dir(dir)} model=${model.providerID}/${model.modelID} op=summarize") + val http = requireClient() + val url = requireBase() + val body = KiloCliDataParser.buildSummarizeJson(model) + val request = Request.Builder() + .url("$url/session/$id/summarize?directory=${encode(dir)}") + .post(body.toRequestBody(JSON_TYPE)) + .build() + + try { + http.newCall(request).execute().use { response -> + val code = response.code + if (!response.isSuccessful) { + val raw = response.body?.string() + log.warn("summarize failed: HTTP $code") + raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=compact op=summarize error=${ChatLogSummary.body(it)}" } } + throw RuntimeException("summarize failed: HTTP $code") + } + log.debug { "${ChatLogSummary.sid(id)} kind=compact op=summarize ok=true code=$code" } + } + } catch (e: RuntimeException) { + throw e + } catch (e: Exception) { + log.warn("${ChatLogSummary.sid(id)} kind=compact op=summarize dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e) + throw RuntimeException("summarize HTTP call failed: ${e.message}", e) + } + } + // ------ messages ------ fun messages(id: String, dir: String): List { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index f06cf03c2f5..8995a1a383e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -174,6 +174,13 @@ object KiloCliDataParser { ChatEventDto.SessionStatusChanged(sid, dto) } + "session.updated" -> { + val info = props["info"]?.jsonObject ?: return null + val dto = parseSessionObject(info) + val sid = props.str("sessionID") ?: dto.id.takeIf { it.isNotBlank() } ?: return null + ChatEventDto.SessionUpdated(sid, dto) + } + "session.idle" -> { val sid = props.str("sessionID") ?: return null ChatEventDto.SessionIdle(sid) @@ -316,6 +323,12 @@ object KiloCliDataParser { return sb.toString() } + /** + * Build the JSON body for `POST /session/{id}/summarize`. + */ + fun buildSummarizeJson(model: ModelSelectionDto): String = + """{"providerID":${escape(model.providerID)},"modelID":${escape(model.modelID)}}""" + /** * Build the partial JSON body for `PATCH /global/config`. */ @@ -363,22 +376,14 @@ object KiloCliDataParser { modelID = obj.str("modelID"), parentID = obj.str("parentID"), cost = obj.num("cost"), - tokens = tokens?.let { - val cache = it["cache"]?.jsonObject - TokensDto( - input = it.long("input") ?: 0, - output = it.long("output") ?: 0, - reasoning = it.long("reasoning") ?: 0, - cacheRead = cache?.long("read") ?: 0, - cacheWrite = cache?.long("write") ?: 0, - ) - }, + tokens = tokens?.let(::parseTokens), error = error?.let { parseError(it) }, ) } internal fun parsePart(obj: JsonObject): PartDto { val state = obj["state"]?.jsonObject + val tokens = obj["tokens"]?.jsonObject val top = obj.map("metadata") val meta = state.map("metadata") + top return PartDto( @@ -396,6 +401,20 @@ object KiloCliDataParser { output = state?.str("output"), error = state?.str("error"), time = obj.time("time") ?: state.time("time"), + reason = obj.str("reason"), + cost = obj.num("cost"), + tokens = tokens?.let(::parseTokens), + ) + } + + private fun parseTokens(obj: JsonObject): TokensDto { + val cache = obj["cache"]?.jsonObject + return TokensDto( + input = obj.long("input") ?: 0, + output = obj.long("output") ?: 0, + reasoning = obj.long("reasoning") ?: 0, + cacheRead = cache?.long("read") ?: 0, + cacheWrite = cache?.long("write") ?: 0, ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 26b98a02a9f..83aecba6219 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -88,6 +89,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun abort(id: String, directory: String) = chat.abort(id, directory) + override suspend fun compact(id: String, directory: String, model: ModelSelectionDto) = + chat.compact(id, directory, model) + override suspend fun messages(id: String, directory: String): List = chat.messages(id, directory) @@ -108,6 +112,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.QuestionReplied -> event.sessionID is ChatEventDto.QuestionRejected -> event.sessionID is ChatEventDto.SessionStatusChanged -> event.sessionID + is ChatEventDto.SessionUpdated -> event.sessionID is ChatEventDto.SessionIdle -> event.sessionID is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 5bbc7198ced..eb1dcd02a75 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -24,6 +24,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SkillDto @@ -142,6 +143,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { status = m.status, recommendedIndex = m.recommendedIndex, variants = m.variants, + limit = m.limit?.let { ModelLimitDto(it.context, it.input, it.output) }, ) private fun agents(d: AgentData) = AgentsDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 71efc268fed..3d5977e1055 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -295,6 +296,7 @@ class KiloBackendWorkspace( private fun model(id: String, obj: JsonObject): ModelInfo { val cap = obj["capabilities"]?.jsonObject + val limit = obj["limit"]?.jsonObject return ModelInfo( id = obj.str("id") ?: id, name = obj.str("name") ?: id, @@ -306,6 +308,13 @@ class KiloBackendWorkspace( status = obj.str("status"), recommendedIndex = obj.num("recommendedIndex"), variants = variants(obj), + limit = limit?.let { + ModelLimitInfo( + context = it.long("context") ?: 0, + input = it.long("input"), + output = it.long("output") ?: 0, + ) + }, ) } @@ -361,3 +370,4 @@ private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.U private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false private fun JsonObject.num(key: String) = this[key]?.jsonPrimitive?.doubleOrNull +private fun JsonObject.long(key: String) = this[key]?.jsonPrimitive?.longOrNull diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 75437e146be..991f679ef48 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -57,6 +57,13 @@ data class ModelInfo( val status: String?, val recommendedIndex: Double?, val variants: List, + val limit: ModelLimitInfo?, +) + +data class ModelLimitInfo( + val context: Long = 0, + val input: Long? = null, + val output: Long = 0, ) data class AgentData( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt new file mode 100644 index 00000000000..fd628ac4e8a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -0,0 +1,42 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.ModelSelectionDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import okhttp3.OkHttpClient +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloBackendChatManagerTest { + + private val mock = MockCliServer() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + @Test + fun `compact posts summarize request with selected model`() { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + chat.compact("ses_abc", "/test/project", ModelSelectionDto("anthropic", "claude-4")) + + assertEquals(1, mock.requestCount("/session/ses_abc/summarize")) + assertNotNull(mock.lastSummarizePath) + assertTrue(mock.lastSummarizePath!!.startsWith("/session/ses_abc/summarize?directory=")) + assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", mock.lastSummarizeBody) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt index 9ad5e710d4b..7ddd445175f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt @@ -188,7 +188,7 @@ class GeneratedApiModelSerializationTest { assertEquals(true, obj.native) assertEquals(false, obj.hidden) assertEquals("#FF5733", obj.color) - assertEquals(5, obj.steps) + assertEquals(5.0, obj.steps) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 63037b9dde9..d47528ce901 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -451,6 +451,32 @@ class KiloCliDataParserTest { assertEquals("Read file.txt", part.title) } + @Test + fun `parseMessages - step finish part with tokens`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", + "type": "step-finish", + "reason": "stop", + "cost": 0.005, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } } + }] + }]""" + + val part = KiloCliDataParser.parseMessages(raw)[0].parts[0] + assertEquals("step-finish", part.type) + assertEquals("stop", part.reason) + assertEquals(0.005, part.cost) + assertEquals(100L, part.tokens?.input) + assertEquals(50L, part.tokens?.output) + assertEquals(10L, part.tokens?.reasoning) + assertEquals(20L, part.tokens?.cacheRead) + assertEquals(5L, part.tokens?.cacheWrite) + } + @Test fun `parseMessages - malformed JSON returns empty`() { assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) @@ -619,6 +645,13 @@ class KiloCliDataParserTest { assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) } + @Test + fun `buildSummarizeJson - writes provider and model`() { + val result = KiloCliDataParser.buildSummarizeJson(ModelSelectionDto("anthropic", "claude-4")) + + assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", result) + } + // ================================================================ // buildConfigPartial // ================================================================ @@ -866,6 +899,32 @@ class KiloCliDataParserTest { assertTrue(result is ChatEventDto.SessionCompacted) } + @Test + fun `parseChatEvent - session updated`() { + val data = globalEvent(""" + "type": "session.updated", + "properties": { + "sessionID": "ses_1", + "info": { + "id": "ses_1", + "projectID": "proj_1", + "directory": "/tmp/project", + "title": "Updated title", + "version": "1", + "time": { "created": 1.0, "updated": 2.0 }, + "summary": { "additions": 3, "deletions": 1, "files": 2 } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("Updated title", result.session.title) + assertEquals(2, result.session.summary?.files) + } + @Test fun `parseChatEvent - session diff`() { val data = globalEvent(""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt index fdd0f9ee416..093b62e9c6a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt @@ -171,7 +171,7 @@ class ProjectModelSerializationTest { assertEquals(true, obj.native) assertEquals(false, obj.hidden) assertEquals("#FF5733", obj.color) - assertEquals(5, obj.steps) + assertEquals(5.0, obj.steps) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 5d794ae5f20..aa1aee34759 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -55,12 +55,16 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" + @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @Volatile var sessionCreateStatus = 200 @Volatile var sessionGetStatus = 200 @Volatile var sessionDeleteStatus = 200 @Volatile var sessionStatusesStatus = 200 + @Volatile var summarizeStatus = 200 + @Volatile var lastSummarizePath: String? = null + @Volatile var lastSummarizeBody: String? = null /** Configurable delay for all endpoint responses (ms). 0 = no delay. */ @Volatile var responseDelay: Long = 0 @@ -173,11 +177,16 @@ class MockCliServer : AutoCloseable { val method = parts[0] val path = parts[1] - // Read all headers + var len = 0 while (true) { val header = input.readLine() if (header.isNullOrBlank()) break + val parts = header.split(":", limit = 2) + if (parts.size == 2 && parts[0].equals("Content-Length", ignoreCase = true)) { + len = parts[1].trim().toIntOrNull() ?: 0 + } } + val body = if (len > 0) CharArray(len).also { input.read(it, 0, len) }.concatToString() else "" val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream())) val bare = path.substringBefore("?") @@ -218,6 +227,11 @@ class MockCliServer : AutoCloseable { respond(output, sessionGetStatus, sessionCreate) bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" -> respond(output, sessionDeleteStatus, "true") + bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { + lastSummarizePath = path + lastSummarizeBody = body + respond(output, summarizeStatus, summarizeResponse) + } else -> respond(output, 404, """{"error":"Not found"}""") } } catch (_: SocketException) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 7831df16c49..13cc7e49567 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -297,6 +297,9 @@ class KiloBackendWorkspaceTest { assertTrue(model.toolCall) assertEquals(2.0, model.recommendedIndex) assertEquals(listOf("low", "medium", "high"), model.variants) + assertEquals(200000L, model.limit?.context) + assertEquals(100000L, model.limit?.input) + assertEquals(16000L, model.limit?.output) } @Test @@ -513,7 +516,7 @@ class KiloBackendWorkspaceTest { "interleaved": false }, "cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}, - "limit": {"context": 200000, "output": 16000}, + "limit": {"context": 200000, "input": 100000, "output": 16000}, "status": "active", "recommendedIndex": 2, "variants": {"high": {}, "low": {}, "medium": {}}, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 7e131c93f7e..9053849503d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -7,6 +7,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -87,6 +88,10 @@ class KiloSessionService internal constructor( suspend fun recent(dir: String, limit: Int): List = call { recent(dir, limit) }.sessions + /** Get a single session. */ + suspend fun get(id: String, dir: String): SessionDto = + call { get(id, dir) } + /** Create a new session. Caller awaits the result. */ suspend fun create(dir: String): SessionDto { LOG.info("create: dir=$dir") @@ -138,6 +143,11 @@ class KiloSessionService internal constructor( call { abort(id, dir) } } + /** Summarize/compact a session. */ + suspend fun compact(id: String, dir: String, model: ModelSelectionDto) { + call { compact(id, dir, model) } + } + /** Load message history for a session. */ suspend fun messages(id: String, dir: String): List = call { messages(id, dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt new file mode 100644 index 00000000000..3a4b2ce7cab --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt @@ -0,0 +1,264 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.client.session.ui.SessionStyleTarget +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.icons.CachedImageIcon +import com.intellij.ui.svg.SvgAttributePatcher +import com.intellij.util.SVGLoader +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Cursor +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JScrollBar + +private const val ICON_DIGEST = 0x5c011b0bb17L +private const val OPAQUE_ALPHA = 255 + +internal class SessionScroll( + private val root: SessionRootPanel, + private val host: JPanel, + private val messages: SessionMessageListPanel, + body: JPanel, +) { + companion object { + private val ICON = IconLoader.getIcon("/icons/scroll-bottom.svg", SessionScroll::class.java) + private const val THRESHOLD = 32 + private const val OPEN_PASSES = 12 + } + + val component = JBScrollPane(body).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + + internal val bar: JScrollBar get() = component.verticalScrollBar + internal val jump: JBLabel + val view: JComponent? get() = component.viewport.view as? JComponent + + private var style = SessionStyle.current() + private var tail = true + private var auto = false + private var opening = false + private var stable = -1 + private var seq = 0 + + init { + jump = JBLabel(patchedIcon(ICON)).apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("session.scroll.bottom") + isVisible = false + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + jumpBottom() + } + }) + } + component.verticalScrollBar.addAdjustmentListener { onScroll() } + root.addOverlay(jump) { _, child -> + val size = child.preferredSize + val gap = JBUI.scale(UiStyle.Space.PAD) + Rectangle( + host.x + host.width - size.width - gap, + host.y + host.height - size.height - gap, + size.width, + size.height, + ) + } + } + + fun show(panel: JPanel) { + if (component.viewport.view === panel) return + (panel as? SessionStyleTarget)?.applyStyle(style) + component.viewport.setView(panel) + component.repaint() + updateJump() + } + + fun atBottom(): Boolean { + val bar = component.verticalScrollBar + return when { + component.viewport.view !== messages -> tail + bar.maximum <= bar.visibleAmount -> true + else -> bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + } + } + + fun followBottom(follow: Boolean) { + if (!follow) { + seq++ + updateJump() + return + } + tail = true + auto = true + show(messages) + auto = false + followPass(++seq, 2) + } + + fun openBottom(done: () -> Unit) { + opening = true + stable = -1 + tail = true + auto = true + show(messages) + auto = false + val id = ++seq + ApplicationManager.getApplication().invokeLater { + openPass(id, OPEN_PASSES, done) + } + } + + fun refresh() { + updateJump() + } + + fun applyStyle(style: SessionStyle) { + this.style = style + jump.icon = patchedIcon(ICON) + messages.applyStyle(style) + val view = component.viewport.view + if (view !== messages) (view as? SessionStyleTarget)?.applyStyle(style) + refresh() + } + + private fun jumpBottom() { + opening = false + stable = -1 + tail = true + auto = true + show(messages) + auto = false + followPass(++seq, 2) + } + + private fun followPass(id: Int, remaining: Int) { + if (id != seq || !tail) return + auto = true + try { + layoutScroll() + scrollToBottom() + updateJump() + } finally { + auto = false + } + if (remaining <= 0) return + ApplicationManager.getApplication().invokeLater { + followPass(id, remaining - 1) + } + } + + private fun openPass(id: Int, remaining: Int, done: () -> Unit) { + if (id != seq) { + opening = false + stable = -1 + return + } + auto = true + val prev = bottom() + try { + tail = true + layoutScroll() + scrollToBottom() + updateJump() + } finally { + auto = false + } + if (remaining <= 0) { + opening = false + stable = -1 + done() + return + } + val next = bottom() + val left = if (next == prev && next == stable) remaining - 1 else OPEN_PASSES + stable = next + ApplicationManager.getApplication().invokeLater { + openPass(id, left, done) + } + } + + private fun layoutScroll() { + root.validate() + } + + private fun scrollToBottom() { + val view = component.viewport.view ?: return + val y = (view.height - component.viewport.extentSize.height).coerceAtLeast(0) + component.viewport.viewPosition = Point(0, y) + (view as? JComponent)?.scrollRectToVisible(Rectangle(0, view.height.coerceAtLeast(1) - 1, 1, 1)) + val bar = component.verticalScrollBar + bar.value = bottom() + } + + private fun bottom(): Int { + val bar = component.verticalScrollBar + return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) + } + + private fun onScroll() { + if (auto || opening) { + updateJump() + return + } + if (component.viewport.view === messages) { + tail = atBottom() + if (!tail) seq++ + } + updateJump() + } + + private fun updateJump() { + val visible = component.viewport.view === messages && !atBottom() + if (jump.isVisible == visible) return + jump.isVisible = visible + root.overlay.revalidate() + root.overlay.repaint() + } +} + +private fun patchedIcon(icon: Icon): Icon { + val cached = icon as? CachedImageIcon ?: return icon + return cached.createWithPatcher(object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { + override fun digest(): LongArray { + val bg = JBUI.CurrentTheme.Button.defaultButtonColorStart().rgb.toLong() + val fg = JBUI.CurrentTheme.Button.defaultButtonForeground().rgb.toLong() + return longArrayOf(bg, fg, ICON_DIGEST) + } + + override fun attributeForPath(path: String) = this + + override fun patchColors(attributes: MutableMap) { + when (attributes["id"]) { + "ScrollButton.Background" -> + set(attributes, "fill", JBUI.CurrentTheme.Button.defaultButtonColorStart()) + + "ScrollButton.Foreground" -> + set(attributes, "stroke", JBUI.CurrentTheme.Button.defaultButtonForeground()) + } + } + + private fun set(attributes: MutableMap, key: String, color: Color) { + if (!attributes.containsKey(key) || attributes[key] == "none") return + attributes[key] = "rgb(${color.red},${color.green},${color.blue})" + if (color.alpha != OPAQUE_ALPHA) { + attributes["$key-opacity"] = "${color.alpha / OPAQUE_ALPHA.toFloat()}" + } + } + }) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 626a81eecb2..d81e9d4437d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -15,8 +15,8 @@ import javax.swing.JPanel class SessionSidePanelManager( private val project: Project, private val root: Workspace, - private val create: (Project, Workspace, SessionManager, String?, Boolean) -> SessionUi = { project, workspace, manager, id, loading -> - service().create(project, workspace, manager, id, loading) + private val create: (Project, Workspace, SessionManager, String?, Boolean, SessionDto?) -> SessionUi = { project, workspace, manager, id, loading, session -> + service().create(project, workspace, manager, id, loading, session) }, private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, ) : SessionManager, Disposable { @@ -37,13 +37,13 @@ class SessionSidePanelManager( val active = current if (active?.blank == true) return register(active) - show(create(project, root, this, null, active == null)) + show(create(project, root, this, null, active == null, null)) } override fun openSession(session: SessionDto) { register(current) val ui = opened.getOrPut(session.id) { - create(project, resolve(session.directory), this, session.id, false).also { + create(project, resolve(session.directory), this, session.id, false, session).also { all.add(it) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index a48feeb65e6..f555b0e6377 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -16,6 +16,7 @@ import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.header.SessionHeaderPanel import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.session.ui.SessionStyleTarget import ai.kilocode.client.session.update.EVENT_FLUSH_MS @@ -30,23 +31,13 @@ import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project -import com.intellij.openapi.util.IconLoader import com.intellij.openapi.util.registry.Registry -import com.intellij.ui.icons.CachedImageIcon -import com.intellij.ui.svg.SvgAttributePatcher import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane -import com.intellij.util.SVGLoader import com.intellij.util.ui.Centerizer -import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout -import java.awt.Color -import java.awt.Rectangle import javax.swing.BoxLayout import javax.swing.BoxLayout.Y_AXIS -import javax.swing.Icon -import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel @@ -66,6 +57,7 @@ class SessionUi private constructor( displayMs: Long, open: (SessionDto) -> Unit, private val loading: Boolean, + session: SessionDto? = null, ) : JPanel(BorderLayout()), Disposable, SessionStyleTarget { constructor( @@ -77,7 +69,8 @@ class SessionUi private constructor( id: String? = null, displayMs: Long = SessionController.DISPLAY_DELAY_MS, open: (SessionDto) -> Unit = {}, - ) : this(project, workspace, sessions, app, cs, id, displayMs, open, id == null) + session: SessionDto? = null, + ) : this(project, workspace, sessions, app, cs, session?.id ?: id, displayMs, open, id == null, session) internal constructor( project: Project, @@ -89,15 +82,18 @@ class SessionUi private constructor( displayMs: Long = SessionController.DISPLAY_DELAY_MS, loading: Boolean, open: (SessionDto) -> Unit = {}, - ) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading) + session: SessionDto? = null, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading, session) companion object { private val LOG = KiloLog.create(SessionUi::class.java) - private val SCROLL_ICON = IconLoader.getIcon("/icons/scroll-bottom.svg", SessionUi::class.java) } private val project = project private val app = app + private var opening = id != null + private var pending = false + private var loaded: Boolean? = null private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) .takeIf { it > 0 } @@ -109,9 +105,11 @@ class SessionUi private constructor( flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), displayMs = displayMs, + session = session, open = open, - beforeUpdate = ::atBottom, - afterUpdate = ::followBottom, + beforeUpdate = { if (opening) false else scroll.atBottom() }, + afterUpdate = { if (!opening) scroll.followBottom(it) }, + loaded = ::onHistoryLoaded, ) @@ -125,9 +123,9 @@ class SessionUi private constructor( private lateinit var messageBody: SessionMessageListPanel - private lateinit var scroll: JBScrollPane + private lateinit var header: SessionHeaderPanel - private lateinit var jump: JButton + internal lateinit var scroll: SessionScroll private lateinit var question: QuestionPanel private lateinit var permission: PermissionPanel @@ -142,7 +140,19 @@ class SessionUi private constructor( bindUi() bindStyle() applyStyle(style) - showBody(if (loading) progressBody else blankBody) + onStateChanged(controller.model.state) + scroll.show(startBody()) + loaded?.let(::finishOpen) + } + + override fun addNotify() { + super.addNotify() + resumeOpen() + } + + override fun doLayout() { + super.doLayout() + resumeOpen() } internal val blank: Boolean get() = controller.blank @@ -171,23 +181,9 @@ class SessionUi private constructor( ), BorderLayout.CENTER) } messageBody = SessionMessageListPanel(controller.model, this) + header = SessionHeaderPanel(controller, this) - scroll = JBScrollPane(blankBody).apply { - border = JBUI.Borders.empty() - verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED - horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER - } - jump = JButton(patchedIcon(SCROLL_ICON)).apply { - border = JBUI.Borders.empty() - isContentAreaFilled = false - isBorderPainted = false - isFocusPainted = false - isFocusable = false - isOpaque = false - toolTipText = KiloBundle.message("session.scroll.bottom") - isVisible = false - addActionListener { jumpBottom() } - } + scroll = SessionScroll(root, sessionContent, messageBody, blankBody) question = QuestionPanel(controller) permission = PermissionPanel(controller) connection = ConnectionPanel(this, controller) @@ -198,7 +194,8 @@ class SessionUi private constructor( onAbort = { controller.abort() }, ) - sessionContent.add(scroll, BorderLayout.CENTER) + sessionContent.add(header, BorderLayout.NORTH) + sessionContent.add(scroll.component, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) // Dock panels stay in normal flow so each visible state takes layout space // above the prompt. @@ -209,17 +206,6 @@ class SessionUi private constructor( add(connection) add(prompt) }, BorderLayout.SOUTH) - root.addOverlay(jump) { _, child -> - val size = child.preferredSize - val gap = JBUI.scale(12) - Rectangle( - sessionContent.x + sessionContent.width - size.width - gap, - sessionContent.y + sessionContent.height - size.height - gap, - size.width, - size.height, - ) - } - add(root, BorderLayout.CENTER) } @@ -230,7 +216,6 @@ class SessionUi private constructor( prompt.onReset = { controller.clearModelOverride() } prompt.model.favorites = { app.favorites.value } prompt.model.onFavoriteToggle = { item -> app.toggleModelFavorite(item.provider, item.id) } - scroll.verticalScrollBar.addAdjustmentListener { updateJump() } controller.addListener(this) { event -> when (event) { @@ -264,16 +249,16 @@ class SessionUi private constructor( } is SessionControllerEvent.ViewChanged.ShowProgress -> { - showBody(progressBody) + scroll.show(progressBody) } is SessionControllerEvent.ViewChanged.ShowRecents -> { val panel = EmptySessionPanel(this, controller, event.recents) - showBody(panel) + scroll.show(panel) } is SessionControllerEvent.ViewChanged.ShowSession -> { - showBody(messageBody) + scroll.show(messageBody) } is SessionControllerEvent.AppChanged, @@ -302,6 +287,8 @@ class SessionUi private constructor( is SessionModelEvent.ContentRemoved, is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, + is SessionModelEvent.SessionUpdated, + is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted, is SessionModelEvent.Cleared -> Unit } @@ -322,10 +309,45 @@ class SessionUi private constructor( }) } + private fun startBody(): JPanel { + if (controller.model.showSession) return messageBody + if (loading) return progressBody + return blankBody + } + + private fun onHistoryLoaded(show: Boolean) { + loaded = show + if (!this::scroll.isInitialized) return + finishOpen(show) + } + + private fun finishOpen(show: Boolean) { + loaded = show + if (!opening) return + if (!show) { + pending = false + opening = false + return + } + pending = true + resumeOpen() + } + + private fun resumeOpen() { + if (!pending || !opening || !this::scroll.isInitialized) return + if (width <= 0 || height <= 0) return + pending = false + scroll.openBottom { + opening = false + } + } + private fun sendPrompt(text: String) { if (text.isBlank()) return LOG.debug { - "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" + val agent = controller.model.agent ?: "none" + val model = controller.model.model ?: "none" + "${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}" } controller.prompt(text) prompt.clear() @@ -352,106 +374,22 @@ class SessionUi private constructor( refresh() } - internal fun atBottom(): Boolean { - val bar = scroll.verticalScrollBar - if (bar.maximum <= bar.visibleAmount) return true - return bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(32) - } - - internal fun followBottom(follow: Boolean) { - if (!follow) return - showBody(messageBody) - scrollToBottom() - updateJump() - ApplicationManager.getApplication().invokeLater { - scroll.viewport.view?.revalidate() - scroll.viewport.view?.doLayout() - scroll.revalidate() - scroll.doLayout() - scrollToBottom() - updateJump() - } - } - - private fun jumpBottom() { - showBody(messageBody) - scrollToBottom() - updateJump() - ApplicationManager.getApplication().invokeLater { - scroll.viewport.view?.revalidate() - scroll.viewport.view?.doLayout() - scroll.revalidate() - scroll.doLayout() - scrollToBottom() - updateJump() - } - } - - private fun scrollToBottom() { - val bar = scroll.verticalScrollBar - bar.value = bar.maximum - } - - private fun updateJump() { - val visible = scroll.viewport.view === messageBody && !atBottom() - if (jump.isVisible == visible) return - jump.isVisible = visible - root.overlay.revalidate() - root.overlay.repaint() - } - private fun refresh() { - updateJump() + scroll.refresh() root.revalidate() root.repaint() } - private fun showBody(panel: JPanel) { - if (scroll.viewport.view === panel) return - (panel as? SessionStyleTarget)?.applyStyle(style) - scroll.viewport.setView(panel) - scroll.revalidate() - scroll.repaint() - updateJump() - } - override fun applyStyle(style: SessionStyle) { this.style = style - jump.icon = patchedIcon(SCROLL_ICON) loadingLabel.font = style.uiFont - messageBody.applyStyle(style) + header.applyStyle(style) prompt.applyStyle(style) - (scroll.viewport.view as? SessionStyleTarget)?.applyStyle(style) + scroll.applyStyle(style) refresh() } override fun dispose() {} } -private fun patchedIcon(icon: Icon): Icon { - val cached = icon as? CachedImageIcon ?: return icon - return cached.createWithPatcher(object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { - override fun digest(): LongArray { - val bg = JBUI.CurrentTheme.Button.defaultButtonColorStart().rgb.toLong() - val fg = JBUI.CurrentTheme.Button.defaultButtonForeground().rgb.toLong() - return longArrayOf(bg, fg, 0x5c011b0bb17L) - } - - override fun attributeForPath(path: String) = this - - override fun patchColors(attributes: MutableMap) { - when (attributes["id"]) { - "ScrollButton.Background" -> set(attributes, "fill", JBUI.CurrentTheme.Button.defaultButtonColorStart()) - "ScrollButton.Foreground" -> set(attributes, "stroke", JBUI.CurrentTheme.Button.defaultButtonForeground()) - } - } - - private fun set(attributes: MutableMap, key: String, color: Color) { - if (!attributes.containsKey(key) || attributes[key] == "none") return - attributes[key] = "rgb(${color.red},${color.green},${color.blue})" - if (color.alpha != 255) attributes["$key-opacity"] = "${color.alpha / 255f}" - } - }) -} - private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index c55e607d926..d4e3b012186 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project @@ -20,14 +21,16 @@ class SessionUiFactory( manager: SessionManager, id: String? = null, loading: Boolean = id == null, + session: SessionDto? = null, ): SessionUi = SessionUi( project = project, workspace = workspace, sessions = project.service(), app = service(), cs = scope(), - id = id, + id = session?.id ?: id, loading = loading, + session = session, open = manager::openSession, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index 039d3786680..61213f0ac76 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -2,6 +2,47 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.PartTimeDto +import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TokensDto + +data class SessionHeaderSnapshot( + val visible: Boolean, + val title: String, + val cost: Double?, + val context: ContextUsage?, + val tokens: TokensDto?, + val timeline: List, + val todos: TodoSummary, + val canCompact: Boolean, +) + +data class ContextUsage( + val tokens: Long, + val percentage: Int?, + val limit: Long?, + val output: Long?, +) + +data class TimelineItem( + val id: String, + val part: Content, + val title: String, + val weight: Int, + val durationMs: Long?, + val active: Boolean, +) + +data class TodoSummary( + val total: Int, + val completed: Int, + val items: List, +) + +data class ModelLimitItem( + val context: Long = 0, + val input: Long? = null, + val output: Long = 0, +) /** A single message with its typed contents. */ class Message( @@ -25,7 +66,7 @@ class Reasoning(id: String) : Content(id) { } /** Tool invocation with lifecycle state. */ -class Tool(id: String, val name: String) : Content(id) { +class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { var state: ToolExecState = ToolExecState.PENDING var title: String? = null var input: Map = emptyMap() @@ -38,6 +79,13 @@ class Tool(id: String, val name: String) : Content(id) { /** Context compaction marker. */ class Compaction(id: String) : Content(id) +/** Assistant step completion marker used by the session header timeline. */ +class StepFinish(id: String) : Content(id) { + var reason: String? = null + var cost: Double? = null + var tokens: TokensDto? = null +} + /** * Generic fallback for part types not yet given a dedicated class. * Preserves the [type] string so unknown content is not silently dropped. @@ -46,6 +94,17 @@ class Generic(id: String, val type: String) : Content(id) enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR } +enum class ToolKind { READ, WRITE, GENERIC } + +private val READ_TOOLS = setOf("read", "glob", "grep", "find", "ls", "diagnostics", "warpgrep") +private val WRITE_TOOLS = setOf("edit", "write", "patch", "multi_edit", "multiedit", "apply_patch") + +fun toolKind(name: String?): ToolKind = when (name?.lowercase()) { + in READ_TOOLS -> ToolKind.READ + in WRITE_TOOLS -> ToolKind.WRITE + else -> ToolKind.GENERIC +} + data class ToolCallRef( val messageId: String, val callId: String, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 72cfc00ed55..89d044dd752 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -8,9 +8,12 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import kotlin.math.roundToInt /** * Pure session model — single source of truth for session content and runtime state. @@ -32,7 +35,7 @@ class SessionModel { companion object { /** Part types that are internal server markers and must never be stored or rendered. */ - val SILENT_PART_TYPES = setOf("step-start", "step-finish") + val SILENT_PART_TYPES = setOf("step-start") } private val entries = LinkedHashMap() @@ -55,6 +58,12 @@ class SessionModel { var state: SessionState = SessionState.Idle private set + var session: SessionDto? = null + private set + + var header: SessionHeaderSnapshot = emptyHeader() + private set + var diff: List = emptyList() private set @@ -95,12 +104,14 @@ class SessionModel { val updated = Message(dto).also { it.parts.putAll(existing.parts) } entries[dto.id] = updated fire(SessionModelEvent.MessageUpdated(updated)) + updateHeader() return false } val msg = Message(dto) entries[dto.id] = msg fire(SessionModelEvent.MessageAdded(msg)) regroup() + updateHeader() return true } @@ -111,6 +122,7 @@ class SessionModel { entries[dto.id] = msg fire(SessionModelEvent.MessageAdded(msg)) regroup() + updateHeader() return msg } @@ -118,12 +130,14 @@ class SessionModel { if (entries.remove(id) == null) return fire(SessionModelEvent.MessageRemoved(id)) regroup() + updateHeader() } fun removeContent(messageId: String, contentId: String) { val msg = entries[messageId] ?: return if (msg.parts.remove(contentId) == null) return fire(SessionModelEvent.ContentRemoved(messageId, contentId)) + updateHeader() } fun updateContent(messageId: String, dto: PartDto) { @@ -137,6 +151,7 @@ class SessionModel { val content = fromDto(dto) msg.parts[dto.id] = content fire(SessionModelEvent.ContentAdded(messageId, content)) + updateHeader() } fun appendDelta(messageId: String, contentId: String, delta: String) { @@ -156,11 +171,20 @@ class SessionModel { fire(SessionModelEvent.ContentAdded(messageId, content)) } fire(SessionModelEvent.ContentDelta(messageId, contentId, delta)) + updateHeader() } fun setState(state: SessionState) { this.state = state fire(SessionModelEvent.StateChanged(state)) + updateHeader() + } + + fun setSession(session: SessionDto) { + if (this.session == session) return + this.session = session + fire(SessionModelEvent.SessionUpdated(session)) + updateHeader() } fun setDiff(diff: List) { @@ -171,15 +195,22 @@ class SessionModel { fun setTodos(todos: List) { this.todos = todos fire(SessionModelEvent.TodosUpdated(todos)) + updateHeader() } fun markCompacted() { compactionCount++ fire(SessionModelEvent.Compacted(compactionCount)) + updateHeader() + } + + fun refreshHeader() { + updateHeader() } fun loadHistory(history: List) { entries.clear() + session = null state = SessionState.Idle diff = emptyList() todos = emptyList() @@ -195,16 +226,19 @@ class SessionModel { } rebuildTurnsSilently() fire(SessionModelEvent.HistoryLoaded) + updateHeader() } fun clear() { entries.clear() turnEntries.clear() + session = null state = SessionState.Idle diff = emptyList() todos = emptyList() compactionCount = 0 fire(SessionModelEvent.Cleared) + updateHeader() } // ------ turn grouping ------ @@ -315,6 +349,7 @@ class SessionModel { existing.done = dto.time?.end != null || dto.time == null } is Tool -> { + existing.kind = toolKind(dto.tool) existing.state = parseToolState(dto.state) existing.title = dto.title existing.input = dto.input @@ -324,9 +359,15 @@ class SessionModel { existing.time = dto.time } is Compaction -> return + is StepFinish -> { + existing.reason = dto.reason + existing.cost = dto.cost + existing.tokens = dto.tokens + } is Generic -> return } fire(SessionModelEvent.ContentUpdated(messageId, existing)) + updateHeader() } private fun fromDto(dto: PartDto, text: CharSequence? = null): Content { @@ -339,7 +380,7 @@ class SessionModel { if (content != null && content.isNotEmpty()) this.content.append(content) done = dto.time?.end != null || dto.time == null } - "tool" -> Tool(dto.id, dto.tool ?: "unknown").apply { + "tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply { state = parseToolState(dto.state) title = dto.title input = dto.input @@ -349,6 +390,11 @@ class SessionModel { time = dto.time } "compaction" -> Compaction(dto.id) + "step-finish" -> StepFinish(dto.id).apply { + reason = dto.reason + cost = dto.cost + tokens = dto.tokens + } else -> Generic(dto.id, dto.type) } } @@ -357,6 +403,61 @@ class SessionModel { for (l in listeners) l.onEvent(event) } + private fun updateHeader() { + val next = buildHeader() + if (next == header) return + header = next + fire(SessionModelEvent.HeaderUpdated(next)) + } + + private fun buildHeader(): SessionHeaderSnapshot { + val items = messages().toList() + if (items.isEmpty()) return emptyHeader() + val last = items.asReversed() + .firstOrNull { it.info.role == "assistant" && (it.info.tokens?.total()?.let { total -> total > 0 } == true) } + val tokens = last?.info?.tokens + val limit = model?.let(::item)?.limit + val total = tokens?.total() ?: 0 + val context = if (tokens == null || total == 0L) null else ContextUsage( + tokens = total, + percentage = limit?.context?.takeIf { it > 0 }?.let { (total.toDouble() / it.toDouble() * 100).roundToInt() }, + limit = limit?.context?.takeIf { it > 0 }, + output = limit?.output?.takeIf { it > 0 }, + ) + val cost = items + .filter { it.info.role == "assistant" } + .sumOf { it.info.cost ?: 0.0 } + .takeIf { it > 0.0 } + val done = todos.count { it.status == "completed" } + return SessionHeaderSnapshot( + visible = items.isNotEmpty(), + title = session?.title?.takeIf { it.isNotBlank() } ?: "New Session", + cost = cost, + context = context, + tokens = tokens, + timeline = timeline(items), + todos = TodoSummary(todos.size, done, todos), + canCompact = !state.isBusy() && model?.let(::parseModelKey) != null, + ) + } + + private fun timeline(items: List): List = items + .filter { it.info.role == "assistant" } + .flatMap { msg -> + msg.parts.values.map { part -> + TimelineItem( + id = "${msg.info.id}/${part.id}", + part = part, + title = part.timelineTitle(), + weight = part.weight().coerceIn(1, 10), + durationMs = (part as? Tool)?.time?.durationMs(), + active = (part as? Tool)?.state == ToolExecState.RUNNING || part is Reasoning && !part.done, + ) + } + } + + private fun item(key: String): ModelItem? = models.firstOrNull { it.key == key } + // ------ string representations ------ /** @@ -435,10 +536,87 @@ data class ModelItem( val recommendedIndex: Double?, val free: Boolean, val variants: List, + val limit: ModelLimitItem?, ) { val key: String get() = "$provider/$id" } +private fun emptyHeader() = SessionHeaderSnapshot( + visible = false, + title = "New Session", + cost = null, + context = null, + tokens = null, + timeline = emptyList(), + todos = TodoSummary(0, 0, emptyList()), + canCompact = false, +) + +private fun TokensDto.total(): Long = listOf(input, output, reasoning, cacheRead, cacheWrite).fold(0L) { sum, value -> + if (value <= 0) return@fold sum + if (Long.MAX_VALUE - sum < value) return@fold Long.MAX_VALUE + sum + value +} + +private fun TokensDto.stepWeight(): Int = (input.coerceIn(0L, 10L) + output.coerceIn(0L, 10L) + reasoning.coerceIn(0L, 10L)) + .coerceIn(1L, 10L) + .toInt() + +private fun parseModelKey(value: String): Pair? { + val slash = value.indexOf('/') + if (slash <= 0 || slash >= value.length - 1) return null + return value.substring(0, slash) to value.substring(slash + 1) +} + +private fun Content.timelineTitle(): String = when (this) { + is Text -> "Text" + is Reasoning -> "Reasoning" + is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name + is Compaction -> "Compaction" + is StepFinish -> "Step finish" + is Generic -> type +} + +private fun Tool.fileActionTitle(): String? { + val verb = when (kind) { + ToolKind.READ -> "Read" + ToolKind.WRITE -> "Write" + ToolKind.GENERIC -> return null + } + val path = listOf("filePath", "path", "file") + .asSequence() + .mapNotNull { + input[it]?.takeIf { value -> value.isNotBlank() } + ?: metadata[it]?.takeIf { value -> value.isNotBlank() } + } + .firstOrNull() + ?: return null + return "$verb ${tail(path).ifBlank { path }}" +} + +private fun tail(path: String): String { + val value = path.trimEnd('/', '\\') + val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) + if (index < 0) return value + return value.substring(index + 1) +} + +private fun Content.weight(): Int = when (this) { + is Text -> content.length / 200 + 1 + is Reasoning -> content.length / 200 + 1 + is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1 + is Compaction -> 2 + is StepFinish -> tokens?.stepWeight() ?: 1 + is Generic -> 1 +} + +private fun ai.kilocode.rpc.dto.PartTimeDto.durationMs(): Long? { + val start = start ?: return null + val end = end ?: return null + if (end < start) return null + return ((end - start) * 1000).toLong() +} + private fun renderMessage(msg: Message): List { val out = mutableListOf() out.add("${msg.info.role}#${msg.info.id}") @@ -454,6 +632,7 @@ private fun renderMessage(msg: Message): List { } is Tool -> out.add(renderTool(part)) is Compaction -> out.add("compaction#${part.id}") + is StepFinish -> out.add("step-finish#${part.id}") is Generic -> out.add("${part.type}#${part.id}") } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 40482f45155..83b79b45b5d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.TodoDto /** @@ -47,6 +48,12 @@ sealed class SessionModelEvent { data class TodosUpdated(val todos: List) : SessionModelEvent() { override fun toString() = "TodosUpdated count=${todos.size}" } + data class SessionUpdated(val session: SessionDto) : SessionModelEvent() { + override fun toString() = "SessionUpdated ${session.id}" + } + data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() { + override fun toString() = "HeaderUpdated visible=${header.visible}" + } data class Compacted(val count: Int) : SessionModelEvent() { override fun toString() = "Compacted count=$count" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index fdb7ae25316..44afdeda402 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -50,14 +50,20 @@ class SessionMessageListPanel( is SessionModelEvent.TurnUpdated -> onTurnUpdated(event.turn) is SessionModelEvent.TurnRemoved -> onTurnRemoved(event.id) - is SessionModelEvent.ContentAdded -> + is SessionModelEvent.ContentAdded -> { msgToView[event.messageId]?.upsertPart(event.content) + refresh() + } - is SessionModelEvent.ContentUpdated -> + is SessionModelEvent.ContentUpdated -> { msgToView[event.messageId]?.upsertPart(event.content) + refresh() + } - is SessionModelEvent.ContentRemoved -> + is SessionModelEvent.ContentRemoved -> { msgToView[event.messageId]?.removePart(event.contentId) + refresh() + } is SessionModelEvent.ContentDelta -> { // Use the full current content from the model rather than @@ -67,6 +73,7 @@ class SessionMessageListPanel( // on first appendDelta and fires both events in sequence). val content = model.content(event.messageId, event.contentId) if (content != null) msgToView[event.messageId]?.upsertPart(content) + refresh() } is SessionModelEvent.HistoryLoaded -> rebuild() @@ -80,6 +87,8 @@ class SessionMessageListPanel( is SessionModelEvent.StateChanged, is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, + is SessionModelEvent.SessionUpdated, + is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted -> Unit } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt index 2e44a9e7910..550b3741746 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt @@ -5,6 +5,7 @@ import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI +import java.awt.Color import java.awt.Font import kotlin.math.roundToInt @@ -13,6 +14,8 @@ data class SessionStyle( val editorScheme: EditorColorsScheme, val editorFamily: String, val editorSize: Int, + val editorForeground: Color, + val editorBackground: Color, val transcriptFont: Font, val smallEditorFont: Font, val boldEditorFont: Font, @@ -44,6 +47,8 @@ data class SessionStyle( editorScheme = scheme, editorFamily = family, editorSize = size, + editorForeground = scheme.defaultForeground, + editorBackground = scheme.defaultBackground, transcriptFont = Font(family, Font.PLAIN, size), smallEditorFont = Font(family, Font.PLAIN, small), boldEditorFont = Font(family, Font.BOLD, size), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt new file mode 100644 index 00000000000..f053a2d28d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt @@ -0,0 +1,150 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.ContextUsage +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JComponent +import javax.swing.JPanel + +internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.inline(), 0)) { + private val used = JBLabel() + private val limit = JBLabel() + private val meter = Meter() + + init { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0) + add(used, BorderLayout.WEST) + add(meter, BorderLayout.CENTER) + add(limit, BorderLayout.EAST) + } + + fun setUsage(value: ContextUsage?) { + val data = data(value) + meter.data = data + isVisible = data != null + used.text = data?.used?.let(::num).orEmpty() + limit.text = data?.limit?.let(::num).orEmpty() + toolTipText = data?.tip() + meter.toolTipText = toolTipText + revalidate() + repaint() + } + + fun applyStyle(style: SessionStyle) { + background = style.editorBackground + foreground = style.editorForeground + meter.background = style.editorBackground + used.font = style.smallUiFont + used.foreground = style.editorForeground + limit.font = style.smallUiFont + limit.foreground = style.editorForeground + } + + fun foregrounds() = listOf(used.foreground, limit.foreground) + + fun used(): Long? = meter.data?.used + + fun reserved(): Long? = meter.data?.reserved + + fun available(): Long? = meter.data?.available + + fun limit(): Long? = meter.data?.limit + + fun trackColor(): Color = meter.trackColor() + + fun usedColor(): Color = meter.data?.let(meter::usedColor) ?: meter.usedColor() + + fun reservedColor(): Color = meter.reservedColor() + + private fun data(value: ContextUsage?): ContextData? { + val ctx = value ?: return null + val max = ctx.limit?.takeIf { it > 0 } ?: return null + if (ctx.tokens <= 0) return null + val used = ctx.tokens.coerceAtMost(max) + val output = ctx.output?.takeIf { it > 0 } ?: 0L + val reserved = output.coerceAtMost(max - used) + val available = (max - used - reserved).coerceAtLeast(0) + return ContextData(used, reserved, available, max, output) + } + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) +} + +private data class ContextData( + val used: Long, + val reserved: Long, + val available: Long, + val limit: Long, + val output: Long, +) { + fun tip(): String { + val lines = mutableListOf(KiloBundle.message("session.header.context.used", num(used), num(limit))) + if (output > 0) lines.add(KiloBundle.message("session.header.context.reserved", num(output))) + if (available > 0) lines.add(KiloBundle.message("session.header.context.available", num(available))) + return lines.joinToString("\n") + } +} + +private class Meter : JComponent() { + var data: ContextData? = null + + init { + isOpaque = false + preferredSize = JBUI.size(80, 4) + minimumSize = JBUI.size(24, 4) + } + + override fun paintComponent(g: Graphics) { + val data = data ?: return + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val h = JBUI.scale(4).coerceAtMost(height).coerceAtLeast(1) + val y = (height - h) / 2 + val arc = JBUI.scale(4) + g2.color = trackColor() + g2.fillRoundRect(0, y, width, h, arc, arc) + val used = segment(data.used, data.limit) + val reserved = segment(data.reserved, data.limit) + fill(g2, 0, y, used, h, usedColor(data), arc) + fill(g2, used, y, reserved, h, reservedColor(), arc) + } finally { + g2.dispose() + } + } + + private fun segment(value: Long, limit: Long): Int { + if (value <= 0 || limit <= 0 || width <= 0) return 0 + return ((value.toDouble() / limit.toDouble()) * width).toInt().coerceIn(0, width) + } + + private fun fill(g: Graphics2D, x: Int, y: Int, w: Int, h: Int, color: Color, arc: Int) { + if (w <= 0) return + g.color = color + g.fillRoundRect(x, y, w, h, arc, arc) + } + + fun trackColor(): Color = shade(0.14f) + + fun usedColor(): Color = shade(0.45f) + + fun usedColor(data: ContextData): Color = usedColor() + + fun reservedColor(): Color = shade(0.28f) + + private fun shade(alpha: Float): Color { + val base = background ?: UiStyle.Colors.panel() + val grey = if (UiStyle.Colors.bright(base)) Color.BLACK else Color.WHITE + return UiStyle.Colors.blend(base, grey, alpha) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/HeaderFormat.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/HeaderFormat.kt new file mode 100644 index 00000000000..719078eddfe --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/HeaderFormat.kt @@ -0,0 +1,48 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.ContextUsage +import com.intellij.ui.components.JBLabel + +internal fun set(label: JBLabel, value: String?) { + val text = value.orEmpty() + if (label.text != text) label.text = text + val show = text.isNotEmpty() + if (label.isVisible != show) label.isVisible = show +} + +internal fun money(value: Double?): String? { + val cost = value ?: return null + if (cost < 0.01) return "\$%.4f".format(cost) + if (cost < 1.0) return "\$%.2f".format(cost) + return "\$%.2f".format(cost) +} + +internal fun contextText(value: ContextUsage?): String? { + val ctx = value ?: return null + val pct = ctx.percentage + if (pct != null) return "$pct%" + if (ctx.tokens > 0) return num(ctx.tokens) + return null +} + +internal fun contextTip(value: ContextUsage?): String? { + val ctx = value ?: return null + val pct = ctx.percentage + if (pct != null) return KiloBundle.message("session.header.context.tooltip.percent", num(ctx.tokens), pct) + if (ctx.tokens > 0) return KiloBundle.message("session.header.context.tooltip.tokens", num(ctx.tokens)) + return null +} + +internal fun todo(done: Int, total: Int): String? { + if (total <= 0) return null + if (done >= total) return KiloBundle.message("session.header.todos.done", total) + return KiloBundle.message("session.header.todos.progress", done, total) +} + +internal fun num(value: Long): String { + val abs = kotlin.math.abs(value) + if (abs < 1_000) return value.toString() + if (abs < 1_000_000) return "%.1fK".format(value / 1_000.0) + return "%.1fM".format(value / 1_000_000.0) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt new file mode 100644 index 00000000000..e4f37d69334 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.session.ui.header + +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.geom.AffineTransform +import javax.swing.Icon + +internal class RotatedIcon(private val base: Icon) : Icon { + override fun getIconWidth(): Int = base.iconWidth + + override fun getIconHeight(): Int = base.iconHeight + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + val tx = AffineTransform() + tx.translate((x + iconWidth / 2.0), (y + iconHeight / 2.0)) + tx.rotate(Math.PI) + tx.translate((-iconWidth / 2.0), (-iconHeight / 2.0)) + g2.transform(tx) + base.paintIcon(c, g2, 0, 0) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt new file mode 100644 index 00000000000..0368fa9b50f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -0,0 +1,455 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.SessionHeaderSnapshot +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.client.session.ui.SessionStyleTarget +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.TokensDto +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import java.awt.event.MouseWheelEvent +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.Icon +import javax.swing.JPanel +import javax.swing.JViewport +import javax.swing.SwingUtilities + +class SessionHeaderPanel( + private val controller: SessionController, + parent: Disposable, +) : BorderLayoutPanel(), SessionStyleTarget { + + companion object { + private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java) + private val CHEVRON_ICON: Icon = IconLoader.getIcon("/icons/chevron-down.svg", SessionHeaderPanel::class.java) + private val CHEVRON_UP_ICON: Icon = RotatedIcon(CHEVRON_ICON) + private val UP_ICON: Icon = IconLoader.getIcon("/icons/arrow-up.svg", SessionHeaderPanel::class.java) + private val DOWN_ICON: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", SessionHeaderPanel::class.java) + private const val TOUCH_BEGIN = 2 + private const val TOUCH_UPDATE = 3 + private const val TOUCH_END = 4 + internal const val EXPANDED_KEY = "kilo.session.header.expanded" + } + + private val title = JBLabel() + private val cost = JBLabel() + private val context = JBLabel() + private val todos = JBLabel() + private val compact = UiStyle.Buttons.HoverIcon().apply { + icon = COMPRESS_ICON + toolTipText = KiloBundle.message("session.header.compact.description") + accessibleContext.accessibleName = KiloBundle.message("session.header.compact") + addActionListener { controller.compact() } + } + private val expand = UiStyle.Buttons.HoverIcon().apply { + icon = CHEVRON_ICON + toolTipText = KiloBundle.message("session.header.expand") + accessibleContext.accessibleName = KiloBundle.message("session.header.expand") + addActionListener { toggle() } + } + private val timeline = TimelinePanel() + private val viewport = JViewport().apply { + isOpaque = false + view = timeline + } + private var press: Point? = null + private var origin = Point() + private var rest = 0.0 + private val bar = ContextBar() + private val tokenTitle = JBLabel(KiloBundle.message("session.header.tokens")) + private val input = JBLabel().apply { + icon = UP_ICON + iconTextGap = UiStyle.Gap.xs() + } + private val output = JBLabel().apply { + icon = DOWN_ICON + iconTextGap = UiStyle.Gap.xs() + } + private val cacheRead = JBLabel().apply { + icon = DOWN_ICON + iconTextGap = UiStyle.Gap.xs() + } + private val cacheWrite = JBLabel().apply { + icon = UP_ICON + iconTextGap = UiStyle.Gap.xs() + } + private val top = BorderLayoutPanel() + private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.inline(), 0)).apply { + isOpaque = false + add(cost) + add(context) + add(compact) + add(expand) + } + private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0) + add(tokenTitle) + add(Box.createHorizontalStrut(UiStyle.Gap.inline())) + add(input) + add(Box.createHorizontalStrut(UiStyle.Gap.small())) + add(output) + add(Box.createHorizontalStrut(UiStyle.Gap.small())) + add(cacheRead) + add(Box.createHorizontalStrut(UiStyle.Gap.small())) + add(cacheWrite) + } + private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.inline(), 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0) + add(todos) + } + private val body = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0) + add(viewport) + add(tokens) + add(bar) + add(todoRow) + } + private var style = SessionStyle.current() + + init { + isOpaque = true + updateUI() + + top.add(title, BorderLayout.CENTER) + top.add(right, BorderLayout.EAST) + add(top, BorderLayout.NORTH) + timeline.addMouseListener(object : MouseAdapter() { + override fun mousePressed(event: MouseEvent) { + press = event.point + origin = viewport.viewPosition + } + + override fun mouseReleased(event: MouseEvent) { + press = null + } + }) + timeline.addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseDragged(event: MouseEvent) { + drag(event) + } + }) + timeline.addMouseWheelListener { scroll(it) } + viewport.addMouseWheelListener { scroll(it) } + + controller.model.addListener(parent) { event -> + when (event) { + is SessionModelEvent.HeaderUpdated -> update(event.header) + + is SessionModelEvent.MessageAdded, + is SessionModelEvent.MessageUpdated, + is SessionModelEvent.MessageRemoved, + is SessionModelEvent.ContentAdded, + is SessionModelEvent.ContentUpdated, + is SessionModelEvent.ContentRemoved, + is SessionModelEvent.ContentDelta, + is SessionModelEvent.StateChanged, + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.SessionUpdated, + is SessionModelEvent.Compacted, + is SessionModelEvent.HistoryLoaded, + is SessionModelEvent.Cleared, + is SessionModelEvent.TurnAdded, + is SessionModelEvent.TurnUpdated, + is SessionModelEvent.TurnRemoved -> Unit + } + } + + applyStyle(style) + syncExpanded(expanded()) + update(controller.model.header) + } + + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.compound( + JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0), + JBUI.Borders.empty(UiStyle.Space.LG, UiStyle.Space.PAD, UiStyle.Space.SM, UiStyle.Space.PAD), + ) + } + + fun update(header: SessionHeaderSnapshot) { + val before = isVisible + title.text = header.title + title.toolTipText = header.title + title.accessibleContext.accessibleName = header.title + isVisible = header.visible + if (!header.visible) { + syncExpanded(false) + if (before) refresh() + return + } + + syncExpanded(expanded()) + + set(cost, money(header.cost)) + set(context, contextText(header.context)) + context.toolTipText = contextTip(header.context) + setTokens(header.tokens) + set(todos, todo(header.todos.completed, header.todos.total)) + todoRow.isVisible = todos.isVisible + + compact.isEnabled = header.canCompact + val appended = timeline.setItems(header.timeline) + sizeTimeline() + if (viewport.isVisible != timeline.isVisible) viewport.isVisible = timeline.isVisible + if (appended) SwingUtilities.invokeLater { endTimeline() } + bar.setUsage(header.context) + refresh() + } + + override fun applyStyle(style: SessionStyle) { + this.style = style + background = style.editorBackground + foreground = style.editorForeground + top.background = style.editorBackground + right.background = style.editorBackground + tokens.background = style.editorBackground + todoRow.background = style.editorBackground + body.background = style.editorBackground + viewport.background = style.editorBackground + title.font = style.boldUiFont + title.foreground = style.editorForeground + cost.font = style.uiFont + cost.foreground = style.editorForeground + context.font = style.uiFont + context.foreground = style.editorForeground + todos.font = style.smallUiFont + todos.foreground = style.editorForeground + tokenTitle.font = style.smallUiFont + tokenTitle.foreground = style.editorForeground + input.font = style.smallUiFont + input.foreground = style.editorForeground + output.font = style.smallUiFont + output.foreground = style.editorForeground + cacheRead.font = style.smallUiFont + cacheRead.foreground = style.editorForeground + cacheWrite.font = style.smallUiFont + cacheWrite.foreground = style.editorForeground + bar.applyStyle(style) + refresh() + } + + internal fun titleText(): String = title.text + + internal fun costText(): String = cost.text + + internal fun contextText(): String = context.text + + internal fun foregrounds() = listOf(title, cost, context, todos, tokenTitle, input, output, cacheWrite, cacheRead) + .map { it.foreground } + + internal fun tokenText(): String = listOf(tokenTitle, input, output, cacheWrite, cacheRead) + .filter { it.isVisible } + .joinToString(" ") { it.text } + + internal fun tokenTip(): String = tokens.toolTipText + + internal fun inputTokenText(): String = input.text + + internal fun outputTokenText(): String = output.text + + internal fun cacheReadText(): String = cacheRead.text + + internal fun cacheWriteText(): String = cacheWrite.text + + internal fun todoText(): String = todos.text + + internal fun todoVisible() = todoRow.isVisible && todos.isVisible + + internal fun compactButton() = compact + + internal fun expandButton() = expand + + internal fun isExpanded() = body.parent === this + + internal fun bodyPanel() = body + + internal fun bodyComponents() = body.components.toList() + + internal fun timelineViewport(): JViewport = viewport + + internal fun tokenPanel() = tokens + + internal fun timelinePanel(): Component = timeline + + internal fun timelineToolTip() = timeline.toolTipText + + internal fun timelineHover() = timeline.hovered() + + internal fun contextBar(): Component = bar + + internal fun contextBarVisible() = bar.isVisible + + internal fun contextBarUsed() = bar.used() + + internal fun contextBarReserved() = bar.reserved() + + internal fun contextBarAvailable() = bar.available() + + internal fun contextBarLimit() = bar.limit() + + internal fun contextBarForegrounds() = bar.foregrounds() + + internal fun contextBarTip() = bar.toolTipText + + internal fun contextBarTrackColor() = bar.trackColor() + + internal fun contextBarUsedColor() = bar.usedColor() + + internal fun contextBarReservedColor() = bar.reservedColor() + + internal fun timelineCount() = timeline.count() + + internal fun timelineParts() = timeline.parts() + + internal fun timelineActive(index: Int) = timeline.active(index) + + internal fun timelinePreferredSize() = timeline.preferredSize + + internal fun timelineBarHeight(index: Int) = timeline.barHeight(index) + + internal fun timelineBarWidth() = timeline.barWidth() + + internal fun timelineViewportPreferredSize() = viewport.preferredSize + + internal fun expandTip() = expand.toolTipText + + private fun setTokens(value: TokensDto?) { + val tk = value + val sent = tk?.input ?: 0L + val received = (tk?.output ?: 0L) + (tk?.reasoning ?: 0L) + val write = tk?.cacheWrite ?: 0L + val read = tk?.cacheRead ?: 0L + val total = listOf(sent, received, write, read).fold(0L) { sum, value -> + if (value <= 0) return@fold sum + if (Long.MAX_VALUE - sum < value) return@fold Long.MAX_VALUE + sum + value + } + + tokenTitle.text = KiloBundle.message("session.header.tokens") + tokens.toolTipText = KiloBundle.message("session.header.tokens.description") + tokenTitle.isVisible = total > 0 + set(input, if (sent > 0) num(sent) else null) + set(output, if (received > 0) num(received) else null) + set(cacheWrite, if (write > 0) KiloBundle.message("session.header.cache.write", num(write)) else null) + set(cacheRead, if (read > 0) KiloBundle.message("session.header.cache.read", num(read)) else null) + tokens.isVisible = total > 0 + } + + private fun toggle() { + val next = !isExpanded() + syncExpanded(next) + PropertiesComponent.getInstance().setValue(EXPANDED_KEY, next.toString()) + refresh() + } + + private fun syncExpanded(expanded: Boolean) { + if (expanded) expand() else collapse() + } + + private fun expand(): Boolean { + if (isExpanded()) return false + add(body, BorderLayout.CENTER) + setExpand(true) + return true + } + + private fun collapse(): Boolean { + val attached = body.parent === this + if (!attached) return false + remove(body) + setExpand(false) + return attached + } + + private fun setExpand(expanded: Boolean) { + val key = if (expanded) "session.header.collapse" else "session.header.expand" + expand.icon = if (expanded) CHEVRON_UP_ICON else CHEVRON_ICON + expand.toolTipText = KiloBundle.message(key) + expand.accessibleContext.accessibleName = KiloBundle.message(key) + } + + private fun expanded() = PropertiesComponent.getInstance().getBoolean(EXPANDED_KEY, true) + + private fun sizeTimeline() { + val size = timeline.preferredSize + viewport.preferredSize = Dimension(0, size.height) + viewport.minimumSize = viewport.preferredSize + viewport.maximumSize = Dimension(Int.MAX_VALUE, viewport.preferredSize.height) + } + + private fun drag(event: MouseEvent) { + val start = press ?: return + val max = limit() + val x = (origin.x - (event.x - start.x)).coerceIn(0, max) + move(x) + } + + private fun endTimeline() { + move(limit()) + } + + private fun scroll(event: MouseWheelEvent) { + if (event.scrollType == TOUCH_BEGIN || event.scrollType == TOUCH_END) { + rest = 0.0 + event.consume() + return + } + val delta = delta(event) + if (delta == 0.0) return + rest += delta + val x = rest.toInt() + if (x == 0) return + if (!move(viewport.viewPosition.x + x)) { + rest = 0.0 + return + } + rest -= x + event.consume() + } + + private fun delta(event: MouseWheelEvent): Double { + if (!event.preciseWheelRotation.isFinite()) return 0.0 + if (event.scrollType == TOUCH_UPDATE) return event.preciseWheelRotation * event.scrollAmount + if (event.scrollType == MouseWheelEvent.WHEEL_BLOCK_SCROLL) return event.preciseWheelRotation * viewport.extentSize.width + return event.preciseWheelRotation * event.scrollAmount * timeline.barWidth() + } + + private fun move(x: Int): Boolean { + val next = x.coerceIn(0, limit()) + if (viewport.viewPosition.x == next) return false + viewport.setViewPosition(Point(next, 0)) + return true + } + + private fun limit(): Int { + val wide = timeline.preferredSize.width.coerceAtLeast(timeline.width) + return (wide - viewport.extentSize.width).coerceAtLeast(0) + } + + private fun refresh() { + revalidate() + repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt new file mode 100644 index 00000000000..3906138b5de --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt @@ -0,0 +1,151 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.model.StepFinish +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.TimelineItem +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import kotlin.math.roundToInt +import javax.swing.JPanel + +internal class TimelinePanel : JPanel() { + companion object { + private const val WIDTH = 12 + private const val MIN = 8 + private const val PAD = 4 + private const val GAP = 2 + } + + private var items: List = emptyList() + private var heights: List = emptyList() + private var hover = -1 + + init { + isOpaque = false + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(event: MouseEvent) { + val idx = index(event) + toolTipText = items.getOrNull(idx)?.title + if (hover == idx) return + hover = idx + repaint() + } + }) + addMouseListener(object : MouseAdapter() { + override fun mouseExited(event: MouseEvent) { + toolTipText = null + if (hover == -1) return + hover = -1 + repaint() + } + }) + } + + fun setItems(items: List): Boolean { + val appended = items.size > this.items.size + val max = items.maxOfOrNull { it.weight }?.coerceAtLeast(1) ?: 1 + this.items = items + heights = items.map { height(it.weight, max) } + val show = items.isNotEmpty() + if (isVisible != show) isVisible = show + revalidate() + repaint() + return appended + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + val g2 = g.create() as Graphics2D + val w = JBUI.scale(WIDTH) + val gap = JBUI.scale(GAP) + val grow = JBUI.scale(1) + val tall = height.takeIf { it > 0 } ?: preferredSize.height + try { + for (idx in items.indices) { + val over = idx == hover + val h = heights[idx] + if (over) grow else 0 + val wide = w + if (over) grow * 2 else 0 + val x = grow + idx * (w + gap) - if (over) grow else 0 + val y = (tall - h).coerceAtLeast(0) + g2.color = color(items[idx]) + g2.fillRect(x, y, wide, h) + } + } finally { + g2.dispose() + } + } + + private fun index(event: MouseEvent): Int { + val w = JBUI.scale(WIDTH) + val gap = JBUI.scale(GAP) + val grow = JBUI.scale(1) + val tall = height.takeIf { it > 0 } ?: preferredSize.height + for (idx in items.indices) { + val h = heights[idx] + val x = grow + idx * (w + gap) + val y = (tall - h).coerceAtLeast(0) + val inside = event.x >= x && event.x < x + w && event.y >= y && event.y < y + h + if (inside) return idx + } + return -1 + } + + override fun getPreferredSize(): Dimension = Dimension(width(), height()) + + override fun getMinimumSize(): Dimension = Dimension(0, height()) + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, height()) + + fun count() = items.size + + fun parts(): List = items.map { it.part } + + fun active(index: Int) = items[index].active + + fun barHeight(index: Int) = heights[index] + + fun barWidth() = JBUI.scale(WIDTH + GAP) + + fun hovered() = hover + + private fun height(weight: Int, max: Int): Int { + val fill = MIN + (weight.toDouble() / max.toDouble()) * (MIN * 3 - MIN - PAD) + return JBUI.scale(fill.roundToInt()) + } + + private fun height(): Int { + val max = heights.maxOrNull() ?: return 0 + return max + JBUI.scale(PAD) + } + + private fun width(): Int { + if (items.isEmpty()) return 0 + return items.size * JBUI.scale(WIDTH) + (items.size - 1) * JBUI.scale(GAP) + JBUI.scale(2) + } + + private fun color(item: TimelineItem): Color { + val part = item.part + if (part is Tool && part.state == ToolExecState.ERROR) return UiStyle.Colors.timelineError + if (part is Text) return UiStyle.Colors.timelineText + if (part is Reasoning) return UiStyle.Colors.timelineText + if (part is Compaction) return UiStyle.Colors.timelineStep + if (part is StepFinish) return UiStyle.Colors.timelineSuccess + if (part !is Tool) return UiStyle.Colors.timelineStep + if (part.kind == ToolKind.READ) return UiStyle.Colors.timelineRead + if (part.kind == ToolKind.WRITE) return UiStyle.Colors.timelineWrite + return UiStyle.Colors.timelineTool + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 0a91d0f8dd3..447ab18a775 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.AgentItem +import ai.kilocode.client.session.model.ModelLimitItem import ai.kilocode.client.session.model.ModelItem import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent @@ -68,9 +69,11 @@ class SessionController( private val flushMs: Long = EVENT_FLUSH_MS, private val condense: Boolean = true, private val displayMs: Long = DISPLAY_DELAY_MS, + session: SessionDto? = null, private val open: (SessionDto) -> Unit = {}, private val beforeUpdate: () -> Boolean = { false }, private val afterUpdate: (Boolean) -> Unit = {}, + private val loaded: (Boolean) -> Unit = {}, ) : Disposable { companion object { @@ -86,7 +89,8 @@ class SessionController( val model = SessionModel() private val listeners = mutableListOf() - private var sessionId: String? = id + private var sessionId: String? = session?.id ?: id + private val initialSession = session private val directory: String get() = workspace.directory private val updates = SessionUpdateQueue( parent, @@ -150,6 +154,9 @@ class SessionController( val session = sessions.create(directory) runEdt { sessionId = session.id + updateModel { + model.setSession(session) + } } val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") @@ -184,6 +191,29 @@ class SessionController( } } + fun compact() { + assertEdt() + val id = sessionId ?: return + if (model.state.isBusy()) return + if (model.isEmpty()) return + val parsed = model.model?.let(::parseModel) ?: return + val sel = ModelSelectionDto(parsed.first, parsed.second) + LOG.debug { "${ChatLogSummary.sid(id)} kind=compact model=${sel.providerID}/${sel.modelID}" } + cs.launch { + try { + sessions.compact(id, directory, sel) + LOG.debug { "${ChatLogSummary.sid(id)} kind=compact ok=true" } + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(id)} kind=compact dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + updateModel { + model.setState(SessionState.Error(e.message ?: KiloBundle.message("session.error.compact"))) + } + } + } + } + } + fun retryConnection() { assertEdt() LOG.debug { @@ -295,6 +325,7 @@ class SessionController( } init { + initialSession?.let { model.setSession(it) } if (sessionId != null) { loadHistory() subscribeEvents() @@ -355,6 +386,7 @@ class SessionController( info.recommendedIndex, info.free, info.variants, + info.limit?.let { ModelLimitItem(it.context, it.input, it.output) }, ) } } @@ -364,6 +396,7 @@ class SessionController( this@SessionController.model.agent = state.agents?.default } syncModelSelection() + model.refreshHeader() } if (state.status == KiloWorkspaceStatusDto.READY) { @@ -387,24 +420,28 @@ class SessionController( if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) } try { + val session = initialSession ?: runCatching { sessions.get(id, directory) }.getOrNull() val items = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" } runEdt { updateModel { this@SessionController.model.loadHistory(items) + if (session != null) this@SessionController.model.setSession(session) } } recoverPending(id) - edt { - if (!model.isEmpty()) { - showMessages() - return@edt - } - refreshRecents(force = true) + runEdt { + val show = !model.isEmpty() + if (show) showMessages() + if (!show) refreshRecents(force = true) + loaded(show) } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - edt { refreshRecents(force = true) } + edt { + refreshRecents(force = true) + loaded(false) + } } finally { edt { if (historyState != state) return@edt @@ -601,6 +638,8 @@ class SessionController( model.setState(state) } + is ChatEventDto.SessionUpdated -> model.setSession(event.session) + is ChatEventDto.SessionIdle -> { // Treat session.idle as an explicit signal to return to Idle. // Only apply if we're not in a more specific non-terminal state. @@ -682,6 +721,7 @@ class SessionController( model.variants = item?.variants ?: emptyList() val saved = key?.let { app.models.value.variant[it] } model.variant = saved?.takeIf { it in model.variants } ?: model.variants.firstOrNull() + model.refreshHeader() } private fun item(key: String): ModelItem? = model.models.firstOrNull { it.key == key } @@ -940,6 +980,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.QuestionReplied -> event.sessionID == id is ChatEventDto.QuestionRejected -> event.sessionID == id is ChatEventDto.SessionStatusChanged -> event.sessionID == id + is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index e291a7ae693..17322436fb2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.session.ui.SessionStyleTarget @@ -42,6 +43,7 @@ class MessageView( // Populate content that already exists (e.g. after loadHistory) for ((_, content) in msg.parts) { + if (content is StepFinish) continue val view = ViewFactory.create(content) view.applyStyle(style) parts[content.id] = view @@ -51,9 +53,11 @@ class MessageView( /** Add or update the renderer for [content]. */ fun upsertPart(content: Content) { + if (content is StepFinish) return val existing = parts[content.id] if (existing != null) { existing.update(content) + refresh() return } val view = ViewFactory.create(content) @@ -61,8 +65,7 @@ class MessageView( parts[content.id] = view add(view) syncBorder() - revalidate() - repaint() + refresh() } /** Remove the renderer for [contentId] if present. */ @@ -70,8 +73,7 @@ class MessageView( val view = parts.remove(contentId) ?: return remove(view) syncBorder() - revalidate() - repaint() + refresh() } private fun syncBorder() { @@ -81,7 +83,9 @@ class MessageView( /** Append a streaming delta to the renderer for [contentId]. */ fun appendDelta(contentId: String, delta: String) { - parts[contentId]?.appendDelta(delta) + val part = parts[contentId] ?: return + part.appendDelta(delta) + refresh() } /** Look up a renderer by part id. */ @@ -96,6 +100,10 @@ class MessageView( override fun applyStyle(style: SessionStyle) { this.style = style for (view in parts.values) view.applyStyle(style) + refresh() + } + + private fun refresh() { revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 502cb8a3634..1954aaa8a48 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -28,10 +28,13 @@ class TextView(text: Text) : PartView() { override fun update(content: Content) { if (content !is Text) return md.set(content.content.toString()) + refresh() } override fun appendDelta(delta: String) { + if (delta.isEmpty()) return md.append(delta) + refresh() } /** Current markdown source — used by tests to assert rendered content. */ @@ -42,6 +45,10 @@ class TextView(text: Text) : PartView() { if (md.font != style.transcriptFont) md.font = style.transcriptFont if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily if (!changed) return + refresh() + } + + private fun refresh() { revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 2015f1a97ee..d7ae3b8e9c4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Tool @@ -21,6 +22,7 @@ object ViewFactory { is Reasoning -> ReasoningView(content) is Tool -> ToolView(content) is Compaction -> CompactionView(content) + is StepFinish -> error("step-finish is timeline-only") is Generic -> GenericView(content) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 1311b1de3b4..b4ffe3717a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.ui import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -50,6 +51,14 @@ object UiStyle { internal const val BORDER_DELTA = 64 internal const val HOVER_ALPHA = 0.35f + val timelineRead: Color = JBColor(Color(0x37, 0x94, 0xff), Color(0x37, 0x94, 0xff)) + val timelineWrite: Color = JBColor(Color(0x00, 0x7f, 0xd4), Color(0x00, 0x7f, 0xd4)) + val timelineTool: Color = JBColor(Color(0x00, 0x7a, 0xcc), Color(0x00, 0x7a, 0xcc)) + val timelineSuccess: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground()) + val timelineError: Color = JBColor(Color(0xf4, 0x87, 0x71), Color(0xf4, 0x87, 0x71)) + val timelineText: Color = JBColor(Color(0x9d, 0x9d, 0x9d), Color(0x9d, 0x9d, 0x9d)) + val timelineStep: Color = JBColor(Color(0x4d, 0x4d, 0x4d), Color(0x4d, 0x4d, 0x4d)) + fun bg(): Color = UIUtil.getPanelBackground() fun fg(): Color = UIUtil.getLabelForeground() @@ -68,6 +77,8 @@ object UiStyle { fun header(): Color = panel() + fun headerBar(): Color = JBUI.CurrentTheme.ToolWindow.headerBackground(false) + /** Local hover color for collapsible transcript card headers. */ fun headerHover(): Color = panelHover() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-down-to-line.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-down-to-line.svg new file mode 100644 index 00000000000..d519d808194 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-down-to-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-down-to-line_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-down-to-line_dark.svg new file mode 100644 index 00000000000..cb9476c8438 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-down-to-line_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-up.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-up.svg new file mode 100644 index 00000000000..e66d89f32f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-up_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-up_dark.svg new file mode 100644 index 00000000000..decedb2977e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/arrow-up_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/chevron-down.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/chevron-down.svg new file mode 100644 index 00000000000..b916dec2a8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/chevron-down_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/chevron-down_dark.svg new file mode 100644 index 00000000000..9d727eb37c2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/chevron-down_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/compress.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/compress.svg new file mode 100644 index 00000000000..7b9ee760792 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/compress.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/compress_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/compress_dark.svg new file mode 100644 index 00000000000..fd7653b5fbc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/compress_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg index 84cd927b8ee..d1dd8bd8cd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg index 902f284642f..a9ede4c7320 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/scroll-bottom_dark.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index b648b527d95..d9376c4e2ee 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -44,8 +44,27 @@ session.part.tool.shell=Shell session.part.tool.truncated=Output truncated in preview. Full output remains in session data. session.error.prompt=Prompt failed +session.error.compact=Session compact failed session.error.unknown=Unknown error +session.header.tokens=Tokens +session.header.tokens.description=Tokens used by the latest assistant response: input, output, cache writes, and cache reads. +session.header.input=in {0} +session.header.output=out {0} +session.header.cache.write=cache write {0} +session.header.cache.read=cache read {0} +session.header.compact=Compact session +session.header.compact.description=Summarize the session to reduce context size +session.header.expand=Show session metrics +session.header.collapse=Hide session metrics +session.header.todos.progress={0}/{1} todos complete +session.header.todos.done=All {0} todos complete +session.header.context.tooltip.percent={0} tokens ({1}% of context) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens used +session.header.context.reserved={0} reserved for output +session.header.context.available={0} available + prompt.placeholder=Type a message... prompt.placeholder.with.shortcuts=Type a message... ({0} to send, {1} for new line) prompt.placeholder.with.send=Type a message... ({0} to send) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt new file mode 100644 index 00000000000..337be345589 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -0,0 +1,331 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CompletableDeferred + +@Suppress("UnstableApiUsage") +class SessionScrollTest : SessionUiTestBase() { + + fun `test session update follows when transcript is at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) + drainScroll() + + assertBottom(bar) + } + + fun `test session update follows when transcript is near bottom threshold`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + val threshold = JBUI.scale(32) + if (bottom(bar) <= threshold) { + fillTranscript(24, start = 24) + } + setValue(bar, bottom(bar) - threshold + 1) + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) + drainScroll() + + assertBottom(bar) + } + + fun `test session update preserves position outside bottom threshold`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + val threshold = JBUI.scale(32) + setValue(bar, bottom(bar) - threshold - 8) + val value = bar.value + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test session update preserves middle scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test user scroll between updates disables following`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail1"))) + drainScroll() + assertBottom(bar) + + setValue(bar, bottom(bar) / 2) + val value = bar.value + emit(ChatEventDto.MessageUpdated("ses_test", message("tail2"))) + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test user scroll cancels pending follow`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail_pending")), flush = false) + forceFlushWithoutDispatch() + setValue(bar, bottom(bar) / 2) + val value = bar.value + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test stale follow does not override later non follow`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail_stale1")), flush = false) + forceFlushWithoutDispatch() + setValue(bar, bottom(bar) / 2) + val value = bar.value + emit(ChatEventDto.MessageUpdated("ses_test", message("tail_stale2"))) + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test user returning to bottom between updates resumes following`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.MessageUpdated("ses_test", message("tail1"))) + drainScroll() + assertEquals(value, bar.value) + + setBottom(bar) + emit(ChatEventDto.MessageUpdated("ses_test", message("tail2"))) + drainScroll() + + assertBottom(bar) + } + + fun `test part delta follows bottom after height growth`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + val id = "stream_bottom" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("stream_part", id, "text", "start\n")), flush = false) + forceFlush() + setBottom(bar) + + repeat(40) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, "stream_part", "text", "line $i\n"), flush = false) + } + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test part delta preserves middle scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + val id = "stream_middle" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("stream_part", id, "text", "start\n")), flush = false) + forceFlush() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + repeat(40) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, "stream_part", "text", "line $i\n"), flush = false) + } + forceFlush() + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test batched update samples scroll once before model changes`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.MessageUpdated("ses_test", message("batch")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("part", "batch", "text", "hello")), flush = false) + forceFlush() + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test state changes do not force scroll when user is in middle`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.TurnOpen("ses_test")) + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test scroll button appears only when transcript is away from bottom`() { + showMessages() + fillTranscript(24) + val button = jumpButton() + val bar = scrollBar() + + setBottom(bar) + drainScroll() + assertFalse(button.isVisible) + + setValue(bar, bottom(bar) / 2) + drainScroll() + assertTrue(button.isVisible) + + setBottom(bar) + drainScroll() + assertFalse(button.isVisible) + } + + fun `test scroll button scrolls transcript to bottom`() { + showMessages() + fillTranscript(24) + val button = jumpButton() + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + drainScroll() + assertTrue(button.isVisible) + + click(button) + drainScroll() + + assertBottom(bar) + assertFalse(button.isVisible) + } + + fun `test scroll button remains hidden outside transcript body`() { + val button = jumpButton() + + settle() + layout() + + assertFalse(button.isVisible) + } + + fun `test history load follows initially empty transcript`() { + rpc.history.addAll(history(24)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + + assertBottom(scrollBar()) + } + + fun `test recovered state after history preserves user scroll position`() { + rpc.history.addAll(history(24)) + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + assertBottom(bar) + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.TurnOpen("ses_test")) + drainScroll() + + assertEquals(value, bar.value) + } + + fun `test existing session scrolls after recovered dock layout`() { + rpc.history.addAll(history(24)) + rpc.pendingPermissionList.add(PermissionRequestDto("perm_pending", "ses_test", "edit", listOf("*.kt"))) + + ui = newUi(id = "ses_test") + settle() + drainScroll() + + assertBottom(scrollBar()) + } + + fun `test replayed event during existing session open cannot cancel initial bottom`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + rpc.history.addAll(history(24)) + + ui = newUi(id = "ses_test") + emit(ChatEventDto.MessageUpdated("ses_test", message("replay")), flush = false) + gate.complete(Unit) + settle() + drainScroll() + + assertBottom(scrollBar()) + } + + fun `test existing session waits for panel layout before initial bottom scroll`() { + rpc.history.addAll(history(24)) + + ui = newUi(id = "ses_test") + ui.setSize(0, 0) + settle() + + ui.setSize(800, 600) + drainScroll() + + assertBottom(scrollBar()) + } + + fun `test existing session scroll waits through deferred transcript revalidation`() { + rpc.history.addAll(history(24)) + + ui = newUi(id = "ses_test") + settle() + scrollView()?.preferredSize + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + scrollView()?.revalidate() + } + drainScroll() + + assertBottom(scrollBar()) + } + + fun `test scroll owns the session viewport`() { + settle() + + assertSame(scrollComponent(), scrollView()?.parent?.parent) + assertFalse(scrollView() is SessionMessageListPanel) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index c346dd1ddcb..e0d31c4c763 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -160,6 +160,15 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf(false), loading) } + fun `test open session seeds session metadata into ui`() { + val manager = manager() + + manager.openSession(session("ses_1", "/test", "Opened title")) + + val controller = active(manager).controller() + assertEquals("Opened title", controller.model.session?.title) + } + fun `test inactive sessions keep queued style updates`() { val manager = manager() manager.openSession(session("ses_1")) @@ -188,10 +197,10 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { val manager = SessionSidePanelManager( project = project, root = workspace, - create = { project, workspace, owner, id, show -> + create = { project, workspace, owner, id, show, session -> created.add(workspace.directory to id) loading.add(show) - SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession).also { + SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession, session = session).also { ui.add(it) Disposer.register(it) { ui.remove(it) } } @@ -219,11 +228,11 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun session(id: String) = session(id, "/test") - private fun session(id: String, dir: String) = SessionDto( + private fun session(id: String, dir: String, title: String = "Session $id") = SessionDto( id = id, projectID = "prj", directory = dir, - title = "Session $id", + title = title, version = "1", time = SessionTimeDto(created = 1.0, updated = 2.0), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index f223106be19..547f7c3a881 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -1,9 +1,5 @@ package ai.kilocode.client.session -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.app.KiloWorkspaceService -import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.Question @@ -17,75 +13,14 @@ import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel -import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.ui.header.SessionHeaderPanel import ai.kilocode.client.session.update.SessionControllerEvent -import ai.kilocode.client.testing.FakeAppRpcApi -import ai.kilocode.client.testing.FakeSessionRpcApi -import ai.kilocode.client.testing.FakeWorkspaceRpcApi -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.MessageDto -import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto -import ai.kilocode.rpc.dto.PartDto -import ai.kilocode.rpc.dto.SessionDto -import ai.kilocode.rpc.dto.SessionTimeDto -import ai.kilocode.rpc.dto.ChatEventDto -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.ui.JBUI import com.intellij.ui.components.JBScrollPane -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import javax.swing.JButton -import javax.swing.JScrollBar import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") -class SessionUiLayoutTest : BasePlatformTestCase() { - - private lateinit var scope: CoroutineScope - private lateinit var sessions: KiloSessionService - private lateinit var app: KiloAppService - private lateinit var workspaces: KiloWorkspaceService - private lateinit var rpc: FakeSessionRpcApi - private lateinit var workspace: Workspace - private lateinit var ui: SessionUi - - override fun setUp() { - super.setUp() - scope = CoroutineScope(SupervisorJob()) - - rpc = FakeSessionRpcApi() - val appRpc = FakeAppRpcApi().also { - it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) - } - val workspaceRpc = FakeWorkspaceRpcApi().also { - it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) - } - - sessions = KiloSessionService(project, scope, rpc) - app = KiloAppService(scope, appRpc) - workspaces = KiloWorkspaceService(scope, workspaceRpc) - workspace = workspaces.workspace("/test") - - ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0).apply { - setSize(800, 600) - } - layout() - } - - override fun tearDown() { - try { - scope.cancel() - } finally { - super.tearDown() - } - } +class SessionUiLayoutTest : SessionUiTestBase() { fun `test root contains content and overlay layers`() { val root = find(ui) @@ -111,6 +46,17 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertEquals(listOf(question, permission, connection, prompt), stack.components.toList()) } + fun `test header is docked above shared scroll pane and hidden while empty`() { + val root = find(ui) + val header = find(ui) + val scroll = find(ui) + + assertSame(root.content, header.parent.parent) + assertSame(scroll.parent, header.parent) + assertTrue(header.y <= scroll.y) + assertFalse(header.isVisible) + } + fun `test default focused component is prompt editor`() { val prompt = find(ui) @@ -172,43 +118,37 @@ class SessionUiLayoutTest : BasePlatformTestCase() { fun `test empty and message bodies share the same scroll pane`() { settle() - val scroll = find(ui) + val pane = scrollComponent() val empty = find(ui) - assertSame(empty, scroll.viewport.view) + assertSame(empty, scrollView()) com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { controller().prompt("hello") } layout() - assertSame(scroll, find(ui).parent.parent) - assertSame(find(ui), scroll.viewport.view) + assertSame(pane, find(ui).parent.parent) + assertSame(find(ui), scrollView()) } fun `test new session starts with loading body`() { - ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { - setSize(800, 600) - } + ui = newUi(displayMs = 1_000) - assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(scrollView() is EmptySessionPanel) } fun `test action-created new session starts blank`() { - ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000, loading = false).apply { - setSize(800, 600) - } + ui = newUi(displayMs = 1_000, loading = false) - assertFalse(find(ui).viewport.view is EmptySessionPanel) - assertFalse(find(ui).viewport.view is SessionMessageListPanel) + assertFalse(scrollView() is EmptySessionPanel) + assertFalse(scrollView() is SessionMessageListPanel) } fun `test clicking recent session calls opener`() { val opened = mutableListOf() rpc.recent.add(session("ses_1")) - ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0, open = { opened.add(it.id) }).apply { - setSize(800, 600) - } + ui = newUi(open = { opened.add(it.id) }) settle() layout() @@ -218,346 +158,61 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } fun `test existing session id loads history and shows message body`() { + rpc.history.addAll(history(1)) + + ui = newUi(id = "ses_test") + settle() + + assertSame(find(ui), scrollView()) + } + + fun `test existing session history shows header above scroll pane`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply { setSize(800, 600) } settle() + layout() - assertSame(find(ui), find(ui).viewport.view) + val header = find(ui) + val scroll = find(ui) + assertTrue(header.isVisible) + assertTrue(header.y + header.height <= scroll.y) } fun `test new session keeps loading body before recents delay`() { rpc.recentGate = kotlinx.coroutines.CompletableDeferred() - ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { - setSize(800, 600) - } + ui = newUi(displayMs = 1_000) settleShort(100) - assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(scrollView() is EmptySessionPanel) } fun `test slow recents switch to loading body only after progress event`() { rpc.recentGate = kotlinx.coroutines.CompletableDeferred() rpc.recent.add(session("ses_1")) - ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 50).apply { - setSize(800, 600) - } + ui = newUi(displayMs = 50) settleShort(20) - assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(scrollView() is EmptySessionPanel) settleShort(80) - assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(scrollView() is EmptySessionPanel) rpc.recentGate!!.complete(Unit) settle() val panel = find(ui) - assertSame(panel, find(ui).viewport.view) + assertSame(panel, scrollView()) assertEquals(1, panel.recentCount()) } - fun `test session update follows when transcript is at bottom`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - setBottom(bar) - - emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) - drainScroll() - - assertBottom(bar) - } - - fun `test session update follows when transcript is near bottom threshold`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - val threshold = JBUI.scale(32) - if (bottom(bar) <= threshold) { - fillTranscript(24, start = 24) - } - setValue(bar, bottom(bar) - threshold + 1) - - emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) - drainScroll() - - assertBottom(bar) - } - - fun `test session update preserves position outside bottom threshold`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - val threshold = JBUI.scale(32) - setValue(bar, bottom(bar) - threshold - 8) - val value = bar.value - - emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) - drainScroll() - - assertEquals(value, bar.value) - } - - fun `test session update preserves middle scroll position`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - setValue(bar, bottom(bar) / 2) - val value = bar.value - - emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) - drainScroll() - - assertEquals(value, bar.value) - } - - fun `test user scroll between updates disables following`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - setBottom(bar) - - emit(ChatEventDto.MessageUpdated("ses_test", message("tail1"))) - drainScroll() - assertBottom(bar) - - setValue(bar, bottom(bar) / 2) - val value = bar.value - emit(ChatEventDto.MessageUpdated("ses_test", message("tail2"))) - drainScroll() - - assertEquals(value, bar.value) - } - - fun `test user returning to bottom between updates resumes following`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - setValue(bar, bottom(bar) / 2) - val value = bar.value - - emit(ChatEventDto.MessageUpdated("ses_test", message("tail1"))) - drainScroll() - assertEquals(value, bar.value) - - setBottom(bar) - emit(ChatEventDto.MessageUpdated("ses_test", message("tail2"))) - drainScroll() - - assertBottom(bar) - } - - fun `test batched update samples scroll once before model changes`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - setValue(bar, bottom(bar) / 2) - val value = bar.value - - emit(ChatEventDto.MessageUpdated("ses_test", message("batch")), flush = false) - emit(ChatEventDto.PartUpdated("ses_test", part("part", "batch", "text", "hello")), flush = false) - forceFlush() - drainScroll() - - assertEquals(value, bar.value) - } - - fun `test state changes do not force scroll when user is in middle`() { - showMessages() - fillTranscript(24) - val bar = scrollBar() - setValue(bar, bottom(bar) / 2) - val value = bar.value - - emit(ChatEventDto.TurnOpen("ses_test")) - drainScroll() - - assertEquals(value, bar.value) - } - - fun `test scroll button appears only when transcript is away from bottom`() { - showMessages() - fillTranscript(24) - val button = jumpButton() - val bar = scrollBar() - - setBottom(bar) - drainScroll() - assertFalse(button.isVisible) - - setValue(bar, bottom(bar) / 2) - drainScroll() - assertTrue(button.isVisible) - - setBottom(bar) - drainScroll() - assertFalse(button.isVisible) - } - - fun `test scroll button scrolls transcript to bottom`() { - showMessages() - fillTranscript(24) - val button = jumpButton() - val bar = scrollBar() - setValue(bar, bottom(bar) / 2) - drainScroll() - assertTrue(button.isVisible) - - button.doClick() - drainScroll() - - assertBottom(bar) - assertFalse(button.isVisible) - } - - fun `test scroll button remains hidden outside transcript body`() { - val button = jumpButton() - - settle() - layout() - - assertFalse(button.isVisible) - } - - fun `test history load follows initially empty transcript`() { - rpc.history.addAll(history(24)) - ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply { - setSize(800, 600) - } - settle() - drainScroll() - - assertBottom(scrollBar()) - } - - fun `test recovered state after history preserves user scroll position`() { - rpc.history.addAll(history(24)) - rpc.statuses.value = mapOf("ses_test" to ai.kilocode.rpc.dto.SessionStatusDto("busy")) - ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply { - setSize(800, 600) - } - settle() - drainScroll() - val bar = scrollBar() - setValue(bar, bottom(bar) / 2) - val value = bar.value - - emit(ChatEventDto.TurnOpen("ses_test")) - drainScroll() - - assertEquals(value, bar.value) - } - - private fun layout() { - ui.doLayout() - val root = find(ui) - root.doLayout() - root.content.doLayout() - find(ui).parent.doLayout() - find(ui).doLayout() - (find(ui).viewport.view as? java.awt.Container)?.doLayout() - } - - private fun settle() = runBlocking { - repeat(5) { - delay(100) - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() - } - } - - private fun settleShort(ms: Long) = runBlocking { - delay(ms) - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() - } - private fun showConnection() { find(ui).onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) } - private fun showMessages() { - controller().prompt("hello") - settle() - layout() - } - - private fun fillTranscript(count: Int, start: Int = 0) { - repeat(count) { offset -> - val i = start + offset - val id = "msg_$i" - emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) - emit(ChatEventDto.PartUpdated("ses_test", part("part_$i", id, "text", text(i))), flush = false) - } - settleShort(100) - forceFlush() - drainScroll() - } - - private fun emit(event: ChatEventDto, flush: Boolean = true) { - runBlocking { rpc.events.emit(event) } - if (flush) { - settleShort(20) - forceFlush() - } - } - - private fun forceFlush() { - controller().flushEvents() - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() - } - - private fun drainScroll() { - repeat(4) { - layout() - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() - } - } - - private fun scrollBar(): JScrollBar = find(ui).verticalScrollBar - - private fun jumpButton(): JButton { - return find(ui).overlay.components.single() as JButton - } - - private fun bottom(bar: JScrollBar): Int = (bar.maximum - bar.visibleAmount).coerceAtLeast(0) - - private fun setBottom(bar: JScrollBar) { - setValue(bar, bottom(bar)) - } - - private fun setValue(bar: JScrollBar, value: Int) { - bar.value = value.coerceIn(bar.minimum, bottom(bar)) - } - - private fun assertBottom(bar: JScrollBar) { - assertTrue("value=${bar.value} bottom=${bottom(bar)} max=${bar.maximum} visible=${bar.visibleAmount}", bar.value >= bottom(bar) - 1) - } - - private inline fun find(root: java.awt.Container): T { - return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") - } - - private fun find(root: java.awt.Container, cls: Class): T? { - if (cls.isInstance(root)) return cls.cast(root) - for (child in root.components) { - if (cls.isInstance(child)) return cls.cast(child) - if (child is java.awt.Container) { - val item = find(child, cls) - if (item != null) return item - } - } - return null - } - - private fun controller(): SessionController { - val field = SessionUi::class.java.getDeclaredField("controller") - field.isAccessible = true - return field.get(ui) as SessionController - } - private fun questionStateChanged() = SessionState.AwaitingQuestion( Question( id = "q1", @@ -583,35 +238,4 @@ class SessionUiLayoutTest : BasePlatformTestCase() { meta = PermissionMeta(raw = emptyMap()), ) ) - - private fun session(id: String) = SessionDto( - id = id, - projectID = "prj", - directory = "/test", - title = "Recent $id", - version = "1", - time = SessionTimeDto(created = 1.0, updated = 2.0), - ) - - private fun message(id: String) = MessageDto( - id = id, - sessionID = "ses_test", - role = "user", - time = MessageTimeDto(created = 0.0), - ) - - private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto( - id = id, - sessionID = "ses_test", - messageID = mid, - type = type, - text = text, - ) - - private fun history(count: Int): List = List(count) { i -> - val id = "hist_$i" - MessageWithPartsDto(message(id), listOf(part("hist_part_$i", id, "text", text(i)))) - } - - private fun text(i: Int): String = "line $i\n".repeat(12) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt new file mode 100644 index 00000000000..96ba3937824 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -0,0 +1,232 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.Container +import java.awt.event.MouseEvent +import javax.swing.JLabel +import javax.swing.JComponent +import javax.swing.JScrollBar + +@Suppress("UnstableApiUsage") +abstract class SessionUiTestBase : BasePlatformTestCase() { + protected lateinit var scope: CoroutineScope + protected lateinit var sessions: KiloSessionService + protected lateinit var app: KiloAppService + protected lateinit var workspaces: KiloWorkspaceService + protected lateinit var rpc: FakeSessionRpcApi + protected lateinit var workspace: Workspace + protected lateinit var ui: SessionUi + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + + rpc = FakeSessionRpcApi() + val appRpc = FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + } + val workspaceRpc = FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + } + + sessions = KiloSessionService(project, scope, rpc) + app = KiloAppService(scope, appRpc) + workspaces = KiloWorkspaceService(scope, workspaceRpc) + workspace = workspaces.workspace("/test") + + ui = newUi() + layout() + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + protected fun newUi( + id: String? = null, + displayMs: Long = 0, + loading: Boolean = id == null, + open: (SessionDto) -> Unit = {}, + ): SessionUi { + return SessionUi(project, workspace, sessions, app, scope, id = id, displayMs = displayMs, loading = loading, open = open).apply { + setSize(800, 600) + } + } + + protected fun layout() { + ui.doLayout() + val root = find(ui) + root.doLayout() + root.content.doLayout() + find(ui).parent.doLayout() + scrollComponent().doLayout() + (scrollView() as? Container)?.doLayout() + } + + protected fun settle() = runBlocking { + repeat(5) { + delay(100) + UIUtil.dispatchAllInvocationEvents() + } + } + + protected fun settleShort(ms: Long) = runBlocking { + delay(ms) + UIUtil.dispatchAllInvocationEvents() + } + + protected fun showMessages() { + controller().prompt("hello") + settle() + layout() + } + + protected fun fillTranscript(count: Int, start: Int = 0) { + repeat(count) { offset -> + val i = start + offset + val id = "msg_$i" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("part_$i", id, "text", text(i))), flush = false) + } + settleShort(100) + forceFlush() + drainScroll() + } + + protected fun emit(event: ChatEventDto, flush: Boolean = true) { + runBlocking { rpc.events.emit(event) } + if (flush) { + settleShort(20) + forceFlush() + } + } + + protected fun forceFlush() { + controller().flushEvents() + UIUtil.dispatchAllInvocationEvents() + } + + protected fun forceFlushWithoutDispatch() { + controller().flushEvents() + } + + protected fun drainScroll() { + repeat(4) { + layout() + UIUtil.dispatchAllInvocationEvents() + } + } + + private fun scroll(): SessionScroll = ui.scroll + + protected fun scrollComponent(): JComponent = scroll().component + + protected fun scrollView(): JComponent? = scroll().view + + protected fun scrollBar(): JScrollBar = scroll().bar + + protected fun jumpButton(): JLabel = scroll().jump + + protected fun click(label: JLabel) { + val event = MouseEvent(label, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false) + for (listener in label.mouseListeners) listener.mouseClicked(event) + } + + protected fun bottom(bar: JScrollBar): Int = (bar.maximum - bar.visibleAmount).coerceAtLeast(0) + + protected fun setBottom(bar: JScrollBar) { + setValue(bar, bottom(bar)) + } + + protected fun setValue(bar: JScrollBar, value: Int) { + bar.value = value.coerceIn(bar.minimum, bottom(bar)) + } + + protected fun assertBottom(bar: JScrollBar) { + assertTrue("value=${bar.value} bottom=${bottom(bar)} max=${bar.maximum} visible=${bar.visibleAmount}", bar.value >= bottom(bar) - 1) + } + + protected inline fun find(root: Container): T { + return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") + } + + protected fun find(root: Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is Container) { + val item = find(child, cls) + if (item != null) return item + } + } + return null + } + + protected fun controller(): SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as SessionController + } + + protected fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Recent $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + protected fun message(id: String) = MessageDto( + id = id, + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + ) + + protected fun part(id: String, mid: String, type: String, text: String? = null) = PartDto( + id = id, + sessionID = "ses_test", + messageID = mid, + type = type, + text = text, + ) + + protected fun history(count: Int): List = List(count) { i -> + val id = "hist_$i" + MessageWithPartsDto(message(id), listOf(part("hist_part_$i", id, "text", text(i)))) + } + + protected fun text(i: Int): String = "line $i\n".repeat(12) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index c29b2841cfc..fa3d3856afa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -10,7 +10,10 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PartTimeDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.UsefulTestCase @@ -26,7 +29,9 @@ class SessionModelTest : UsefulTestCase() { parent = Disposer.newDisposable("test") model = SessionModel() events = mutableListOf() - model.addListener(parent) { events.add(it) } + model.addListener(parent) { + if (it !is SessionModelEvent.HeaderUpdated) events.add(it) + } } override fun tearDown() { @@ -143,10 +148,27 @@ class SessionModelTest : UsefulTestCase() { val p = model.message("m1")!!.parts["p1"] as Tool assertEquals("bash", p.name) + assertEquals(ToolKind.GENERIC, p.kind) assertEquals(ToolExecState.RUNNING, p.state) assertEquals("ls", p.title) } + fun `test updateContent tool derives tool kind from name`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "tool", tool = "read")) + model.updateContent("m1", part("p2", "m1", "tool", tool = "glob")) + model.updateContent("m1", part("p3", "m1", "tool", tool = "write")) + model.updateContent("m1", part("p4", "m1", "tool", tool = "apply_patch")) + model.updateContent("m1", part("p5", "m1", "tool", tool = "bash")) + + assertEquals(ToolKind.READ, (model.message("m1")!!.parts["p1"] as Tool).kind) + assertEquals(ToolKind.READ, (model.message("m1")!!.parts["p2"] as Tool).kind) + assertEquals(ToolKind.WRITE, (model.message("m1")!!.parts["p3"] as Tool).kind) + assertEquals(ToolKind.WRITE, (model.message("m1")!!.parts["p4"] as Tool).kind) + assertEquals(ToolKind.GENERIC, (model.message("m1")!!.parts["p5"] as Tool).kind) + } + fun `test updateContent tool stores rich fields`() { model.addMessage(msg("m1", "assistant")) @@ -183,6 +205,7 @@ class SessionModelTest : UsefulTestCase() { model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "completed")) val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals(ToolKind.GENERIC, p.kind) assertEquals(ToolExecState.COMPLETED, p.state) assertTrue(events.single() is SessionModelEvent.ContentUpdated) } @@ -221,14 +244,25 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events.isEmpty()) } - fun `test updateContent silently drops step-finish parts`() { + fun `test updateContent stores step-finish parts`() { model.addMessage(msg("m1", "assistant")) events.clear() - model.updateContent("m1", part("p1", "m1", "step-finish")) + model.updateContent("m1", part( + "p1", + "m1", + "step-finish", + reason = "stop", + cost = 0.005, + tokens = TokensDto(100, 50, 10, 20, 5), + )) - assertNull(model.message("m1")!!.parts["p1"]) - assertTrue(events.isEmpty()) + val part = model.message("m1")!!.parts["p1"] + assertTrue(part is StepFinish) + assertEquals("stop", (part as StepFinish).reason) + assertEquals(0.005, part.cost) + assertEquals(100L, part.tokens?.input) + assertTrue(events.single() is SessionModelEvent.ContentAdded) } fun `test updateContent unknown type stored as Generic`() { @@ -448,7 +482,7 @@ class SessionModelTest : UsefulTestCase() { assertEquals("snapshot", (entry.parts["p2"] as Generic).type) } - fun `test loadHistory silently drops step-start and step-finish parts`() { + fun `test loadHistory drops step-start and preserves step-finish parts`() { val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") val stepStart = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "step-start") val stepFinish = PartDto(id = "p3", sessionID = "s1", messageID = "m1", type = "step-finish") @@ -456,7 +490,8 @@ class SessionModelTest : UsefulTestCase() { model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish)))) val entry = model.message("m1")!! - assertEquals(listOf("p1"), entry.parts.keys.toList()) + assertEquals(listOf("p1", "p3"), entry.parts.keys.toList()) + assertTrue(entry.parts["p3"] is StepFinish) } fun `test upsertMessage adds new message and returns true`() { @@ -622,7 +657,9 @@ class SessionModelTest : UsefulTestCase() { Disposer.register(parent, child) val extra = mutableListOf() - model.addListener(child) { extra.add(it) } + model.addListener(child) { + if (it !is SessionModelEvent.HeaderUpdated) extra.add(it) + } model.addMessage(msg("m1", "user")) assertEquals(2, extra.size) // MessageAdded + TurnAdded @@ -634,11 +671,114 @@ class SessionModelTest : UsefulTestCase() { assertTrue(extra.isEmpty()) } - private fun msg(id: String, role: String) = MessageDto( + fun `test header snapshot hidden with no messages`() { + assertFalse(model.header.visible) + assertEquals("New Session", model.header.title) + assertFalse(model.header.canCompact) + } + + fun `test header snapshot totals assistant cost only`() { + model.model = "kilo/gpt-5" + model.upsertMessage(msg("u1", "user", cost = 10.0)) + model.upsertMessage(msg("a1", "assistant", cost = 0.25)) + model.upsertMessage(msg("a2", "assistant", cost = 0.75)) + + assertTrue(model.header.visible) + assertEquals(1.0, model.header.cost) + assertTrue(model.header.canCompact) + } + + fun `test header snapshot uses last assistant tokens and model limit`() { + model.models = listOf(ModelItem( + id = "gpt-5", + display = "GPT-5", + provider = "kilo", + providerName = "Kilo", + recommendedIndex = null, + free = false, + variants = emptyList(), + limit = ModelLimitItem(context = 1000, input = 800, output = 200), + )) + model.model = "kilo/gpt-5" + model.upsertMessage(msg("a1", "assistant", tokens = TokensDto(100, 50, 25, 25, 0))) + model.upsertMessage(msg("a2", "assistant", tokens = TokensDto(200, 100, 0, 0, 0))) + + assertEquals(300L, model.header.context?.tokens) + assertEquals(30, model.header.context?.percentage) + assertEquals(1000L, model.header.context?.limit) + assertEquals(200L, model.header.context?.output) + assertEquals(200L, model.header.tokens?.input) + } + + fun `test header snapshot tracks todo summary`() { + model.upsertMessage(msg("a1", "assistant")) + model.setTodos(listOf( + TodoDto("Write tests", "completed", "high"), + TodoDto("Ship it", "pending", "medium"), + )) + + assertEquals(2, model.header.todos.total) + assertEquals(1, model.header.todos.completed) + assertEquals("Write tests", model.header.todos.items[0].content) + } + + fun `test header snapshot tracks session title and timeline`() { + model.setSession(session("Updated title")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("t1", "a1", "tool", tool = "bash", state = "running", title = "Run tests", time = PartTimeDto(1.0, 3.0))) + model.updateContent("a1", part("s1", "a1", "step-finish", tokens = TokensDto(200, 100, 25, 0, 0))) + + val item = model.header.timeline[0] + val step = model.header.timeline[1] + assertEquals("Updated title", model.header.title) + assertTrue(item.part is Tool) + assertEquals("bash", (item.part as Tool).name) + assertEquals("Run tests", item.title) + assertEquals(2000L, item.durationMs) + assertTrue(item.active) + assertTrue(step.part is StepFinish) + assertEquals("Step finish", step.title) + assertEquals(10, step.weight) + assertFalse(step.active) + } + + fun `test header timeline clamps large step finish token weight`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("s1", "a1", "step-finish", tokens = TokensDto(Long.MAX_VALUE, 1, 1, 0, 0))) + + assertEquals(10, model.header.timeline.single().weight) + } + + fun `test loadHistory and clear reset header state`() { + model.setSession(session("Old title")) + model.upsertMessage(msg("a1", "assistant", cost = 1.0)) + + model.loadHistory(emptyList()) + assertFalse(model.header.visible) + assertEquals("New Session", model.header.title) + + model.upsertMessage(msg("a2", "assistant", cost = 1.0)) + model.clear() + assertFalse(model.header.visible) + assertNull(model.header.cost) + } + + private fun msg(id: String, role: String, cost: Double? = null, tokens: TokensDto? = null) = MessageDto( id = id, sessionID = "ses", role = role, time = MessageTimeDto(created = 0.0), + cost = cost, + tokens = tokens, + ) + + private fun session(title: String) = SessionDto( + id = "ses", + projectID = "proj", + directory = "/test", + title = title, + version = "1", + time = SessionTimeDto(created = 0.0, updated = 0.0), ) private fun part( @@ -654,6 +794,9 @@ class SessionModelTest : UsefulTestCase() { output: String? = null, error: String? = null, time: PartTimeDto? = null, + reason: String? = null, + cost: Double? = null, + tokens: TokensDto? = null, ) = PartDto( id = id, sessionID = "ses", @@ -668,6 +811,9 @@ class SessionModelTest : UsefulTestCase() { output = output, error = error, time = time, + reason = reason, + cost = cost, + tokens = tokens, ) private fun question(id: String) = Question( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt index 6c4bf72981c..65793c239fb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt @@ -239,8 +239,8 @@ class TurnGroupingTest : UsefulTestCase() { turn#u2: user#u2, assistant#a2 """) // HistoryLoaded is the only event — no per-turn events - assertEquals(1, events.size) - assertTrue(events.single() is SessionModelEvent.HistoryLoaded) + assertEquals(1, visibleEvents().size) + assertTrue(visibleEvents().single() is SessionModelEvent.HistoryLoaded) } fun `test loadHistory with leading assistant messages groups them correctly`() { @@ -254,7 +254,7 @@ class TurnGroupingTest : UsefulTestCase() { turn#a1: assistant#a1, assistant#a2 turn#u1: user#u1 """) - assertEquals(1, events.size) // only HistoryLoaded + assertEquals(1, visibleEvents().size) // only HistoryLoaded } fun `test loadHistory clears previous turns`() { @@ -264,7 +264,7 @@ class TurnGroupingTest : UsefulTestCase() { model.loadHistory(listOf(withParts(msg("u2", "user")))) assertTurns("turn#u2: user#u2") - assertEquals(1, events.size) // only HistoryLoaded + assertEquals(1, visibleEvents().size) // only HistoryLoaded } fun `test loadHistory with empty list produces no turns`() { @@ -274,7 +274,7 @@ class TurnGroupingTest : UsefulTestCase() { model.loadHistory(emptyList()) assertTurns("(no turns)") - assertEquals(1, events.size) // only HistoryLoaded + assertEquals(1, visibleEvents().size) // only HistoryLoaded } // ------ clear ------ @@ -288,8 +288,8 @@ class TurnGroupingTest : UsefulTestCase() { assertTurns("(no turns)") // Cleared is the only event — no per-turn events - assertEquals(1, events.size) - assertTrue(events.single() is SessionModelEvent.Cleared) + assertEquals(1, visibleEvents().size) + assertTrue(visibleEvents().single() is SessionModelEvent.Cleared) } // ------ idempotency ------ @@ -350,6 +350,8 @@ class TurnGroupingTest : UsefulTestCase() { it is SessionModelEvent.TurnRemoved } + private fun visibleEvents(): List = events.filter { it !is SessionModelEvent.HeaderUpdated } + private fun assertTurns(expected: String) { assertEquals(expected.trimIndent().trim(), model.toTurnsString().trim()) } @@ -359,6 +361,6 @@ class TurnGroupingTest : UsefulTestCase() { } private fun assertAllEvents(expected: String) { - assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + assertEquals(expected.trimIndent().trim(), visibleEvents().joinToString("\n")) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt index 203c4174017..35ba549f76e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt @@ -14,6 +14,8 @@ class SessionStyleTest : BasePlatformTestCase() { assertEquals(scheme.editorFontName, font.name) assertEquals(scheme.editorFontSize, font.size) + assertEquals(scheme.defaultForeground, style.editorForeground) + assertEquals(scheme.defaultBackground, style.editorBackground) assertEquals(Font.PLAIN, font.style) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 386209de196..375ae454afb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -131,7 +131,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { // ------ silent part types ------ - fun `test step-start part is not rendered in panel`() { + fun `test step markers are not rendered in panel`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", PartDto("g1", "ses", "a1", "step-start")) model.updateContent("a1", PartDto("g2", "ses", "a1", "step-finish")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt new file mode 100644 index 00000000000..b00c0596e73 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -0,0 +1,572 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.model.StepFinish +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.client.session.update.SessionControllerTestBase +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PartTimeDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.TokensDto +import com.intellij.ide.util.PropertiesComponent +import java.awt.Color +import java.awt.Point +import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent + +class SessionHeaderPanelTest : SessionControllerTestBase() { + + override fun setUp() { + super.setUp() + reset() + } + + override fun tearDown() { + try { + reset() + } finally { + super.tearDown() + } + } + + fun `test starts hidden for empty header`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val c = controller() + flush() + val panel = SessionHeaderPanel(c, parent) + + assertFalse(panel.isVisible) + assertEquals("New Session", panel.titleText()) + } + + fun `test shows populated session header`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val style = SessionStyle.current() + + assertTrue(panel.isVisible) + assertTrue(panel.isExpanded()) + assertEquals("Generated title", panel.titleText()) + assertEquals("$0.07", panel.costText()) + assertEquals("1%", panel.contextText()) + assertEquals("Tokens 13.7K 2.5K cache write 25 cache read 75", panel.tokenText()) + assertEquals("Tokens used by the latest assistant response: input, output, cache writes, and cache reads.", panel.tokenTip()) + assertEquals("13.7K", panel.inputTokenText()) + assertEquals("2.5K", panel.outputTokenText()) + assertEquals("cache write 25", panel.cacheWriteText()) + assertEquals("cache read 75", panel.cacheReadText()) + assertEquals("1/2 todos complete", panel.todoText()) + assertTrue(panel.todoVisible()) + assertEquals(style.editorBackground, panel.background) + assertEquals( + List(panel.foregrounds().size) { style.editorForeground }, + panel.foregrounds(), + ) + assertNotNull(panel.expandButton().icon) + } + + fun `test compact button follows eligibility and invokes controller`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + assertTrue(panel.compactButton().isEnabled) + panel.compactButton().doClick() + flush() + assertEquals(1, rpc.compacts.size) + + emit(ChatEventDto.TurnOpen("ses_test")) + assertFalse(panel.compactButton().isEnabled) + panel.compactButton().doClick() + flush() + assertEquals(1, rpc.compacts.size) + } + + fun `test retained labels update on later header event`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val button = panel.compactButton() + + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) + emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2, tokens = TokensDto(1_000, 500, 0, 0, 0)))) + + assertSame(button, panel.compactButton()) + assertEquals("New title", panel.titleText()) + assertEquals("$0.20", panel.costText()) + assertEquals("Tokens 1.0K 500", panel.tokenText()) + assertEquals("1.0K", panel.inputTokenText()) + assertEquals("500", panel.outputTokenText()) + } + + fun `test apply style updates header colors`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val style = SessionStyle.current().copy( + editorForeground = Color(1, 2, 3), + editorBackground = Color(4, 5, 6), + ) + + panel.applyStyle(style) + + assertEquals(style.editorBackground, panel.background) + assertEquals( + List(panel.foregrounds().size) { style.editorForeground }, + panel.foregrounds(), + ) + assertEquals( + List(panel.contextBarForegrounds().size) { style.editorForeground }, + panel.contextBarForegrounds(), + ) + } + + fun `test expanded body shows timeline context and token metrics`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val style = SessionStyle.current() + val body = panel.bodyPanel() + val timeline = panel.timelinePanel() + val bar = panel.contextBar() + + assertTrue(panel.isExpanded()) + assertSame(body, panel.bodyPanel()) + assertSame(timeline, panel.timelinePanel()) + assertSame(bar, panel.contextBar()) + assertEquals(listOf(panel.timelineViewport(), panel.tokenPanel(), bar), panel.bodyComponents().take(3)) + assertSame(timeline, panel.timelineViewport().view) + assertFalse(panel.timelineViewport().isOpaque) + assertEquals(4, panel.timelineCount()) + val parts = panel.timelineParts() + assertTrue(parts[0] is Reasoning) + assertEquals("bash", (parts[1] as Tool).name) + assertEquals(ToolKind.GENERIC, (parts[1] as Tool).kind) + assertEquals(ToolExecState.ERROR, (parts[2] as Tool).state) + assertTrue(parts[3] is StepFinish) + assertTrue(panel.timelineActive(0)) + assertTrue(panel.timelineActive(1)) + assertFalse(panel.timelineActive(2)) + assertFalse(panel.timelineActive(3)) + assertTrue(panel.contextBarVisible()) + assertEquals(16_300L, panel.contextBarUsed()) + assertEquals(200_000L, panel.contextBarReserved()) + assertEquals(1_783_700L, panel.contextBarAvailable()) + assertEquals(2_000_000L, panel.contextBarLimit()) + assertEquals( + List(panel.contextBarForegrounds().size) { style.editorForeground }, + panel.contextBarForegrounds(), + ) + assertEquals("16.3K / 2.0M tokens used\n200.0K reserved for output\n1.8M available", panel.contextBarTip()) + assertNotSame(panel.contextBarTrackColor(), panel.contextBarReservedColor()) + assertNotSame(panel.contextBarUsedColor(), panel.contextBarReservedColor()) + assertEquals(panel.timelinePreferredSize().height, panel.timelineViewportPreferredSize().height) + assertEquals(0, panel.timelineViewportPreferredSize().width) + assertTrue(panel.timelinePreferredSize().height >= panel.contextBar().preferredSize.height) + assertTrue(panel.timelineBarHeight(1) < panel.timelineViewportPreferredSize().height) + assertTrue(panel.timelineBarHeight(0) < panel.timelineBarHeight(1)) + assertEquals(panel.timelineBarHeight(1), panel.timelineBarHeight(2)) + assertTrue(panel.timelineBarHeight(3) > panel.timelineBarHeight(1)) + + timeline.dispatchEvent(MouseEvent( + timeline, + MouseEvent.MOUSE_MOVED, + System.currentTimeMillis(), + 0, + panel.timelineBarWidth() + 1, + panel.timelinePreferredSize().height - 1, + 0, + false, + )) + assertEquals("Run tests", panel.timelineToolTip()) + assertEquals(1, panel.timelineHover()) + timeline.dispatchEvent(MouseEvent( + timeline, + MouseEvent.MOUSE_MOVED, + System.currentTimeMillis(), + 0, + panel.timelineBarWidth() * 3 + 1, + panel.timelinePreferredSize().height - 1, + 0, + false, + )) + assertEquals("Step finish", panel.timelineToolTip()) + assertEquals(3, panel.timelineHover()) + timeline.dispatchEvent(MouseEvent( + timeline, + MouseEvent.MOUSE_MOVED, + System.currentTimeMillis(), + 0, + panel.timelineBarWidth() - 1, + 0, + 0, + false, + )) + assertNull(panel.timelineToolTip()) + assertEquals(-1, panel.timelineHover()) + + panel.expandButton().doClick() + + assertFalse(panel.isExpanded()) + assertSame(body, panel.bodyPanel()) + assertSame(timeline, panel.timelinePanel()) + assertSame(bar, panel.contextBar()) + assertEquals(4, panel.timelineCount()) + } + + fun `test read and write timeline tooltips show filename`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + emit(ChatEventDto.PartUpdated( + "ses_test", + tool("tool_read", "read", "completed", "Read file", input = mapOf("filePath" to "src/docs/README.md")), + )) + emit(ChatEventDto.PartUpdated( + "ses_test", + tool("tool_write", "write", "completed", "Write file", input = mapOf("filePath" to "src/main/App.kt")), + )) + val timeline = panel.timelinePanel() + + move(panel, 4) + assertEquals("Read README.md", panel.timelineToolTip()) + move(panel, 5) + assertEquals("Write App.kt", panel.timelineToolTip()) + + assertSame(timeline, panel.timelinePanel()) + } + + fun `test expand button owns expanded state across updates`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + assertTrue(panel.isExpanded()) + assertEquals("Hide session metrics", panel.expandTip()) + + panel.expandButton().doClick() + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) + + assertFalse(panel.isExpanded()) + assertEquals("Show session metrics", panel.expandTip()) + + panel.expandButton().doClick() + emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) + + assertTrue(panel.isExpanded()) + assertEquals("Hide session metrics", panel.expandTip()) + } + + fun `test collapse persists and new header starts collapsed`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + panel.expandButton().doClick() + + assertFalse(panel.isExpanded()) + assertFalse(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, true)) + + val next = SessionHeaderPanel(c, parent) + + assertFalse(next.isExpanded()) + assertEquals("Show session metrics", next.expandTip()) + } + + fun `test expand persists and new header starts expanded`() { + PropertiesComponent.getInstance().setValue(SessionHeaderPanel.EXPANDED_KEY, "false") + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + + assertFalse(panel.isExpanded()) + + panel.expandButton().doClick() + + assertTrue(panel.isExpanded()) + assertTrue(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, false)) + + val next = SessionHeaderPanel(c, parent) + + assertTrue(next.isExpanded()) + assertEquals("Hide session metrics", next.expandTip()) + } + + fun `test hidden empty header collapse keeps saved expansion preference`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val c = controller() + flush() + val panel = SessionHeaderPanel(c, parent) + + assertFalse(panel.isVisible) + assertFalse(panel.isExpanded()) + assertTrue(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, true)) + + edt { c.prompt("go") } + flush() + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title"))) + emit(ChatEventDto.MessageUpdated("ses_test", assistant())) + + assertTrue(panel.isVisible) + assertTrue(panel.isExpanded()) + } + + fun `test context bar uses neutral grey colors`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val color = panel.contextBarUsedColor() + + emit(ChatEventDto.MessageUpdated("ses_test", assistant(tokens = TokensDto(1_200_000, 0, 0, 0, 0)))) + + assertEquals(color, panel.contextBarUsedColor()) + assertNotSame(panel.contextBarTrackColor(), panel.contextBarUsedColor()) + assertNotSame(panel.contextBarTrackColor(), panel.contextBarReservedColor()) + assertNotSame(panel.contextBarUsedColor(), panel.contextBarReservedColor()) + } + + fun `test timeline width uses uniform bars and gaps`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val first = panel.timelinePreferredSize().width + + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_3", "bash", "running", "Short"))) + + val next = panel.timelinePreferredSize().width + assertTrue(first > 0) + assertEquals(5, panel.timelineCount()) + assertEquals(panel.timelineBarWidth(), next - first) + } + + fun `test timeline drags horizontally inside viewport`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + repeat(12) { idx -> + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx"))) + } + panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) + panel.timelineViewport().doLayout() + panel.timelineViewport().viewPosition = Point(0, 0) + + val x = panel.timelineViewport().viewPosition.x + val y = panel.timelineViewport().viewPosition.y + val timeline = panel.timelinePanel() + timeline.dispatchEvent(MouseEvent( + timeline, + MouseEvent.MOUSE_PRESSED, + System.currentTimeMillis(), + 0, + panel.timelineBarWidth() * 3, + 1, + 1, + false, + )) + timeline.dispatchEvent(MouseEvent( + timeline, + MouseEvent.MOUSE_DRAGGED, + System.currentTimeMillis(), + 0, + panel.timelineBarWidth(), + 1, + 0, + false, + )) + + assertTrue(panel.timelineViewport().viewPosition.x > x) + assertEquals(y, panel.timelineViewport().viewPosition.y) + } + + fun `test timeline touch scrolls horizontally inside viewport`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + repeat(12) { idx -> + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_touch_$idx", "bash", "running", "Touch $idx"))) + } + panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) + panel.timelineViewport().doLayout() + panel.timelineViewport().viewPosition = Point(0, 0) + + val x = panel.timelineViewport().viewPosition.x + val y = panel.timelineViewport().viewPosition.y + val timeline = panel.timelinePanel() + timeline.dispatchEvent(MouseWheelEvent( + timeline, + MouseWheelEvent.MOUSE_WHEEL, + System.currentTimeMillis(), + 0, + 1, + 1, + 1, + 1, + 0, + false, + 3, + panel.timelineBarWidth(), + 1, + panel.timelineBarWidth().toDouble(), + )) + + assertTrue(panel.timelineViewport().viewPosition.x > x) + assertEquals(y, panel.timelineViewport().viewPosition.y) + } + + fun `test timeline wheel scrolls horizontally inside viewport`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + repeat(12) { idx -> + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_wheel_$idx", "bash", "running", "Wheel $idx"))) + } + panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) + panel.timelineViewport().doLayout() + panel.timelineViewport().viewPosition = Point(0, 0) + + val x = panel.timelineViewport().viewPosition.x + val y = panel.timelineViewport().viewPosition.y + val timeline = panel.timelinePanel() + timeline.dispatchEvent(MouseWheelEvent( + timeline, + MouseWheelEvent.MOUSE_WHEEL, + System.currentTimeMillis(), + 0, + 1, + 1, + 1, + 1, + 0, + false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, + 1, + 1, + 1.0, + )) + + assertTrue(panel.timelineViewport().viewPosition.x > x) + assertEquals(y, panel.timelineViewport().viewPosition.y) + } + + fun `test timeline append scrolls viewport to end`() { + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + repeat(12) { idx -> + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_$idx", "bash", "running", "More $idx"))) + } + panel.timelineViewport().setSize(panel.timelineBarWidth() * 4, panel.timelineViewportPreferredSize().height) + panel.timelineViewport().doLayout() + panel.timelineViewport().viewPosition = Point(0, 0) + + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_more_final", "bash", "running", "Final"))) + panel.timelineViewport().doLayout() + flush() + + val max = panel.timelinePreferredSize().width - panel.timelineViewport().extentSize.width + assertTrue(max > 0) + assertEquals(max, panel.timelineViewport().viewPosition.x) + assertEquals(0, panel.timelineViewport().viewPosition.y) + } + + private fun promptedHeader(): ai.kilocode.client.session.update.SessionController { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto( + ai.kilocode.rpc.dto.KiloAppStatusDto.READY, + config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"), + ) + projectRpc.state.value = workspaceReady( + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf( + "gpt-5" to ModelDto( + id = "gpt-5", + name = "GPT-5", + limit = ai.kilocode.rpc.dto.ModelLimitDto(context = 2_000_000, output = 200_000), + ), + ), + ), + ), + ) + val c = controller() + flush() + edt { c.prompt("go") } + flush() + + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title"))) + emit(ChatEventDto.MessageUpdated("ses_test", assistant())) + emit(ChatEventDto.PartUpdated("ses_test", reasoning(done = false, text = "Thinking"))) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_1", "bash", "running", "Run tests", input = mapOf("cmd" to "test", "files" to "src")))) + emit(ChatEventDto.PartUpdated("ses_test", tool("tool_2", "edit", "error", "Edit file", input = mapOf("cmd" to "test", "files" to "src")))) + emit(ChatEventDto.PartUpdated("ses_test", stepFinish())) + emit(ChatEventDto.TodoUpdated("ses_test", listOf( + TodoDto("Write tests", "completed", "high"), + TodoDto("Ship it", "pending", "medium"), + ))) + return c + } + + private fun assistant( + cost: Double = 0.07, + tokens: TokensDto = TokensDto(13_700, 2_000, 500, 75, 25), + ) = MessageDto( + id = "msg1", + sessionID = "ses_test", + role = "assistant", + time = MessageTimeDto(created = 0.0), + cost = cost, + tokens = tokens, + ) + + private fun reasoning(done: Boolean, text: String) = PartDto( + id = "reasoning_1", + sessionID = "ses_test", + messageID = "msg1", + type = "reasoning", + text = text, + time = if (done) PartTimeDto(1.0, 2.0) else PartTimeDto(1.0, null), + ) + + private fun tool( + id: String, + name: String, + state: String, + title: String, + input: Map = mapOf("cmd" to "test"), + ) = PartDto( + id = id, + sessionID = "ses_test", + messageID = "msg1", + type = "tool", + tool = name, + state = state, + title = title, + input = input, + time = PartTimeDto(1.0, 3.0), + ) + + private fun stepFinish() = PartDto( + id = "step_finish_1", + sessionID = "ses_test", + messageID = "msg1", + type = "step-finish", + reason = "stop", + cost = 0.07, + tokens = TokensDto(13_700, 2_000, 500, 75, 25), + ) + + private fun move(panel: SessionHeaderPanel, index: Int) { + val timeline = panel.timelinePanel() + timeline.dispatchEvent(MouseEvent( + timeline, + MouseEvent.MOUSE_MOVED, + System.currentTimeMillis(), + 0, + panel.timelineBarWidth() * index + 1, + panel.timelinePreferredSize().height - 1, + 0, + false, + )) + } + + private fun reset() { + PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt index 93e83b4cb9b..e6885853521 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt @@ -2,12 +2,13 @@ package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.model.StepFinish import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto /** * Verifies that: - * 1. `step-start` and `step-finish` parts are silently dropped at the model level. + * 1. `step-start` parts are silently dropped while `step-finish` remains available to the timeline. * 2. [SessionState.Busy.text] carries the right progress string throughout a turn. * 3. [SessionModelEvent.StateChanged] fires with correct [SessionState.Busy] text * as parts arrive. @@ -15,7 +16,7 @@ import ai.kilocode.rpc.dto.MessageErrorDto */ class ProgressTrackingTest : SessionControllerTestBase() { - // ------ silent part types ------ + // ------ timeline marker parts ------ fun `test step-start part is silently dropped from model`() { val (m, _, modelEvents) = prompted() @@ -27,14 +28,14 @@ class ProgressTrackingTest : SessionControllerTestBase() { assertFalse(modelEvents.any { it is SessionModelEvent.ContentAdded }) } - fun `test step-finish part is silently dropped from model`() { + fun `test step-finish part is stored for timeline`() { val (m, _, modelEvents) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) emit(ChatEventDto.PartUpdated("ses_test", part("p2", "ses_test", "msg1", "step-finish"))) - assertNull(m.model.message("msg1")!!.parts["p2"]) - assertFalse(modelEvents.any { it is SessionModelEvent.ContentAdded }) + assertTrue(m.model.message("msg1")!!.parts["p2"] is StepFinish) + assertTrue(modelEvents.any { it is SessionModelEvent.ContentAdded }) } // ------ progress text per turn event ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index f799d076a5e..13b30a8355b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -24,6 +24,8 @@ import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer @@ -134,6 +136,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { flushMs: Long, condense: Boolean, displayMs: Long = Long.MAX_VALUE, + session: SessionDto? = null, beforeUpdate: () -> Boolean = { false }, afterUpdate: (Boolean) -> Unit = {}, ): SessionController { @@ -149,8 +152,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { flushMs, condense, displayMs, - beforeUpdate = beforeUpdate, - afterUpdate = afterUpdate, + session = session, + beforeUpdate = beforeUpdate, + afterUpdate = afterUpdate, ) controllers.add(m) roots[m] = root @@ -197,7 +201,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { Disposer.register(parent, disposable) m.model.addListener(disposable) { event -> assertTrue("Model listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) - events.add(event) + if (event !is SessionModelEvent.HeaderUpdated) events.add(event) } return events } @@ -251,6 +255,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { flush() edt { m.prompt("go") } flush() + modelEvents.clear() return Triple(m, events, modelEvents) } @@ -274,7 +279,11 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } protected fun assertModelEvents(expected: String, events: List) { - assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + val act = events + .filter { it !is SessionModelEvent.HeaderUpdated } + .filter { it !is SessionModelEvent.SessionUpdated } + .joinToString("\n") + assertEquals(expected.trimIndent().trim(), act) } protected fun snapshot(c: SessionController) = Snapshot( @@ -315,6 +324,15 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { title = title, ) + protected fun session(id: String, title: String = "Session $id", dir: String = "/test") = SessionDto( + id = id, + projectID = "prj", + directory = dir, + title = title, + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + protected fun workspaceReady( agents: List = listOf(AgentDto(name = "code", displayName = "Code", mode = "code")), default: String = "code", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionHeaderControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionHeaderControllerTest.kt new file mode 100644 index 00000000000..8ebea6e5676 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionHeaderControllerTest.kt @@ -0,0 +1,75 @@ +package ai.kilocode.client.session.update + +import ai.kilocode.rpc.dto.ChatEventDto + +class SessionHeaderControllerTest : SessionControllerTestBase() { + + fun `test session updated refreshes model metadata`() { + val (m, _, modelEvents) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Generated title"))) + + assertEquals("Generated title", m.model.session?.title) + assertEquals("Generated title", m.model.header.title) + assertTrue(modelEvents.any { it is ai.kilocode.client.session.model.SessionModelEvent.SessionUpdated }) + } + + fun `test compact calls RPC with selected model when eligible`() { + val (m, _, _) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + edt { m.compact() } + flush() + + val call = rpc.compacts.single() + assertEquals("ses_test", call.first) + assertEquals("/test", call.second) + assertEquals("kilo", call.third.providerID) + assertEquals("gpt-5", call.third.modelID) + } + + fun `test compact is blocked while busy`() { + val (m, _, _) = prompted() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.TurnOpen("ses_test")) + + edt { m.compact() } + flush() + + assertTrue(rpc.compacts.isEmpty()) + } + + fun `test compact is blocked without model`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = null)) + projectRpc.state.value = workspaceReady(providers = emptyList(), connected = emptyList()) + val m = controller() + flush() + edt { m.prompt("go") } + flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + edt { m.model.model = null } + assertNull(m.model.model) + + edt { m.compact() } + flush() + + assertTrue(rpc.compacts.isEmpty()) + } + + fun `test opened session metadata is seeded through constructor`() { + rpc.history.add(ai.kilocode.rpc.dto.MessageWithPartsDto(msg("msg1", "ses_test", "assistant"), emptyList())) + val c = controller( + id = "ses_test", + flushMs = Long.MAX_VALUE, + condense = true, + session = session("ses_test", title = "Opened title"), + ) + + flush() + + assertEquals("Opened title", c.model.session?.title) + assertEquals("Opened title", c.model.header.title) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index c7842c280b7..e5579fda720 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -44,14 +45,14 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test title shown instead of name when title is set`() { - val t = Tool("p1", "bash").also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } + val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } val view = ToolView(t) assertTrue(view.labelText().contains("Install deps")) assertTrue(view.labelText().contains("Shell")) } fun `test blank title falls back to tool name`() { - val t = Tool("p1", "bash").also { it.state = ToolExecState.COMPLETED; it.title = " " } + val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED; it.title = " " } val view = ToolView(t) assertTrue(view.labelText().contains("Shell")) } @@ -297,14 +298,14 @@ class ToolViewTest : BasePlatformTestCase() { fun `test update changes state icon`() { val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING)) - val updated = Tool("p1", "bash").also { it.state = ToolExecState.COMPLETED } + val updated = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED } view.update(updated) assertFalse(view.labelText().contains("Running")) } fun `test update changes title`() { val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING, title = "old")) - val updated = Tool("p1", "bash").also { it.state = ToolExecState.COMPLETED; it.title = "new title" } + val updated = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED; it.title = "new title" } view.update(updated) assertTrue(view.labelText().contains("new title")) } @@ -319,14 +320,14 @@ class ToolViewTest : BasePlatformTestCase() { // ---- contentId ------ fun `test contentId matches Tool id`() { - val view = ToolView(Tool("part99", "edit").also { it.state = ToolExecState.PENDING }) + val view = ToolView(Tool("part99", "edit", toolKind("edit")).also { it.state = ToolExecState.PENDING }) assertEquals("part99", view.contentId) } // ---- helpers ------ private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = - Tool(id, name).also { it.state = state; it.title = title } + Tool(id, name, toolKind(name)).also { it.state = state; it.title = title } private fun assertEditorFont(font: java.awt.Font, style: SessionStyle) { assertEquals(style.editorFamily, font.name) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 0bf9d7211ec..ca961ee6650 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -161,7 +162,7 @@ class TurnViewTest : BasePlatformTestCase() { fun `test assistant card parts use shared compact gap`() { val message = msg("a1", "assistant") val reasoning = Reasoning("r1") - val tool = Tool("t1", "read").also { it.state = ToolExecState.COMPLETED } + val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool val mv = MessageView(message) @@ -175,10 +176,10 @@ class TurnViewTest : BasePlatformTestCase() { fun `test consecutive messages use shared compact gap`() { val tv = TurnView("u1") tv.addMessage(msg("u1", "user").also { msg -> - msg.parts["t1"] = Tool("t1", "read").also { it.state = ToolExecState.COMPLETED } + msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } }) tv.addMessage(msg("a2", "assistant").also { msg -> - msg.parts["t2"] = Tool("t2", "read").also { it.state = ToolExecState.COMPLETED } + msg.parts["t2"] = Tool("t2", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } }) tv.setSize(400, 300) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 8759f4d913e..49127d853ae 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -4,6 +4,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -42,6 +43,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() + var historyGate: CompletableDeferred? = null /** Recent sessions returned by [recent]. */ val recent = mutableListOf() @@ -67,6 +69,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val prompts = mutableListOf>() val aborts = mutableListOf>() + val compacts = mutableListOf>() val configs = mutableListOf>() val permissionReplies = mutableListOf>() val permissionRulesSaved = mutableListOf>() @@ -133,8 +136,14 @@ class FakeSessionRpcApi : KiloSessionRpcApi { aborts.add(id to directory) } + override suspend fun compact(id: String, directory: String, model: ModelSelectionDto) { + assertNotEdt("compact") + compacts.add(Triple(id, directory, model)) + } + override suspend fun messages(id: String, directory: String): List { assertNotEdt("messages") + historyGate?.await() return history.toList() } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 19d6a80d68a..64e5966999e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -29,6 +29,7 @@ object ChatLogSummary { is ChatEventDto.QuestionReplied -> event.sessionID is ChatEventDto.QuestionRejected -> event.sessionID is ChatEventDto.SessionStatusChanged -> event.sessionID + is ChatEventDto.SessionUpdated -> event.sessionID is ChatEventDto.SessionIdle -> event.sessionID is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID @@ -181,6 +182,12 @@ object ChatLogSummary { status(event.status), ) + is ChatEventDto.SessionUpdated -> join( + sid(event.sessionID), + "evt=session.updated", + "title=${event.session.title.length}", + ) + is ChatEventDto.SessionIdle -> join( sid(event.sessionID), "evt=session.idle", diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 330bfb8c113..d12efefb742 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -3,6 +3,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -66,6 +67,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Abort ongoing processing for a session. */ suspend fun abort(id: String, directory: String) + /** Summarize/compact a session using the selected model. */ + suspend fun compact(id: String, directory: String, model: ModelSelectionDto) + /** Load message history for a session. */ suspend fun messages(id: String, directory: String): List diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 9d5b0094f2f..9d145f2fad6 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -65,6 +65,9 @@ data class PartDto( val output: String? = null, val error: String? = null, val time: PartTimeDto? = null, + val reason: String? = null, + val cost: Double? = null, + val tokens: TokensDto? = null, ) @Serializable @@ -196,6 +199,13 @@ sealed class ChatEventDto { val status: SessionStatusDto, ) : ChatEventDto() + @Serializable + @SerialName("session.updated") + data class SessionUpdated( + val sessionID: String, + val session: SessionDto, + ) : ChatEventDto() + @Serializable @SerialName("session.idle") data class SessionIdle( diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt index 751cdce05fd..6045f5c130f 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt @@ -14,6 +14,14 @@ data class ModelDto( val status: String? = null, val recommendedIndex: Double? = null, val variants: List = emptyList(), + val limit: ModelLimitDto? = null, +) + +@Serializable +data class ModelLimitDto( + val context: Long = 0, + val input: Long? = null, + val output: Long = 0, ) @Serializable diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 37a44343553..730a66c48f1 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.40", + "version": "7.2.42", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/events.ts b/packages/kilo-telemetry/src/events.ts index d61d4ae06b6..59b489a34ac 100644 --- a/packages/kilo-telemetry/src/events.ts +++ b/packages/kilo-telemetry/src/events.ts @@ -42,6 +42,9 @@ export enum TelemetryEvent { // Config Events TELEMETRY_DISABLED = "Telemetry Disabled", + // Feedback + FEEDBACK_SUBMITTED = "Feedback Submitted", + // Errors ERROR = "Error", } diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 94a8d946ce8..16dbcc92201 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -154,6 +154,9 @@ export namespace Telemetry { // LLM export function trackLlmCompletion(properties: { taskId?: string + mode?: "review" + feature?: "code_reviews" + command?: "local-review" | "local-review-uncommitted" apiProvider: string modelId: string inputTokens?: number @@ -241,6 +244,22 @@ export namespace Telemetry { track(TelemetryEvent.ERROR, { error, context }) } + // Feedback + export interface FeedbackProperties extends Record { + providerID: string + modelID: string + variant?: string + rating: "up" | "down" | "cleared" + previousRating?: "up" | "down" + sessionID?: string + messageID?: string + parentMessageID?: string + } + + export function trackFeedback(props: FeedbackProperties) { + track(TelemetryEvent.FEEDBACK_SUBMITTED, props) + } + export async function shutdown(): Promise { await Client.shutdown() } diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 18729510a21..48b1770c7ff 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.40", + "version": "7.2.42", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/accordion.css b/packages/kilo-ui/src/components/accordion.css index cc21b6f88e8..ec51bc85e56 100644 --- a/packages/kilo-ui/src/components/accordion.css +++ b/packages/kilo-ui/src/components/accordion.css @@ -1,7 +1,7 @@ /* Kilo Accordion overrides */ [data-component="accordion"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); [data-slot="accordion-trigger"] { cursor: pointer; diff --git a/packages/kilo-ui/src/components/auto-approve-bar.css b/packages/kilo-ui/src/components/auto-approve-bar.css index 517e05d0846..1e4cf27bc86 100644 --- a/packages/kilo-ui/src/components/auto-approve-bar.css +++ b/packages/kilo-ui/src/components/auto-approve-bar.css @@ -13,7 +13,7 @@ } [data-slot="auto-approve-label"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); color: var(--text-base); flex: 1; } diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 17cb5d9d07c..7ea63c439e2 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -7,7 +7,7 @@ } [data-slot="basic-tool-tool-info"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); text-align: start; } @@ -34,19 +34,19 @@ } [data-slot="basic-tool-tool-subtitle"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="basic-tool-tool-title"] { - font-size: 14px; + font-size: var(--kilo-font-size-14); } [data-slot="message-part-title-text"] { - font-size: 14px; + font-size: var(--kilo-font-size-14); } [data-slot="message-part-title-filename"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="message-part-meta-line"] { @@ -57,7 +57,7 @@ white-space: nowrap; min-width: 0; flex-shrink: 1; - font-size: 12px; + font-size: var(--kilo-font-size-12); &.clickable { cursor: pointer; @@ -88,7 +88,7 @@ text-align: left; flex-shrink: 1; min-width: 0; - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="webfetch-meta"] { @@ -164,7 +164,7 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty [data-slot="mcp-section-label"] { padding: 6px 12px 0; - font-size: 11px; + font-size: var(--kilo-font-size-11); color: var(--text-weak, var(--vscode-descriptionForeground)); } diff --git a/packages/kilo-ui/src/components/button.css b/packages/kilo-ui/src/components/button.css index 2ddd442620f..6b5b884a380 100644 --- a/packages/kilo-ui/src/components/button.css +++ b/packages/kilo-ui/src/components/button.css @@ -2,8 +2,8 @@ [data-component="button"] { font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); border-radius: 2px; cursor: pointer; border-color: transparent; @@ -86,11 +86,11 @@ } &[data-size="normal"] { - height: 32px; + min-height: 32px; padding: 6px 16px; gap: 6px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); &[data-icon] { padding: 6px 16px 6px 12px; @@ -98,11 +98,11 @@ } &[data-size="large"] { - height: 32px; + min-height: 32px; padding: 6px 16px; gap: 6px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); &[data-icon] { padding: 6px 16px 6px 12px; @@ -110,11 +110,11 @@ } &[data-size="small"] { - height: 24px; + min-height: 24px; padding: 4px 12px; gap: 4px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); &[data-icon] { padding: 4px 12px 4px 8px; diff --git a/packages/kilo-ui/src/components/chat-input.css b/packages/kilo-ui/src/components/chat-input.css index bfd29fc7028..a454b22bd7e 100644 --- a/packages/kilo-ui/src/components/chat-input.css +++ b/packages/kilo-ui/src/components/chat-input.css @@ -15,13 +15,13 @@ [data-slot="input-input"] { min-height: 80px; padding: 9px; - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; } [data-slot="input-helper-text"] { color: var(--text-weak); - font-size: 11px; + font-size: var(--kilo-font-size-11); padding: 4px 9px; } } diff --git a/packages/kilo-ui/src/components/checkbox.css b/packages/kilo-ui/src/components/checkbox.css index d4bce11bfac..97b8260f4cb 100644 --- a/packages/kilo-ui/src/components/checkbox.css +++ b/packages/kilo-ui/src/components/checkbox.css @@ -21,7 +21,7 @@ } [data-slot="checkbox-checkbox-label"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; padding-inline-start: 10px; margin-inline-end: 10px; diff --git a/packages/kilo-ui/src/components/code.css b/packages/kilo-ui/src/components/code.css index 4deb4adb66a..81a7ea8da18 100644 --- a/packages/kilo-ui/src/components/code.css +++ b/packages/kilo-ui/src/components/code.css @@ -2,7 +2,7 @@ [data-component="code"] { font-family: var(--vscode-editor-font-family, Menlo, Monaco, "Courier New", monospace); - font-size: var(--vscode-editor-font-size, 13px); + font-size: var(--font-size-base); content-visibility: auto; overflow: hidden; } diff --git a/packages/kilo-ui/src/components/code.tsx b/packages/kilo-ui/src/components/code.tsx index f3f4bbf49ec..c5ff061f33f 100644 --- a/packages/kilo-ui/src/components/code.tsx +++ b/packages/kilo-ui/src/components/code.tsx @@ -22,6 +22,17 @@ const codeMetrics = { fileGap: 0, } satisfies Partial +const codeStyle = { + ...styleVariables, + "--diffs-line-height": "var(--kilo-font-size-24)", +} + +function lineHeight() { + if (typeof window === "undefined") return codeMetrics.lineHeight + const value = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--kilo-font-size-24")) + return Number.isFinite(value) ? value : codeMetrics.lineHeight +} + type SelectionSide = "additions" | "deletions" export type CodeProps = FileOptions & { @@ -172,6 +183,7 @@ export function Code(props: CodeProps) { const [findQuery, setFindQuery] = createSignal("") const [findIndex, setFindIndex] = createSignal(0) const [findCount, setFindCount] = createSignal(0) + const [height, setHeight] = createSignal(lineHeight()) let findMode: "highlights" | "overlay" = "overlay" let findHits: Range[] = [] @@ -914,7 +926,8 @@ export function Code(props: CodeProps) { return virtualizer })() - instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool) + const metrics = { ...codeMetrics, lineHeight: height() } + instance = isVirtual && v ? new VirtualizedFile(opts, v, metrics, workerPool) : new File(opts, workerPool) container.innerHTML = "" const value = text() @@ -942,6 +955,19 @@ export function Code(props: CodeProps) { onCleanup(() => monitor.disconnect()) }) + createEffect(() => { + if (typeof document === "undefined") return + if (typeof MutationObserver === "undefined") return + + const root = document.documentElement + const update = () => setHeight(lineHeight()) + const monitor = new MutationObserver(update) + monitor.observe(root, { attributes: true, attributeFilter: ["style"] }) + update() + + onCleanup(() => monitor.disconnect()) + }) + createEffect(() => { rendered() const ranges = local.commentedLines ?? [] @@ -1063,7 +1089,7 @@ export function Code(props: CodeProps) { return (
{ + icon: IconProps["name"] + size?: "small" | "normal" | "large" + iconSize?: IconProps["size"] + variant?: "primary" | "secondary" | "ghost" +} + +export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { + const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"]) + return ( + + + + ) +} diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 130949d4cf3..3eed7092fdd 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -1,9 +1,31 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon" import { splitProps } from "solid-js" -const icons = { - "circuit-board": ``, - organization: ``, +const icons: Record = { + "circuit-board": { + viewBox: "0 0 16 16", + path: ``, + }, + organization: { + viewBox: "0 0 16 16", + path: ``, + }, + "thumbs-up": { + viewBox: "0 0 20 20", + path: ``, + }, + "thumbs-down": { + viewBox: "0 0 20 20", + path: ``, + }, + "thumbs-up-filled": { + viewBox: "0 0 20 20", + path: ``, + }, + "thumbs-down-filled": { + viewBox: "0 0 20 20", + path: ``, + }, } type Name = keyof typeof icons @@ -14,8 +36,7 @@ export interface IconProps extends Omit { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) - const name = local.name as Name - if (!(name in icons)) { + if (!((local.name as Name) in icons)) { return (
@@ -2064,8 +2119,13 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) - const before = () => props.metadata?.filediff?.before ?? props.input.oldString ?? "" - const after = () => props.metadata?.filediff?.after ?? props.input.newString ?? "" + const view = createMemo(() => { + const diff = props.metadata?.filediff + if (!diff?.patch) return + return contents(diff) + }) + const before = () => view()?.before ?? props.metadata?.filediff?.before ?? props.input.oldString ?? "" + const after = () => view()?.after ?? props.metadata?.filediff?.after ?? props.input.newString ?? "" const canOpenDiff = () => !!data.openDiff && !!path() && (before() !== "" || after() !== "") const canOpenFile = () => !!data.openFile && !!path() @@ -2184,10 +2244,25 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) + const view = createMemo(() => { + const diff = props.metadata?.filediff + if (!diff?.patch) return + return contents(diff) + }) const handleFileClick = (e: MouseEvent) => { - if (!data.openFile || !props.input.filePath) return e.stopPropagation() + if (data.openDiff && view()) { + data.openDiff({ + file: props.metadata?.filediff?.file || props.input.filePath, + before: view()!.before, + after: view()!.after, + additions: props.metadata?.filediff?.additions ?? 0, + deletions: props.metadata?.filediff?.deletions ?? 0, + }) + return + } + if (!data.openFile || !props.input.filePath) return data.openFile(props.input.filePath) } @@ -2209,8 +2284,13 @@ ToolRegistry.register({ )} @@ -2219,19 +2299,40 @@ ToolRegistry.register({ } > - - + + + {(diff) => } + + } + >
- + + } + > + {(diff) => ( + + )} +
@@ -2246,6 +2347,7 @@ interface ApplyPatchFile { filePath: string relativePath: string type: "add" | "update" | "delete" | "move" + patch?: string diff: string before?: string after?: string @@ -2261,6 +2363,13 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + const view = (file: ApplyPatchFile) => { + if (file.patch) + return contents({ file: file.relativePath, patch: file.patch, additions: file.additions, deletions: file.deletions }) + if (file.diff) + return contents({ file: file.relativePath, patch: file.diff, additions: file.additions, deletions: file.deletions }) + if (file.before !== undefined || file.after !== undefined) return { before: file.before ?? "", after: file.after ?? "" } + } const pending = createMemo(() => busy(props.status)) const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { @@ -2398,15 +2507,17 @@ ToolRegistry.register({ - -
- -
+ + {(diff) => ( +
+ +
+ )}
@@ -2446,15 +2557,17 @@ ToolRegistry.register({ } > - -
- -
+ + {(diff) => ( +
+ +
+ )}
)} diff --git a/packages/kilo-ui/src/components/message-row.css b/packages/kilo-ui/src/components/message-row.css index 57cb7bdd442..4bf5d906767 100644 --- a/packages/kilo-ui/src/components/message-row.css +++ b/packages/kilo-ui/src/components/message-row.css @@ -11,24 +11,24 @@ } [data-slot="message-author"] { - font-size: 13px; + font-size: var(--font-size-base); font-weight: 500; color: var(--text-strong); } [data-slot="message-timestamp"] { color: var(--text-weak); - font-size: 11px; + font-size: var(--font-size-small); } [data-slot="message-cost"] { color: var(--text-weak); - font-size: 11px; + font-size: var(--font-size-small); margin-left: auto; } [data-slot="message-content"] { - font-size: 13px; + font-size: var(--font-size-base); line-height: 1.6; color: var(--text-base); } diff --git a/packages/kilo-ui/src/components/model-info-card.css b/packages/kilo-ui/src/components/model-info-card.css index b8cd8c5bbd5..658cb886c7b 100644 --- a/packages/kilo-ui/src/components/model-info-card.css +++ b/packages/kilo-ui/src/components/model-info-card.css @@ -7,7 +7,7 @@ background-color: var(--surface-raised-base); [data-slot="model-info-description"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; color: var(--text-base); margin-bottom: 12px; @@ -24,7 +24,7 @@ display: flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: var(--kilo-font-size-13); &::before { content: "\2713"; @@ -35,7 +35,7 @@ [data-slot="model-info-specs"] { display: flex; gap: 8px; - font-size: 11px; + font-size: var(--kilo-font-size-11); color: var(--text-weak); margin-bottom: 12px; } @@ -51,7 +51,7 @@ background-color: var(--surface-inset-base); padding: 2px 4px; border-radius: 2px; - font-size: 11px; + font-size: var(--kilo-font-size-11); font-weight: 500; color: var(--text-base); } diff --git a/packages/kilo-ui/src/components/model-selector.css b/packages/kilo-ui/src/components/model-selector.css index 89cf891532e..aa536981e42 100644 --- a/packages/kilo-ui/src/components/model-selector.css +++ b/packages/kilo-ui/src/components/model-selector.css @@ -25,7 +25,7 @@ background-color: var(--input-base, var(--vscode-input-background, #3c3c3c)); [data-component="input"] input { - font-size: 12px; + font-size: var(--kilo-font-size-12); } } @@ -52,7 +52,7 @@ /* Group headers */ [data-slot="list-header"] { padding: 6px 12px 4px; - font-size: 11px; + font-size: var(--kilo-font-size-11); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; @@ -72,8 +72,8 @@ /* List items */ [data-slot="list-item"] { padding: 6px 12px; - font-size: 12px; - line-height: 16px; + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-16); border-radius: 0; cursor: pointer; @@ -94,7 +94,7 @@ padding: 16px 12px; [data-slot="list-message"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); } } } diff --git a/packages/kilo-ui/src/components/prompt-input.css b/packages/kilo-ui/src/components/prompt-input.css index 32c70da1939..0d429623dff 100644 --- a/packages/kilo-ui/src/components/prompt-input.css +++ b/packages/kilo-ui/src/components/prompt-input.css @@ -24,8 +24,8 @@ padding: 6px 8px; padding-right: 36px; font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); - font-size: var(--vscode-editor-font-size, 13px); - line-height: var(--vscode-editor-line-height, normal); + font-size: var(--font-size-base); + line-height: 1.4; color: var(--text-strong, var(--vscode-input-foreground, #cccccc)); } @@ -33,8 +33,8 @@ [data-component="prompt-input-form"] [data-slot="prompt-input-placeholder"] { padding: 6px 8px; padding-right: 36px; - font-size: var(--vscode-editor-font-size, 13px); - line-height: var(--vscode-editor-line-height, normal); + font-size: var(--font-size-base); + line-height: 1.4; } /* Bottom toolbar: tighter padding to match legacy control bar */ @@ -55,7 +55,7 @@ height: auto !important; min-height: 22px; padding: 4px 6px !important; - font-size: 12px; + font-size: var(--kilo-font-size-12); line-height: normal; border-radius: 6px; background: var(--surface-base); diff --git a/packages/kilo-ui/src/components/select.css b/packages/kilo-ui/src/components/select.css index b2d95724272..d7efba7bdf5 100644 --- a/packages/kilo-ui/src/components/select.css +++ b/packages/kilo-ui/src/components/select.css @@ -2,11 +2,11 @@ /* Select trigger (renders as a Button, override the button sizing) */ [data-component="select"] [data-slot="select-select-trigger"] { - height: 32px; + height: auto; min-height: 32px; border-radius: 2px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); padding: 6px 12px !important; cursor: pointer; background: var(--background-weak); @@ -43,8 +43,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); color: var(--text-base); } @@ -57,6 +57,11 @@ } } +[data-component="select"][data-trigger-style="settings"] [data-slot="select-select-trigger"] { + box-sizing: border-box; + width: 100%; +} + /* Select dropdown content */ [data-component="select-content"] { min-width: 200px !important; @@ -76,7 +81,8 @@ /* Section headers */ [data-slot="select-section"] { - font-size: 11px; + padding: 4px 12px; + font-size: var(--kilo-font-size-11); font-weight: 500; color: var(--text-weak); text-transform: uppercase; @@ -90,8 +96,8 @@ box-sizing: border-box; border-radius: 0; padding: 6px 12px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); cursor: pointer; gap: 8px; diff --git a/packages/kilo-ui/src/components/session-diff.ts b/packages/kilo-ui/src/components/session-diff.ts index a6bea2ed926..d9a079ee5f0 100644 --- a/packages/kilo-ui/src/components/session-diff.ts +++ b/packages/kilo-ui/src/components/session-diff.ts @@ -1 +1 @@ -export { normalize, text, type ViewDiff } from "../../../ui/src/components/session-diff" +export { contents, normalize, text, type DiffText, type ViewDiff } from "../../../ui/src/components/session-diff" diff --git a/packages/kilo-ui/src/components/status-indicator.css b/packages/kilo-ui/src/components/status-indicator.css index b8bdfec65dd..eee7739ec6c 100644 --- a/packages/kilo-ui/src/components/status-indicator.css +++ b/packages/kilo-ui/src/components/status-indicator.css @@ -4,7 +4,7 @@ display: inline-flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: var(--kilo-font-size-13); &[data-status="completed"] { color: var(--icon-success-base); @@ -17,7 +17,7 @@ &[data-status="checkpoint"] { color: var(--text-weak); - font-size: 11px; + font-size: var(--kilo-font-size-11); &::before { content: "\2691"; diff --git a/packages/kilo-ui/src/components/switch.css b/packages/kilo-ui/src/components/switch.css index 3c407874b0f..695d5991b2c 100644 --- a/packages/kilo-ui/src/components/switch.css +++ b/packages/kilo-ui/src/components/switch.css @@ -4,7 +4,7 @@ cursor: pointer; [data-slot="switch-label"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); } [data-slot="switch-control"] { diff --git a/packages/kilo-ui/src/components/tabs.css b/packages/kilo-ui/src/components/tabs.css index 87403cc6a90..a581a0edf0d 100644 --- a/packages/kilo-ui/src/components/tabs.css +++ b/packages/kilo-ui/src/components/tabs.css @@ -1,7 +1,7 @@ /* Kilo Tabs overrides – sized to match @vscode/webview-ui-toolkit panels */ [data-component="tabs"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; [data-slot="tabs-list"] { @@ -18,7 +18,7 @@ } [data-slot="tabs-trigger"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); line-height: normal; padding: 0px; } @@ -33,14 +33,14 @@ &[data-variant="pill"] [data-slot="tabs-trigger"] { padding: 4px 0; border-radius: 0; - font-size: 13px; + font-size: var(--kilo-font-size-13); } /* Default/alt variant adjustments */ [data-slot="tabs-trigger"] { height: 48px; padding: 4px 0; - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; cursor: pointer; } @@ -68,7 +68,7 @@ padding: 8px 16px; > h3 { - font-size: 16px; + font-size: var(--kilo-font-size-16); font-weight: 400; margin: 16px 0; color: var(--vscode-foreground, var(--text-strong)); diff --git a/packages/kilo-ui/src/components/tag.css b/packages/kilo-ui/src/components/tag.css index 416c7a08072..71389ce17a7 100644 --- a/packages/kilo-ui/src/components/tag.css +++ b/packages/kilo-ui/src/components/tag.css @@ -3,8 +3,8 @@ [data-component="tag"], [data-component="badge"] { border-radius: 2px; - font-size: 11px; - line-height: 16px; + font-size: var(--kilo-font-size-11); + line-height: var(--kilo-font-size-16); font-weight: 600; padding: 2px 4px; border: 1px solid transparent; diff --git a/packages/kilo-ui/src/components/task-header.css b/packages/kilo-ui/src/components/task-header.css index df7ddc96766..9f44c093e22 100644 --- a/packages/kilo-ui/src/components/task-header.css +++ b/packages/kilo-ui/src/components/task-header.css @@ -12,7 +12,7 @@ [data-slot="task-header-title"] { flex: 1; - font-size: 14px; + font-size: var(--kilo-font-size-14); font-weight: 500; color: var(--text-strong); overflow: hidden; @@ -25,7 +25,7 @@ align-items: center; gap: 12px; flex-shrink: 0; - font-size: 13px; + font-size: var(--kilo-font-size-13); color: var(--text-weak); white-space: nowrap; } diff --git a/packages/kilo-ui/src/components/text-field.css b/packages/kilo-ui/src/components/text-field.css index 7919d4fc3ca..a8579590cfc 100644 --- a/packages/kilo-ui/src/components/text-field.css +++ b/packages/kilo-ui/src/components/text-field.css @@ -2,8 +2,8 @@ [data-component="input"] { &[data-variant="normal"] [data-slot="input-label"] { - font-size: 12px; - line-height: 14px; + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-14); font-weight: var(--font-weight-regular); color: var(--text-base); } @@ -36,10 +36,10 @@ } &[data-variant="normal"] [data-slot="input-input"] { - height: 32px; + min-height: 32px; padding: 6px 12px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); color: var(--text-base); @@ -55,8 +55,8 @@ } &[data-variant="normal"] [data-slot="input-error"] { - font-size: 12px; - line-height: 14px; + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-14); font-weight: var(--font-weight-regular); color: var(--text-on-critical-base); } diff --git a/packages/kilo-ui/src/components/tooltip.css b/packages/kilo-ui/src/components/tooltip.css index f2c6f81a133..5c38e2d8d62 100644 --- a/packages/kilo-ui/src/components/tooltip.css +++ b/packages/kilo-ui/src/components/tooltip.css @@ -2,7 +2,7 @@ [data-component="tooltip"] { border-radius: 0; - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; font-weight: 400; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.36); diff --git a/packages/kilo-ui/src/styles/globals.css b/packages/kilo-ui/src/styles/globals.css index b995c31af2f..eb946b3785d 100644 --- a/packages/kilo-ui/src/styles/globals.css +++ b/packages/kilo-ui/src/styles/globals.css @@ -8,9 +8,26 @@ /* ===== Global design tokens ===== */ :root { - --font-size-small: 11px; - --font-size-base: 13px; - --font-size-large: 16px; + --kilo-font-size-10: 10px; + --kilo-font-size-11: 11px; + --kilo-font-size-12: 12px; + --kilo-font-size-13: 13px; + --kilo-font-size-14: 14px; + --kilo-font-size-15: 15px; + --kilo-font-size-16: 16px; + --kilo-font-size-17: 17px; + --kilo-font-size-18: 18px; + --kilo-font-size-19: 19px; + --kilo-font-size-20: 20px; + --kilo-font-size-21: 21px; + --kilo-font-size-22: 22px; + --kilo-font-size-23: 23px; + --kilo-font-size-24: 24px; + --kilo-font-scale: 1; + --font-size-x-small: var(--kilo-font-size-10); + --font-size-small: var(--kilo-font-size-11); + --font-size-base: var(--kilo-font-size-13); + --font-size-large: var(--kilo-font-size-16); /* Tight border radius (~2px) */ --radius-xs: 2px; @@ -131,7 +148,7 @@ html[data-theme="kilo-vscode"] { --font-family-sans: var(--vscode-font-family); --font-family-mono: var(--vscode-editor-font-family, Menlo, Monaco, "Courier New", monospace); font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); + font-size: calc(var(--vscode-font-size, 16px) * var(--kilo-font-scale, 1)); font-weight: var(--vscode-font-weight); ::-webkit-scrollbar-button { 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-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 964a8a92c54..daf3437ee8f 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,35 @@ # kilo-code +## 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/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index b11a766659b..de3e2b75e4d 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", 3500] }, }, { 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 f10cdc91f3d..53485a2738c 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.2.42", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -781,11 +781,23 @@ "default": false, "description": "Load CLAUDE.md instructions and skills from your Claude Code configuration directory into Kilo sessions. Enable this if you want Kilo to use your Claude Code instructions and skills." }, + "kilo-code.new.extraCaCerts": { + "type": "string", + "default": "", + "description": "Absolute path to a PEM file containing extra CA certificates to trust when the Kilo backend makes HTTPS requests (sets NODE_EXTRA_CA_CERTS on the CLI process). Use this if you're behind a corporate proxy that performs SSL inspection. Leave empty to rely on the OS trust store." + }, "kilo-code.new.autoApprove.enabled": { "type": "boolean", "default": false, "description": "Start Kilo Code with the main auto-approve toggle enabled. When enabled, permission prompts are approved automatically." }, + "kilo-code.new.fontSize": { + "type": "number", + "default": 13, + "minimum": 10, + "maximum": 24, + "description": "Font size in pixels for the Kilo Code webview UI." + }, "kilo-code.new.browserAutomation.enabled": { "type": "boolean", "default": false, @@ -937,7 +949,6 @@ "quick-lru": "^7.0.0", "simple-git": "3.35.2", "solid-js": "^1.9.11", - "stream-chat": "9.38.0", "uri-js": "^4.4.1", "virtua": "catalog:", "web-tree-sitter": "^0.24.7", diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts deleted file mode 100644 index be434af5157..00000000000 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ /dev/null @@ -1,262 +0,0 @@ -import * as vscode from "vscode" -import type { KiloConnectionService } from "./services/cli-backend" -import { buildWebviewHtml } from "./utils" -import { GitOps } from "./agent-manager/GitOps" -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 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) - - panel.onDidDispose(() => { - this.log("Panel disposed") - this.stopDiffPolling() - 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"), - 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.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 53e8962ccb6..0e2954bbf76 100644 --- a/packages/kilo-vscode/src/DiffVirtualProvider.ts +++ b/packages/kilo-vscode/src/DiffVirtualProvider.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" -import { buildWebviewHtml } from "./utils" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" +import { watchFontSizeConfig } from "./kilo-provider/font-size" import { appendOutput, getWorkspaceRoot } from "./review-utils" import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" @@ -21,6 +22,7 @@ export class DiffVirtualProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private pending: DiffVirtualFile | undefined private outputChannel: vscode.OutputChannel + private fontConfigDisposable: vscode.Disposable | undefined constructor(private readonly extensionUri: vscode.Uri) { this.outputChannel = vscode.window.createOutputChannel("Kilo Diff Virtual") @@ -55,8 +57,12 @@ export class DiffVirtualProvider implements vscode.Disposable { panel.webview.html = this.getHtml(panel.webview) panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg)) + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = watchFontSizeConfig((msg) => this.post(msg)) panel.onDidDispose(() => { this.log("Panel disposed") + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined this.panel = undefined this.pending = undefined }) @@ -72,6 +78,7 @@ export class DiffVirtualProvider implements vscode.Disposable { type: "ready", vscodeLanguage: vscode.env.language, languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), + fontSize: getWebviewFontSize(), workspaceDirectory: getWorkspaceRoot(), }) this.pushData() @@ -113,6 +120,7 @@ export class DiffVirtualProvider implements vscode.Disposable { } public dispose(): void { + this.fontConfigDisposable?.dispose() this.panel?.dispose() this.outputChannel.dispose() } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index f4ad6dd913b..b18192e38f4 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -15,8 +15,8 @@ import { type KiloConnectionService, ServerStartupError } from "./services/cli-b 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 } from "./utils" -import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" +import { TelemetryProxy, type TelemetryPropertiesProvider, pushTelemetryState, watchTelemetryState } from "./services/telemetry" // prettier-ignore import { sessionToWebview, indexProvidersById, @@ -49,6 +49,7 @@ import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" import { handleFileSearch } from "./kilo-provider/file-search" +import { watchFontSizeConfig } from "./kilo-provider/font-size" import { getTerminalContents } from "./services/terminal/context" import { disposeGitChangesTarget } from "./kilo-provider/git-changes-target" import { interceptMessage } from "./kilo-provider/git-changes-request" @@ -104,6 +105,7 @@ import { fetchAndSendPendingQuestions, } from "./kilo-provider/handlers/question" import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion" +import { nativeTitle } from "./kilo-provider/native-tab-title" import { buildActionContext, @@ -121,11 +123,9 @@ 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" type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" - // Helper to map agent data to the subset of fields sent to the webview const mapAgent = (a: Agent) => ({ name: a.name, @@ -216,6 +216,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 @@ -250,10 +251,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) } @@ -268,6 +269,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 }) @@ -351,10 +358,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) { @@ -465,12 +470,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({ @@ -479,10 +481,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) } @@ -581,6 +580,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), @@ -599,7 +600,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper 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) => + vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId }), + currentSessionId: this.currentSession?.id, createWorktree: async (baseBranch, branchName) => { await this.createWorktreeHandler?.(baseBranch, branchName) }, @@ -679,7 +682,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": @@ -1091,6 +1094,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } }) + this.webviewMessageDisposable = watchFontSizeConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) } private openExternal(url: unknown): void { @@ -1246,10 +1250,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper extensionVersion: this.extensionVersion, vscodeLanguage: vscode.env.language, languageOverride: langConfig.get("language"), + fontSize: getWebviewFontSize(), workspaceDirectory: this.getProjectDirectory(this.currentSession?.id), }) } - this.postMessage({ type: "connectionState", state: this.connectionState }) // connect() can resolve after SSE reaches "connected" but before this @@ -1314,7 +1318,7 @@ 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 + this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, workspaceDir) this.trackedSessionIds.add(session.id) @@ -1340,7 +1344,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 } }) @@ -1580,7 +1584,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 }) @@ -1609,7 +1613,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) { @@ -2334,7 +2338,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (refreshProviders) await this.fetchAndSendProviders() } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) - const patch = { ...partial, ...project } + const patch = + partial.indexing === undefined && project.indexing === undefined + ? { ...partial, ...project } + : { ...partial, ...project, indexing: { ...(partial.indexing ?? {}), ...(project.indexing ?? {}) } } const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config const features = (this.cachedConfigMessage as { features?: unknown } | null)?.features const optimistic = @@ -2348,7 +2355,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pending-- } } - private postConfigFailure(error: unknown): void { console.error("[Kilo New] KiloProvider: Failed to update config:", error) this.postMessage({ @@ -2357,7 +2363,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper details: getConfigErrorDetails(error), }) } - private async resolveSession(sessionID?: string, draftID?: string, context?: string) { if (!this.client) return undefined @@ -2372,7 +2377,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!sessionID && !this.currentSession) { const { data: session } = await this.client.session.create({ directory: dir }, { throwOnError: true }) - this.currentSession = session + this.setCurrentSession(session) this.contextSessionID = session.id this.trackDirectory(session.id, dir) this.trackedSessionIds.add(session.id) @@ -2745,7 +2750,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, @@ -3040,12 +3045,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // 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 } @@ -3350,7 +3355,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // ── Worktree stats polling (sidebar diff badge) ────────────────── - private startStatsPolling(): void { this.statsPoller?.stop() this.statsGitOps?.dispose() @@ -3401,6 +3405,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..f8602ef792e 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,7 @@ import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" import { WorktreeImporter } from "./worktree-importer" +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 +69,7 @@ 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 /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on @@ -248,14 +250,22 @@ export class AgentManagerProvider implements Disposable { return } - const migration = await state.load() + 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 +290,20 @@ export class AgentManagerProvider implements Disposable { this.panel?.sessions.recoverPendingPrompts() } + 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 +315,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 @@ -392,7 +417,7 @@ export class AgentManagerProvider implements Disposable { } 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 } @@ -803,6 +828,39 @@ 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.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) @@ -1296,6 +1354,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 +1366,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() @@ -1614,7 +1683,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 +1704,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/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..7ed0c1e0114 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -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. */ diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 3491a613963..2bbe74ce358 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -75,6 +75,12 @@ interface StateFile { 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" @@ -97,7 +103,8 @@ export class WorktreeStateManager { 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 +195,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 @@ -507,7 +539,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 +550,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 +600,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 +610,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 }) @@ -610,7 +662,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 +673,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 + } } } 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/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 26cef806ba2..4939d703ace 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 { hashFileDiffs } from "../diff/shared/hash" +import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DIFF_POLL_INTERVAL_MS } from "../diff/polling" +import { WorktreeDiffClient } from "../diff/shared/client" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" @@ -200,7 +202,7 @@ export class WorktreeDiffController { if (this.session !== sessionId) return this.interval = setInterval(() => { void this.poll(sessionId) - }, 2500) + }, DIFF_POLL_INTERVAL_MS) }) } diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts new file mode 100644 index 00000000000..6bc6ba5470f --- /dev/null +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -0,0 +1,208 @@ +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 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 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 + + if (this.panel && this.controller) { + this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.One) + this.controller.setContext(ctx) + const nextId = this.catalog.defaultSourceId(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. + */ + openFromCommand(arg?: { sessionId?: string; initialSourceId?: string }): void { + this.openPanel({ + workspaceRoot: getWorkspaceRoot(), + sessionId: arg?.sessionId ?? this.sessionIdProvider(), + initialSourceId: arg?.initialSourceId, + }) + } + + /** + * 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.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) + }, + openFile: (msg) => { + if (typeof msg.filePath !== "string") return + openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) + }, + } + + 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..4433cab1dd7 --- /dev/null +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -0,0 +1,142 @@ +import type * as vscode from "vscode" +import type { DiffSource, DiffSourceDescriptor, DiffSourceMessage, DiffSourcePost } from "./sources/types" +import type { PanelContext } from "./types" + +/** + * Owns the active DiffSource for a panel: builds it via the injected + * `build` function, runs initialFetch + start, and disposes it on swap + * or teardown. + * + * Decoupled from the webview panel — receives neutral callbacks. Stale + * messages are filtered via an internal epoch counter that bumps on + * every stop/activate, so async posts from a disposed source are dropped. + */ +export class SourceController { + private ctx: PanelContext | undefined + private activeId: string | undefined + private active: DiffSource | undefined + private startDisposable: vscode.Disposable | 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, + ) {} + + setContext(ctx: PanelContext): void { + this.ctx = ctx + } + + get currentId(): string | undefined { + return this.activeId + } + + /** Dispose the active source and bump the epoch so in-flight posts are dropped. */ + stop(): void { + this.epoch++ + this.startDisposable?.dispose() + this.startDisposable = undefined + this.active?.dispose() + this.active = undefined + this.activeId = undefined + } + + /** + * Build, initial-fetch, and start source `id` in the current context. + * Internally disposes any previously active source. Throws if the + * catalog can't build the id — callers should catch and log. + */ + async activate(id: string): 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.post({ + type: "setAvailableSources", + descriptors: this.listAvailable(ctx), + currentId: id, + }) + this.post({ + type: "diffViewer.capabilities", + capabilities: source.descriptor.capabilities, + }) + + const sourcePost = this.guardedPost(epoch) + await source.initialFetch(sourcePost) + // Prevents source polling from starting after teardown or source swap. + if (this.epoch !== epoch || this.activeId !== id) { + if (this.active === source) source.dispose() + return + } + this.startDisposable = source.start?.(sourcePost) + } + + async revertFile(file: string): Promise { + const source = this.active + if (!source?.revertFile) { + this.post({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Revert is not supported for the current source", + }) + return + } + + const result = await source.revertFile(file).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message } + }) + this.post({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + } + + /** + * Lazy detail load for a single file. Forwards to the active source's + * `requestFile`. 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?.requestFile) { + this.post({ type: "diffViewer.diffFile", file, diff: null }) + return + } + const diff = await source.requestFile(file).catch(() => null) + // Drop the response if the source has been disposed/swapped while we waited. + if (this.epoch !== epoch) return + this.post({ type: "diffViewer.diffFile", file, diff }) + } + + dispose(): void { + this.stop() + } + + private guardedPost(epoch: number): DiffSourcePost { + return (msg: DiffSourceMessage) => { + // Drops stale messages from sources whose lifecycle epoch has ended. + if (this.epoch !== epoch) return + if (msg.type === "diffs") { + this.post({ type: "diffViewer.diffs", diffs: msg.diffs }) + } else if (msg.type === "loading") { + this.post({ type: "diffViewer.loading", loading: msg.loading }) + } else if (msg.type === "error") { + this.post({ type: "diffViewer.loading", loading: false }) + } else if (msg.type === "notice") { + this.post({ type: "diffViewer.notice", notice: msg.notice }) + } + } + } +} 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/worktree-diff-client.ts b/packages/kilo-vscode/src/diff/shared/client.ts similarity index 84% rename from packages/kilo-vscode/src/worktree-diff-client.ts rename to packages/kilo-vscode/src/diff/shared/client.ts index 0b8ecce7df6..18922a77a20 100644 --- a/packages/kilo-vscode/src/worktree-diff-client.ts +++ b/packages/kilo-vscode/src/diff/shared/client.ts @@ -1,5 +1,5 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { GitOps } from "./agent-manager/GitOps" +import type { GitOps } from "../../agent-manager/GitOps" /** * A worktree diff target: the working directory and the base branch we diff @@ -11,11 +11,8 @@ type Status = "added" | "deleted" | "modified" /** * Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the - * small set of operations used by both the sidebar DiffViewerProvider and the - * agent manager's WorktreeDiffController. - * - * Keeping the helper off review-utils.ts: this deals in HTTP + git orchestration, - * not the small path/vscode helpers that file is scoped to. + * small set of operations used by the Changes panel's WorktreeDiffSource and + * the agent manager's WorktreeDiffController. */ export class WorktreeDiffClient { constructor( 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/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..d0d29763975 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -0,0 +1,58 @@ +import type { KiloConnectionService } from "../../services/cli-backend" +import type { PanelContext } from "../types" +import type { DiffSource, DiffSourceDescriptor } from "./types" +import { WorktreeDiffSource, WORKSPACE_DESCRIPTOR, WORKSPACE_SOURCE_ID } from "./worktree" +import { + SESSION_PREFIX, + SessionDiffSource, + sessionDescriptor, + sessionSourceId, + type SessionDiffFetch, + type SnapshotEnabledCheck, +} from "./session" + +/** + * Enumerates and constructs diff sources for a PanelContext. + */ +export class DiffSourceCatalog { + private readonly sessionFetch: SessionDiffFetch = async ({ sessionID, directory }) => { + const client = this.connection.getClient() + const { data } = await client.session.diff({ sessionID, directory }, { throwOnError: true }) + return data ?? [] + } + + 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 + } + + constructor(private readonly connection: KiloConnectionService) {} + + listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { + const out: DiffSourceDescriptor[] = [] + if (ctx.workspaceRoot) out.push(WORKSPACE_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 new WorktreeDiffSource(this.connection) + + 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 new SessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + } + + throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) + } +} 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..b92125e0dd3 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -0,0 +1,134 @@ +import * as vscode from "vscode" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import type { DiffFile } from "../types" +import { hashFileDiffs } from "../shared/hash" +import { DIFF_POLL_INTERVAL_MS } from "../polling" +import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "./types" +import { normalize, text } from "@kilocode/kilo-ui/session-diff" + +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. Initial fetch + 2.5s polling with hash dedup + */ +export class SessionDiffSource implements DiffSource { + readonly descriptor: DiffSourceDescriptor + + private lastHash: string | undefined + private interval: ReturnType | undefined + private disposed = false + + private snapshotsDisabled = false + + constructor( + private readonly sessionId: string, + private readonly fetch: SessionDiffFetch, + private readonly workspaceRoot?: string, + private readonly checkSnapshotsEnabled?: SnapshotEnabledCheck, + ) { + this.descriptor = sessionDescriptor(sessionId) + } + + async initialFetch(post: DiffSourcePost): Promise { + post({ type: "loading", loading: true }) + + try { + if (this.checkSnapshotsEnabled) { + const enabled = await this.checkSnapshotsEnabled(this.workspaceRoot) + if (this.disposed) return + if (!enabled) { + this.snapshotsDisabled = true + post({ type: "notice", notice: "snapshots-disabled" }) + post({ type: "diffs", diffs: [] }) + return + } + } + + const diffs = await this.fetchDiffs() + if (this.disposed) return + this.lastHash = hashFileDiffs(diffs as never) + post({ type: "diffs", diffs }) + } catch (err) { + if (this.disposed) return + const message = err instanceof Error ? err.message : String(err) + post({ type: "error", message }) + } finally { + if (!this.disposed) post({ type: "loading", loading: false }) + } + } + + start(post: DiffSourcePost): vscode.Disposable { + this.stopPolling() + // Skip polling entirely when snapshots are disabled — nothing to fetch. + if (this.snapshotsDisabled) return new vscode.Disposable(() => {}) + this.interval = setInterval(() => { + void this.poll(post) + }, DIFF_POLL_INTERVAL_MS) + + return new vscode.Disposable(() => this.stopPolling()) + } + + dispose(): void { + this.disposed = true + this.stopPolling() + this.lastHash = undefined + } + + private async fetchDiffs(): Promise { + const raw = await this.fetch({ sessionID: this.sessionId, directory: this.workspaceRoot }) + return raw.map((r) => { + // Empty patch means binary or summarized (>256 KB) — normalize() can't + // parse it, so short-circuit to empty strings. + const view = r.patch === "" ? null : normalize(r) + return { + file: r.file, + before: view ? text(view, "deletions") : "", + after: view ? text(view, "additions") : "", + additions: r.additions, + deletions: r.deletions, + status: r.status, + tracked: true, + generatedLike: false, + summarized: r.patch === "", + } + }) + } + + private async poll(post: DiffSourcePost): Promise { + try { + const diffs = await this.fetchDiffs() + if (this.disposed) return + const hash = hashFileDiffs(diffs as never) + if (hash === this.lastHash) return + this.lastHash = hash + post({ type: "diffs", diffs }) + } catch (err) { + if (this.disposed) return + console.log("[Kilo New] SessionDiffSource.poll error", err) + } + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } +} 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..2ac5d01caf9 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/types.ts @@ -0,0 +1,62 @@ +import type * as vscode from "vscode" +import type { DiffFile } from "../types" + +export interface DiffSourceCapabilities { + revert: boolean + comments: boolean +} + +/** + * Closed enum of diff source kinds. Drives i18n key composition: + * `diffViewer.source..label` and `diffViewer.source..tooltip`. + */ +export type DiffSourceType = "workspace" | "session" + +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 type DiffSourceMessage = + | { type: "diffs"; diffs: DiffFile[] } + | { type: "loading"; loading: boolean } + | { type: "error"; message: string } + | { type: "notice"; notice: DiffSourceNotice | undefined } + +export type DiffSourcePost = (msg: DiffSourceMessage) => void + +/** + * A DiffSource produces file diffs for a given context (local workspace, + * session changes, a turn, a git ref...). The SourceController owns one + * active source at a time and swaps between them on user request. + */ +export interface DiffSource { + readonly descriptor: DiffSourceDescriptor + + initialFetch(post: DiffSourcePost): Promise + + /** Start change detection (polling, SSE, watcher...). Dispose to stop. */ + start?(post: DiffSourcePost): vscode.Disposable + + revertFile?(file: string): Promise<{ ok: boolean; message: string }> + + /** + * 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. + */ + requestFile?(file: string): Promise + + dispose(): void +} 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..c31ceaf9241 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -0,0 +1,177 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../../services/cli-backend" +import { GitOps } from "../../agent-manager/GitOps" +import { diffSummary, diffFile } from "../../agent-manager/local-diff" +import type { WorktreeDiffEntry } from "../../agent-manager/types" +import { WorktreeDiffClient, type DiffTarget } from "../shared/client" +import { hashFileDiffs } from "../shared/hash" +import { resolveLocalDiffTarget } from "../shared/target" +import { DIFF_POLL_INTERVAL_MS } from "../polling" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } 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 }, +} + +/** + * Diffs between the local working tree and the base branch. Polls a summary + * (one entry per changed file, no content) every {@link DIFF_POLL_INTERVAL_MS}, + * then loads `before`/`after`/`patch` per file on demand via {@link requestFile}. + * + * Mirrors the Agent Manager's `WorktreeDiffController` and runs entirely in + * the extension host (no `kilo serve` round-trip) + */ +export class WorktreeDiffSource implements DiffSource { + readonly descriptor = WORKSPACE_DESCRIPTOR + + private readonly git: GitOps + private readonly output: vscode.OutputChannel + private target: DiffTarget | undefined + private lastHash: string | undefined + private interval: ReturnType | undefined + private post: DiffSourcePost | undefined + + constructor(private readonly connection: KiloConnectionService) { + this.git = new GitOps({ log: (...args) => this.log(...args) }) + this.output = vscode.window.createOutputChannel("Kilo Diff: Workspace") + } + + async initialFetch(post: DiffSourcePost): Promise { + this.post = post + post({ type: "loading", loading: true }) + + const target = await this.resolveTarget() + if (!target) { + post({ type: "diffs", diffs: [] }) + post({ type: "loading", loading: false }) + return + } + + this.target = target + await this.fetchAndPost(target, post, true) + post({ type: "loading", loading: false }) + } + + start(post: DiffSourcePost): vscode.Disposable { + this.post = post + this.stopPolling() + this.interval = setInterval(() => { + void this.poll(post) + }, DIFF_POLL_INTERVAL_MS) + + return new vscode.Disposable(() => this.stopPolling()) + } + + async revertFile(file: string): Promise<{ ok: boolean; message: string }> { + const target = this.target ?? (await this.resolveTarget()) + if (!target) { + return { ok: false, message: "Could not resolve diff target" } + } + + try { + const client = this.connection.getClient() + const diff = new WorktreeDiffClient(client, this.git, (...args) => this.log(...args)) + const result = await diff.revertFile(target, file) + if (result.ok && this.post) void this.poll(this.post) + return result + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to revert file:", message) + return { ok: false, message } + } + } + + async requestFile(file: string): Promise { + if (!file) return null + const target = this.target ?? (await this.resolveTarget()) + if (!target) return null + this.target = target + + try { + const entry = await diffFile(this.git, target.directory, target.baseBranch, file, (...args) => this.log(...args)) + if (!entry) return null + return toDiffFile(entry) + } catch (err) { + this.log("Failed to fetch worktree diff file:", err) + return null + } + } + + dispose(): void { + this.stopPolling() + this.git.dispose() + this.output.dispose() + this.post = undefined + this.target = undefined + this.lastHash = undefined + } + + private async resolveTarget(): Promise { + return await resolveLocalDiffTarget(this.git, (...args) => this.log(...args), getWorkspaceRoot()) + } + + private async fetchAndPost(target: DiffTarget, post: DiffSourcePost, force: boolean): Promise { + try { + const entries = await diffSummary(this.git, target.directory, target.baseBranch, (...args) => this.log(...args)) + const diffs = entries.map(toDiffFile) + const hash = hashFileDiffs(diffs as never) + if (!force && hash === this.lastHash) return + this.lastHash = hash + + this.log(`Diff: ${diffs.length} file(s)`) + post({ type: "diffs", diffs }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to fetch diff:", message) + if (force) post({ type: "error", message }) + } + } + + private async poll(post: DiffSourcePost): Promise { + const target = this.target + if (!target) { + await this.initialFetch(post) + return + } + await this.fetchAndPost(target, post, false) + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } + + private log(...args: unknown[]): void { + appendOutput(this.output, "WorktreeDiffSource", ...args) + } +} + +/** + * 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..9850668212e --- /dev/null +++ b/packages/kilo-vscode/src/diff/types.ts @@ -0,0 +1,20 @@ +export interface PanelContext { + workspaceRoot: string | undefined + sessionId?: string + /** Overrides the computed default source on open. */ + initialSourceId?: 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/extension.ts b/packages/kilo-vscode/src/extension.ts index fadee229dc8..2ba3759cb5d 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" @@ -21,6 +22,12 @@ import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" +let agentManager: AgentManagerProvider | undefined + +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; @@ -50,6 +57,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 +77,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) @@ -111,6 +131,7 @@ 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) + agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) // Wire "Continue in Worktree" from sidebar → Agent Manager @@ -171,7 +192,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 +220,10 @@ 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) + const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService, diffSourceCatalog, { + sessionIdProvider: () => provider.getCurrentSessionId(), + }) diffViewerProvider.setCommentHandler((comments, autoSend) => { void provider.appendReviewComments(comments, autoSend) }) @@ -323,9 +348,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; initialSourceId?: string }) => { + diffViewerProvider.openFromCommand(arg) + }, + ), vscode.commands.registerCommand("kilo-code.new.openSubAgentViewer", (sessionID: string, title?: string) => { subAgentViewerProvider.openPanel(sessionID, title) }), @@ -431,7 +459,8 @@ export function activate(context: vscode.ExtensionContext) { }) } -export function deactivate() { +export async function deactivate() { + await agentManager?.shutdown() TelemetryProxy.getInstance().shutdown() } @@ -464,7 +493,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/font-size.ts b/packages/kilo-vscode/src/kilo-provider/font-size.ts new file mode 100644 index 00000000000..1338cbe3832 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/font-size.ts @@ -0,0 +1,13 @@ +import * as vscode from "vscode" +import { getWebviewFontSize } from "../utils" + +export function watchFontSizeConfig( + post: (msg: { type: "fontSizeChanged"; fontSize: number }) => void, + next?: vscode.Disposable, +) { + const font = vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("kilo-code.new.fontSize")) + post({ type: "fontSizeChanged", fontSize: getWebviewFontSize() }) + }) + return next ? vscode.Disposable.from(font, next) : font +} 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..5889a6ea5e2 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -0,0 +1,5 @@ +export type KiloProviderOptions = { + projectDirectory?: string | null + slimEditMetadata?: boolean + tabTitle?: (title: string) => void +} diff --git a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts index a478d9b13c4..84cccf373bf 100644 --- a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -13,7 +13,8 @@ interface Ctx { post: (msg: unknown) => void openAgentManager: () => Thenable openAdvancedWorktree: () => Thenable - openChanges: () => Thenable + openChanges: (sessionId?: string) => Thenable + currentSessionId?: string createWorktree?: (baseBranch?: string, branchName?: string) => Promise continueInWorktree?: ( sessionId: string, @@ -53,7 +54,7 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { } if (message.type === "openChanges") { - await ctx.openChanges() + await ctx.openChanges(ctx.currentSessionId) 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 aa5868bea75..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" @@ -10,35 +15,80 @@ import { homedir } from "os" import type { KiloConnectionService } from "../services/cli-backend" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildWebviewHtml } from "../utils" -import { connect, history, presence, type ClawChatClient } from "./chat-client" +import { watchFontSizeConfig } from "../kilo-provider/font-size" +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, @@ -105,18 +155,24 @@ 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 }) }) this.subs.push(unsub) + const font = watchFontSizeConfig((msg) => this.post(msg)) + this.subs.push(() => font.dispose()) } private post(msg: KiloClawOutMessage): void { @@ -127,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 } } @@ -150,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 { @@ -171,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() @@ -271,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) @@ -371,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() @@ -384,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 0b0dd9ab71b..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,23 +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..3d8e4089224 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,7 @@ export function computeDefaultSelection( type PostMessage = (message: unknown) => void type GetErrorMessage = (error: unknown) => string +type SetCachedConfig = (msg: unknown) => void interface ActionContext { client: KiloClient @@ -155,6 +178,69 @@ function validateID( return null } +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 } = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) + if (!config) return + const features = configFeatures(config) + setCachedConfig({ type: "configLoaded", config, features }) + ctx.postMessage({ type: "configUpdated", config, 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) { const id = validateID(ctx, requestId, providerID, "connect") if (!id) return @@ -230,64 +316,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 }) 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/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 7e3c7dc82e6..18c176c389a 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -15,6 +15,17 @@ export interface ServerInstance { const STARTUP_TIMEOUT_SECONDS = 30 +type WorkspaceFolderLike = { uri: { fsPath: string } } + +export function resolveServerCwd(folders: readonly WorkspaceFolderLike[] | undefined, storage: string): string { + return folders?.[0]?.uri.fsPath ?? storage +} + +export function resolveIndexingEnv(folders: readonly WorkspaceFolderLike[] | undefined): Record { + if (folders && folders.length > 0) return {} + return { KILO_DISABLE_CODEBASE_INDEXING: "vscode-no-workspace" } +} + export class ServerManager { private instance: ServerInstance | null = null private startupPromise: Promise | null = null @@ -66,13 +77,39 @@ export class ServerManager { return new Promise((resolve, reject) => { console.log("[Kilo New] ServerManager: 🎬 Spawning CLI process:", cliPath, ["serve", "--port", "0"]) - const claudeCompat = vscode.workspace.getConfiguration("kilo-code.new").get("claudeCodeCompat", false) + const cfg = vscode.workspace.getConfiguration("kilo-code.new") + const claudeCompat = cfg.get("claudeCodeCompat", false) // Pin cwd so the CLI doesn't inherit the extension host's cwd ("/" under F5 debug) - const spawnCwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.env.HOME ?? require("os").homedir() + // or "$HOME" in empty VS Code windows. + const folders = vscode.workspace.workspaceFolders + const spawnCwd = resolveServerCwd(folders, this.context.globalStorageUri.fsPath) + fs.mkdirSync(spawnCwd, { recursive: true }) + const indexingEnv = resolveIndexingEnv(folders) + // TLS / corporate-proxy support: + // - Default NODE_USE_SYSTEM_CA=1 so the bundled Bun CLI trusts the OS + // trust store (Windows cert store, macOS keychain, Linux /etc/ssl). + // Mirrors VS Code's `http.systemCertificates` default (true). + // - Allow users behind MITM proxies to point at a custom CA bundle via + // `kilo-code.new.extraCaCerts` (NODE_EXTRA_CA_CERTS). + // - Honor VS Code's `http.proxyStrictSSL=false` as an explicit opt-out + // from verification, matching what VS Code already does for its own + // requests. Users explicitly set that; we don't flip it ourselves. + // All three are overridable by the user's environment. + const extraCaCerts = cfg.get("extraCaCerts", "").trim() + const proxyStrictSSL = vscode.workspace.getConfiguration("http").get("proxyStrictSSL", true) const serverProcess = spawn(cliPath, ["serve", "--port", "0"], { cwd: spawnCwd, env: { + NODE_USE_SYSTEM_CA: "1", + ...(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 @@ -84,6 +121,7 @@ export class ServerManager { KILO_CLIENT: "vscode", KILO_ENABLE_QUESTION_TOOL: "true", KILOCODE_FEATURE: "vscode-extension", + ...indexingEnv, KILO_TELEMETRY_LEVEL: vscode.env.isTelemetryEnabled ? "all" : "off", KILO_APP_NAME: "kilo-code", KILO_EDITOR_NAME: vscode.env.appName, @@ -225,6 +263,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/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/utils.ts b/packages/kilo-vscode/src/utils.ts index 6bdcfe065d6..1936575c222 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -6,6 +6,31 @@ function getNonce(): string { return crypto.randomBytes(16).toString("hex") } +const SIZES = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24] + +function clamp(size: number) { + if (!Number.isFinite(size)) return 13 + return Math.min(24, Math.max(10, Math.round(size))) +} + +export function getWebviewFontSize(): number { + const raw = vscode.workspace.getConfiguration("kilo-code.new").get("fontSize", 13) + return clamp(raw) +} + +function fontStyle(): string { + const base = getWebviewFontSize() + const vars = SIZES.map((size) => `--kilo-font-size-${size}: ${(base * size) / 13}px;`).join("\n ") + return `:root { + ${vars} + --kilo-font-scale: ${base / 13}; + --font-size-x-small: var(--kilo-font-size-10); + --font-size-small: var(--kilo-font-size-11); + --font-size-base: var(--kilo-font-size-13); + --font-size-large: var(--kilo-font-size-16); + }` +} + export function buildWebviewHtml( webview: vscode.Webview, opts: { @@ -29,6 +54,7 @@ export function buildWebviewHtml( ${opts.title}