diff --git a/.changeset/azure-provider-settings.md b/.changeset/azure-provider-settings.md new file mode 100644 index 00000000000..7bd22e1e156 --- /dev/null +++ b/.changeset/azure-provider-settings.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Support configuring Azure OpenAI resource names or endpoint URLs from the provider settings flow, and document using the native Azure provider for GPT-5 family deployments. diff --git a/.changeset/fix-subagent-cost-double-counting.md b/.changeset/fix-subagent-cost-double-counting.md new file mode 100644 index 00000000000..02898329539 --- /dev/null +++ b/.changeset/fix-subagent-cost-double-counting.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the task cost shown at the top of the chat double-counting subagent costs. Sessions that spawned subagents were overreporting their totals because the backend already rolls descendant costs up into the parent session, and the webview was then summing them again. diff --git a/.changeset/fold-auto-approve-exceptions.md b/.changeset/fold-auto-approve-exceptions.md new file mode 100644 index 00000000000..e420831df8c --- /dev/null +++ b/.changeset/fold-auto-approve-exceptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Collapse the Auto-Approve Exceptions list by default when it has more than 5 entries. 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/open-write-diff-viewer.md b/.changeset/open-write-diff-viewer.md new file mode 100644 index 00000000000..c9ce4d18efc --- /dev/null +++ b/.changeset/open-write-diff-viewer.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show an "Open in Diff Viewer" icon on Write tool calls (matching the Edit tool), so the changes can be inspected in a full diff tab. diff --git a/.changeset/soft-compaction-recovery.md b/.changeset/soft-compaction-recovery.md new file mode 100644 index 00000000000..7be1338ca62 --- /dev/null +++ b/.changeset/soft-compaction-recovery.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Recover compaction when large tool results or media attachments exceed provider payload limits. diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index c7660a5d577..b0604eb8ebc 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -96,6 +96,16 @@ jobs: echo "cli_url=$URL" >> "$GITHUB_OUTPUT" echo "::notice::Testing CLI v${VERSION} via asset API: $URL" + # Harbor's default agent-setup timeout is 360s. The hello-world container + # (FROM ubuntu:24.04) needs apt-get update + apt-get install + a NodeSource + # curl|bash + apt install nodejs before the CLI even downloads, and on + # the Blacksmith runners that occasionally pushes past 6 min when an apt + # mirror or NodeSource cdn is slow, killing the run with + # AgentSetupTimeoutError. Doubling the multiplier to 2 gives enough + # headroom for transient mirror/cdn slowness while still finishing well + # under timeout-minutes. Note: --timeout-multiplier scales every timeout + # uniformly (setup, agent, verifier, env build), but they're all upper + # limits so this is harmless. - name: Run smoke test — hello-world env: KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }} @@ -104,7 +114,8 @@ jobs: ./scripts/run_eval.sh \ -m kilo/anthropic/claude-sonnet-4.6 \ -d hello-world \ - --job-name smoke-test-hello-world + --job-name smoke-test-hello-world \ + --timeout-multiplier 2 - name: Run smoke test — log-summary-date-ranges env: @@ -115,11 +126,18 @@ jobs: -m kilo/anthropic/claude-sonnet-4.6 \ -d terminal-bench-sample \ -t "log-summary-date-ranges" \ - --job-name smoke-test-log-summary + --job-name smoke-test-log-summary \ + --timeout-multiplier 2 - name: Validate results run: python3 scripts/validate_smoke_test.py jobs/smoke-test-*/ + # Also upload the agent setup logs (stdout/stderr/return-code from the + # CLI install script) so we can see exactly which step stalls when the + # next AgentSetupTimeoutError happens. These come from the install + # script in kilo-bench, which uses `set -euo pipefail` (no `set -x`) + # and never echoes auth tokens or API keys, so the captured output is + # safe to upload. - name: Upload results if: always() uses: actions/upload-artifact@v7 # kilocode_change @@ -128,5 +146,6 @@ jobs: path: | jobs/smoke-test-*/**/result.json jobs/smoke-test-*/**/trajectory.json + jobs/smoke-test-*/**/agent/setup/*.txt retention-days: 30 if-no-files-found: warn diff --git a/.kilo/command/upstream-manual-merge.md b/.kilo/agent/upstream-merge.md similarity index 61% rename from .kilo/command/upstream-manual-merge.md rename to .kilo/agent/upstream-merge.md index cebc4045353..d8aff1fd6c0 100644 --- a/.kilo/command/upstream-manual-merge.md +++ b/.kilo/agent/upstream-merge.md @@ -1,14 +1,53 @@ --- -description: Resolve upstream merge conflicts +description: Resolve upstream opencode merge conflicts interactively +mode: primary +permission: + read: ask + edit: ask + webfetch: ask + bash: + "*": ask + "git status *": allow + "git log *": allow + "git diff *": allow + "git show *": allow + "git ls-files *": allow + "git ls-tree *": allow + "git grep *": allow + "git hash-object *": allow + "git remote -v *": allow + "git rev-parse *": allow + "git merge-base *": allow + "git show-ref *": allow + "git worktree list": allow + "git branch --show-current": allow + "grep *": allow + "rg *": allow + "head *": allow + "tail *": allow + "cat *": allow + "wc *": allow + "ls *": allow + "pwd *": allow + "diff *": allow + "gh pr view *": allow + "gh run view *": allow + "gh api \"repos/sst/opencode/commits/dev\" *": allow + "axiom *": allow + "bun test *": allow + "bun run typecheck *": allow + "bun run lint *": allow + "bun run script/check-opencode-annotations.ts *": allow + "script/upstream/find-conflict-markers.sh *": allow + "./script/upstream/find-conflict-markers.sh *": allow --- Resolve the manual part of an upstream merge. -Arguments: `$ARGUMENTS` - -Use the first argument as the upstream version, for example `v1.1.50` or -`1.1.50`. If no argument is provided, infer the version from the current branch -name, `upstream-merge-report-.md`, or the newest relevant report file. +The user will provide the upstream version (for example `v1.1.50` or `1.1.50`) +in their first message. If they don't, infer it from the current branch name, +from `upstream-merge-report-.md`, or from the newest relevant report +file. ## Workflow @@ -44,6 +83,11 @@ git log --all --oneline -- Look at the commit message and any PR reference. "We wrote our own because of PR #NNNN" is a real constraint; "we wrote our own because of a typo" is not. +When upstream narrows an externally-visible compatibility list (models, +providers, routes, config keys, file formats), verify the intent from upstream +PRs, issues, release notes, or current docs before dropping entries. Treat +silent list shrinkage during a refactor as suspicious until proven intentional. + ### 3. Write a plan in chat and get approval For every conflicted file (and any adjacent file the resolution forces you to @@ -112,6 +156,9 @@ Apply in order: there and list both paths in the final summary. Verify the new file already carries the Kilo-renamed symbols (e.g. `x-kilo-directory`) by diffing against pristine upstream. +- if upstream extracts shared policy into a helper, move Kilo-specific additions + into the helper when possible instead of keeping a pre-check at the old call + site. The extracted helper should stay the source of truth for all callers. - if upstream deleted a file, analyse whether the Kilo behaviour should be ported elsewhere or removed rather than restoring the deleted file - if tests fail only because upstream intentionally removed behaviour, remove @@ -151,6 +198,26 @@ ported in too. After resolving the flagged file, check: Add any such files to the plan as `hybrid` or `take-ours` with the same approval flow. +### 6.5. Scan auto-merged files for latent bugs + +Files not in `--diff-filter=U` merged without conflict markers but may still +be broken. Check every auto-merged file for: + +- **Duplicate declarations in the same scope.** If both sides added equivalent + code independently, auto-merge keeps both. Grep touched functions for + repeated identifiers before trusting the merge. +- **Duplicate keys in config/manifest files.** If both sides added the same + entry to a shared manifest (dependencies, scripts, workflow lists), the + merged file may have the key twice. This often breaks install/setup before + any test runs — a cheap early win to scan for. +- **Orphaned imports and references.** A rename upstream may leave a Kilo + callsite pointing at a now-missing export. Run full typecheck from the repo + root; references that silently survived the merge surface there. +- **Partial auto-merges.** Upstream may have refactored a region Kilo + deliberately stubbed out (commented blocks, removed fallbacks). If the + auto-merge pulled in references to names that only exist in the removed + path, the file compiles upstream but breaks on Kilo. + ### 7. Verify each resolution before moving on - confirm `script/upstream/find-conflict-markers.sh ` prints nothing @@ -189,6 +256,49 @@ git commit -m "resolve merge conflicts" The default `git merge` auto-message (`Merge branch '…' into …`) is also fine, but `resolve merge conflicts` is the convention for these PRs. +### 9.5. Handle downstream API renames as separate commits + +Upstream often renames exported APIs. The rename itself auto-merges cleanly in +shared code, but the change cascades into Kilo-only files (kilocode tests, +kilo-specific source, plugins) that still reference the old symbol. Those +files don't appear in `--diff-filter=U` because their own content didn't +conflict. + +Keep the behavioural merge commit focused on resolution decisions. Land the +cascade in one or more follow-up commits: + +- after the merge commit, run full repo typecheck and collect every "cannot + find name" / "property does not exist" error +- bulk-rename with a mechanical transform when the rename is one-to-one +- restructure or parameter-thread when upstream changed semantics, not just + the name (e.g. moved a helper behind a dependency-injected surface, so + callers now need the injected handle) +- split large downstream refactors into their own commits with messages that + name the rename + +Reviewers can then skim the behavioural commit without untangling mechanical +rename noise from merge decisions. + +### 9.6. Handle upstream-added tests that diverge from Kilo + +Upstream sometimes adds tests that encode design contracts Kilo intentionally +breaks. These auto-merge cleanly and then fail. Three resolution patterns: + +- **Rewrite the test** when the test is a contract assertion and Kilo has a + different but equally valid contract. Invert or adjust the assertion with a + `kilocode_change` marker explaining the divergence. +- **Skip the test** when the test relies on patterns that Kilo has replaced + (interception seams that are bypassed by dependency injection, fixture + helpers bound to a removed API, assumptions about serialization shape that + Kilo's extensions break). Mark with `kilocode_change` and a rationale + explaining what would need to change for the test to run. +- **Delete the test** when it covers functionality Kilo deliberately removed + (fallback paths, deprecated endpoints, products Kilo doesn't ship). Note + the deletion in the PR body. + +Never silently delete; always leave a breadcrumb. A future reviewer should be +able to understand why this one upstream test is treated differently. + ### 10. Resync version strings in a separate commit Upstream stamps its own version into shared files — notably @@ -261,3 +371,37 @@ config schema behaviour, migrations, provider routing, or security posture. - Stricter DOM lib types (upstream TS upgrade) can surface latent casting issues around `WebSocket.send`, `Headers`, etc. — prefer narrowing the Kilo type over adding `any` casts. +- Auto-merge can duplicate the same declaration twice in one scope when both + sides added equivalent code independently. Silent for git, caught by + typecheck. Same hazard for duplicated object keys in config/manifest files — + those can break install before any test runs. +- Kilo code may rely on ambient context (async-local storage, globally-set + flags, process env) being populated at a lifecycle moment that upstream + refactors away. If Kilo behaviour reads ambient state during init, forked + work, or event handlers, check the refactor still establishes that state at + the right time. Fix by restoring the ambient state, or by threading the + needed value through explicitly. +- Tests that intercept via process-global or module-global spies can become + no-ops after upstream moves the intercepted code path through dependency + injection. The production code no longer touches the spied symbol. Fixing + the test usually means injecting a mock at the new seam rather than tweaking + the spy. +- When Kilo extends a shared data shape with extra optional fields, different + serialization paths for that shape can diverge on whether missing values are + omitted or emitted as null. Parity tests between two such paths break on + every Kilo addition — audit the encoding assumption before adding fields. +- Rule ordering in allowlist/permission evaluation is usually last-match-wins. + Re-declaring a catch-all rule "for safety" in a later ruleset silently + overrides more specific allow rules from an earlier ruleset. Treat a + redundant catch-all as destructive, not defensive. +- Upstream-added tests can encode a design contract Kilo deliberately breaks. + The test passes upstream because upstream doesn't share Kilo's requirement. + Decide between refactoring Kilo to match the upstream contract or rewriting + the test to assert Kilo's divergent contract — with a `kilocode_change` + marker explaining the divergence. +- CI and local can show different test failures. Tests that read user-local + state (home dir, global config, auth tokens) pass in one environment but + fail in the other. A green local run does not imply green CI. +- Dependency manifests and lockfiles move together. When the merge edits one, + regenerate and commit the other in the same change — otherwise CI breaks on + the follow-up setup step. diff --git a/.kilo/skills/gh-issues/SKILL.md b/.kilo/skills/gh-issues/SKILL.md new file mode 100644 index 00000000000..9a5bfefdf30 --- /dev/null +++ b/.kilo/skills/gh-issues/SKILL.md @@ -0,0 +1,68 @@ +--- +name: gh-issues +description: Use when creating, triaging, or commenting on GitHub issues for the Kilo VS Code extension or JetBrains plugin via `gh`. Covers issue templates, project board assignment, title conventions, and required `gh` scopes. +--- + +# GitHub Issues + +Use this skill whenever you create or manage a GitHub issue with `gh` for either the VS Code extension or the JetBrains plugin. + +## Templates + +The repo defines issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template instead of opening a blank issue: + +| Template | When to use | +|---|---| +| `Bug report` (`bug-report.yml`) | Reproducible defects with steps, expected, and actual behavior | +| `Feature Request` (`feature-request.yml`) | New capabilities, enhancements, or behavior changes | +| `Question` (`question.yml`) | Usage or design questions that aren't obviously bugs or feature requests | + +Pass the template title to `gh issue create --template`. + +## Title Conventions + +- Use a plain, descriptive title that reads cleanly as a standalone sentence. +- Do not add platform-specific prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Routing happens through project boards, not the title. + +## Project Boards + +Every new issue must land on the correct project board: + +| Surface | Project | URL | +|---|---|---| +| VS Code extension | `VS Code Extension` | https://github.com/orgs/Kilo-Org/projects/25 | +| JetBrains plugin | `Jetbrains Plugin` | https://github.com/orgs/Kilo-Org/projects/39 | + +Pass the project title to `gh issue create --project`. + +## Recipes + +Create a VS Code extension bug report and add it to the board: + +```bash +gh issue create \ + --template "Bug report" \ + --project "VS Code Extension" \ + --title "Sidebar chat fails to render after reload" \ + --body "..." +``` + +Create a JetBrains feature request: + +```bash +gh issue create \ + --template "Feature Request" \ + --project "Jetbrains Plugin" \ + --title "Support Kotlin Multiplatform target detection" \ + --body "..." +``` + +## Scope Errors + +If `gh` reports a missing scope when assigning a project, refresh the auth token and retry: + +```bash +gh auth refresh -s project +``` + +After the refresh succeeds, re-run the original `gh issue create` command. Do not fall back to creating the issue without the project — the board assignment is required. diff --git a/.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 cc5eda0d054..34fec96cc6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,12 +223,7 @@ PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus ## GitHub Issues -- When creating a GitHub issue for the VS Code extension or JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. -- Do not add platform-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Use a plain, descriptive title. -- Always add VS Code extension issues to the GitHub project `VS Code Extension`: https://github.com/orgs/Kilo-Org/projects/25 -- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 -- When using `gh`, prefer `gh issue create --template "..." --project "..."` with the matching project title. -- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. +When creating or managing GitHub issues for the VS Code extension or JetBrains plugin via `gh`, load `.kilo/skills/gh-issues/SKILL.md`. It covers templates, project boards (`VS Code Extension`, `Jetbrains Plugin`), title conventions, and the `gh auth refresh -s project` recovery path. ## Fork Merge Process @@ -262,50 +257,13 @@ The goal is to keep our diff from upstream as small as possible, making regular ### Kilocode Change Markers -To minimize merge conflicts when syncing with upstream, mark Kilo Code-specific changes in shared code with `kilocode_change` comments. +When editing shared upstream files, mark Kilo-specific lines with `kilocode_change` comments so future merges can find them. The basic forms are: -**Single line:** +- Single line: `const value = 42 // kilocode_change` +- Multi-line block: wrap with `// kilocode_change start` / `// kilocode_change end` +- New file in a shared path: `// kilocode_change - new file` at the top +- JSX/TSX: use `{/* kilocode_change */}` (and `{/* kilocode_change start */}` / `end`) -```typescript -const value = 42 // kilocode_change -``` +Markers are NOT needed in paths that contain `kilocode` in the name (e.g. `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`) — these are entirely Kilo Code additions and won't conflict with upstream. -**Multi-line:** - -```typescript -// kilocode_change start -const foo = 1 -const bar = 2 -// kilocode_change end -``` - -**New files:** - -```typescript -// kilocode_change - new file -``` - - -**JSX/TSX (inside JSX templates):** - - -```tsx -{/* kilocode_change */} -``` - - -```tsx -{/* kilocode_change start */} - -{/* kilocode_change end */} -``` - -#### When markers are NOT needed - -Code in these paths is Kilo Code-specific and does NOT need `kilocode_change` markers: - -- `packages/opencode/src/kilocode/` - All files in this directory -- `packages/opencode/test/kilocode/` - All test files for kilocode -- Any other path containing `kilocode` in filename or directory name - -These paths are entirely Kilo Code additions and won't conflict with upstream. +For decision rules on when to keep changes inline vs. extract Kilo logic, marker placement guidance, and verification commands, load `.kilo/skills/kilocode-merge-minimizer/SKILL.md`. diff --git a/README.md b/README.md index 5d48917f89c..3a9c92b457d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ## Get Started in Visual Studio Code 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). -2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 – with transparent pricing that matches provider rates exactly. +2. Create your account to access 500+ cutting-edge AI models including GPT-5.5, Claude Opus 4.7, Claude Sonnet 4.6, and Gemini 3.1 Pro Preview, with transparent pricing that matches provider rates exactly. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: Watch the video diff --git a/bun.lock b/bun.lock index 66f76f529e7..084d5a25925 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.42", + "version": "7.2.44", "bin": { "opencode": "./bin/opencode", }, @@ -68,7 +68,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.42", + "version": "7.2.44", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -97,7 +97,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.42", + "version": "7.2.44", "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.42", + "version": "7.2.44", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -143,7 +143,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.42", + "version": "7.2.44", "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.42", + "version": "7.2.44", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -191,7 +191,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.42", + "version": "7.2.44", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", @@ -226,7 +226,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.42", + "version": "7.2.44", "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.42", + "version": "7.2.44", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -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.42", + "version": "7.2.44", "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.42", + "version": "7.2.44", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +480,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.42", + "version": "7.2.44", "dependencies": { "cross-spawn": "catalog:", }, @@ -497,7 +495,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.42", + "version": "7.2.44", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -520,7 +518,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.42", + "version": "7.2.44", "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=="], @@ -3404,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=="], @@ -4156,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=="], @@ -4950,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=="], @@ -5074,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=="], @@ -5188,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=="], @@ -5262,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=="], @@ -5600,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=="], @@ -5800,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 04605df30f9..948a66c017f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-BkFDOCEvivFrxKOvMjAIhj3QZwmb9vE/KvCZv5puw6E=", - "aarch64-linux": "sha256-sTkfQNjqCNCxmHbpQ8woA6azoiyD37e8Xy7UwB6eNsA=", - "aarch64-darwin": "sha256-lKuagsTHqij5C764DM3d+BOe4JKs2KV8XiwSlJItGMg=", - "x86_64-darwin": "sha256-g5gZMHLq8feSZHVaCOLGU6Tj2Qw56Zogif3pT9EIlz8=" + "x86_64-linux": "sha256-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 d4e3deb1e06..045bd8676a0 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,13 @@ "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", - "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", + "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", @@ -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.42", + "version": "7.2.44", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 4f5b23705da..f3682c8b5e2 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.42", + "version": "7.2.44", "name": "@opencode-ai/core", "type": "module", "license": "MIT", 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 b7679756f20..b624a24d328 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.42" +version = "7.2.44" 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.42/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.44/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.42/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.44/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.42/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.44/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.42/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.44/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.42/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.44/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 10d60618a3f..f87c69ac90c 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.42", + "version": "7.2.44", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 89551016dcd..02af740576e 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -1,6 +1,6 @@ --- title: "Using OpenAI-Compatible Providers with Kilo Code" -description: "Connect any OpenAI-compatible API endpoint to Kilo Code, including Azure OpenAI, Together AI, and self-hosted servers." +description: "Connect OpenAI-compatible API endpoints like Together AI, Perplexity, and self-hosted servers." sidebar_label: OpenAI Compatible --- @@ -14,6 +14,12 @@ Kilo Code supports a wide range of AI model providers that offer APIs compatible This document focuses on setting up providers _other than_ the official OpenAI API (which has its own [dedicated configuration page](/docs/ai-providers/openai)). +{% callout type="warning" %} +Do not use a custom OpenAI-compatible provider for Azure OpenAI GPT-5 deployments. Azure GPT-5 rejects the `max_tokens` parameter used by generic OpenAI-compatible providers and requires Azure-specific handling. + +Use Kilo Code's native `azure` provider instead. If your Azure deployment name differs from the model name you select in Kilo, map it with the model `id` field in `kilo.json`. +{% /callout %} + ## General Configuration {% tabs %} @@ -21,7 +27,7 @@ This document focuses on setting up providers _other than_ the official OpenAI A The key to using an OpenAI-compatible provider is to configure two main settings: -1. **Base URL:** This is the API endpoint for the provider. It will _not_ be `https://api.openai.com/v1` (that's for the official OpenAI API). +1. **Base URL:** This is the API endpoint for the provider. It will _not_ be `https://api.openai.com/v1` (that's for the official OpenAI API). For Azure OpenAI GPT-5, do not enter your Azure endpoint here. Configure the native `azure` provider instead. 2. **API Key:** This is the secret key you obtain from the provider. 3. **Model ID:** This is the model name of the specific model. @@ -53,7 +59,7 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon - **Provider ID** — A unique identifier (e.g., `my-provider`). - **Display name** — A human-readable name shown in the UI. -- **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. +- **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. For Azure OpenAI GPT-5, use the native `azure` provider instead. - **API key** — Your API key. Optional — leave empty if authentication is handled via headers. - **Models** — Add models manually or select from the auto-fetched list (see [Automatic Model Detection](#automatic-model-detection) below). - **Headers** (optional) — Custom HTTP headers as key-value pairs. @@ -114,7 +120,7 @@ Then set your default model using the `provider-id/model-id` format: **Configuration fields:** - **`models`** — A map of model IDs to model definitions. Each model should include a `name` and `limit` with `context` and `output` token counts. If `limit.context` or `limit.output` is omitted, it defaults to `0`, which limits context management. -- **`options.baseURL`** — The base URL of your OpenAI-compatible API endpoint. +- **`options.baseURL`** — The base URL of your OpenAI-compatible API endpoint. For Azure OpenAI GPT-5, configure `provider.azure` instead. - **`options.apiKey`** — Your API key. Use any non-empty string (e.g., `"none"`) if the provider doesn't require authentication. You can also set the API key via an environment variable instead of putting it in the config file. Use the `env` field to specify which variable to read: @@ -172,6 +178,7 @@ This enhancement allows you to: - **"Invalid API Key":** Double-check that you've entered the API key correctly. - **"Model Not Found":** Make sure you're using a valid model ID for your chosen provider. - **Connection Errors:** Verify the Base URL is correct and that your provider's API is accessible. +- **Azure GPT-5 rejects `max_tokens`:** Azure GPT-5 deployments must use Kilo Code's native `azure` provider. Generic OpenAI-compatible custom providers send `max_tokens`, which Azure GPT-5 rejects because it expects `max_completion_tokens`. - **Unexpected Results:** If you're getting unexpected results, try a different model. By using an OpenAI-compatible provider, you can leverage the flexibility of Kilo Code with a wider range of AI models. Remember to always consult your provider's documentation for the most accurate and up-to-date information. diff --git a/packages/kilo-docs/pages/ai-providers/openai.md b/packages/kilo-docs/pages/ai-providers/openai.md index f100680ccfa..3025b09a4ba 100644 --- a/packages/kilo-docs/pages/ai-providers/openai.md +++ b/packages/kilo-docs/pages/ai-providers/openai.md @@ -71,4 +71,4 @@ Then set your default model: ## Tips and Notes - **Pricing:** Refer to the [OpenAI Pricing](https://openai.com/pricing) page for details on model costs. -- **Azure OpenAI Service:** If you'd like to use the Azure OpenAI service, please see our section on [OpenAI-compatible](/docs/ai-providers/openai-compatible) providers. +- **Azure OpenAI Service:** Use Kilo Code's native `azure` provider for Azure OpenAI, especially GPT-5 deployments. Do not configure Azure GPT-5 through a generic [OpenAI-compatible](/docs/ai-providers/openai-compatible) custom provider. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 5d84f04f8d2..a8b035662c7 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -309,6 +309,38 @@ If the model key in your config differs from what the provider expects, use the Here `my-local-llama` is the key you use in your config and model picker, while `meta-llama-3.1-8b-instruct` is the actual model identifier sent to the LM Studio API. +For Azure OpenAI, use the native `azure` provider and set `id` to your Azure deployment name when it differs from the model key. Do not configure Azure GPT-5 family deployments under `openai-compatible`, because that provider sends `max_tokens` and Azure GPT-5 expects `max_completion_tokens`. + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "azure/gpt-5.5", + "provider": { + "azure": { + "options": { + "apiKey": "{env:AZURE_API_KEY}", + "resourceName": "my-azure-resource", + }, + "models": { + "gpt-5.5": { + "id": "my-gpt-5-5-deployment", + "name": "GPT-5.5 on Azure", + "reasoning": true, + "tool_call": true, + "temperature": false, + "limit": { + "context": 400000, + "output": 128000, + }, + }, + }, + }, + }, +} +``` + +Here `azure/gpt-5.5` is the model you select in Kilo Code, while `my-gpt-5-5-deployment` is the Azure deployment name sent to Azure. If you prefer to configure the full Azure endpoint instead of a resource name, replace `resourceName` with `baseURL`, for example `"baseURL": "https://my-resource.openai.azure.com/openai"`. If both are configured, Kilo Code uses `baseURL` and ignores `resourceName` to avoid sending conflicting Azure SDK options. + ## Model Loading Priority When Kilo starts, it resolves the active model in this order: diff --git a/packages/kilo-docs/pages/code-with-ai/features/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/agentmanager/tab-bar-multiple-tabs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png index bc4bc545585..d6b7e3dee6e 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee8d8e04b09b58973a1ff66d5a3d8648ba590f88312cf1f37412c39ccdac0480 -size 3973 +oid sha256:483e73147bed34050ee35e60607c33c04d7e726b8f746921ad27e582cb5b19ee +size 2886 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png index 999c6b2f9e9..fe9ea053966 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0e9ea1be8fbb44f05bed7960d1ba13f0d4c5e904e4025e4ab4743c4991e93c8 -size 4696 +oid sha256:3b4687ed34fe06adb7e72797798b1f3602c225e9a36223842ea1c66eebea4601 +size 4794 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png index 0760abd4b86..e3f811c4397 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66a7ecedac685e17c442bb036d7392320e0642ef43da77ed8dc23be29bc7988e -size 2935 +oid sha256:980c9baf0edafa113956157a34eb00495aff55293b10fb4cbb86a8cb5b28e8c0 +size 2865 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png index 04959213872..d341a7a2c72 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d29bb3a9356cf8bec6bb0a9d6e4d0b59d147c1fa8adee7140c81a061eea407d -size 6930 +oid sha256:067a5c757dfa32d27955dd634b8dea52edef2141111a5eecee0161df36c0ed8b +size 7061 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png index d7d21c928f4..889daa6e724 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:483c04840175e7678f0cfd1eba377ec199239404acd567fd7778367f4d2cb46c -size 21219 +oid sha256:a1ca383c2a5fa9d6f477430d7c63c7330e13e2667bf717906d3c29c215f163c5 +size 21564 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png index 2283986b267..18c542093c0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2130db1ea14a5e8fbf879e96529198592a785b8460d29e12746175e102d50d5 -size 53617 +oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856 +size 57421 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png new file mode 100644 index 00000000000..e395b8b062d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdcac95e7969d021ebce19001f37f86b8ab670bd79e39086385c9419617294df +size 27177 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 9cd64a85b00..1054996f620 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.42", + "version": "7.2.44", "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 8be4c754977..cd5c7418e91 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.42", + "version": "7.2.44", "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 67919b6d799..e694445f79b 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.42", + "version": "7.2.44", "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 730a66c48f1..262d772aa30 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.42", + "version": "7.2.44", "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 2e037e3aa6b..16dbcc92201 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -244,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 48b1770c7ff..b041632c40f 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.42", + "version": "7.2.44", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/icon-button.tsx b/packages/kilo-ui/src/components/icon-button.tsx index 52c1b666f8d..457283aa039 100644 --- a/packages/kilo-ui/src/components/icon-button.tsx +++ b/packages/kilo-ui/src/components/icon-button.tsx @@ -1 +1,29 @@ -export * from "@opencode-ai/ui/icon-button" +import { Button as Kobalte } from "@kobalte/core/button" +import { type ComponentProps, splitProps } from "solid-js" +import { Icon, IconProps } from "./icon" + +export interface IconButtonProps extends ComponentProps { + 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 ( diff --git a/packages/kilo-ui/src/components/markdown.css b/packages/kilo-ui/src/components/markdown.css index c87f2f13835..ae502e81192 100644 --- a/packages/kilo-ui/src/components/markdown.css +++ b/packages/kilo-ui/src/components/markdown.css @@ -51,5 +51,4 @@ background: var(--background-stronger); margin-bottom: 1rem; } - } diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index e887aaf7770..bb6f99c515a 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -24,21 +24,30 @@ display: flex; align-items: center; justify-content: flex-start; + gap: 2px; margin-top: 2px; [data-component="icon-button"] { width: 20px; height: 20px; } + + /* Thumbs up/down: fill the outline on hover to preview the rated state. */ + [data-component="icon-button"][data-icon="thumbs-up"]:hover [data-slot="icon-svg"] path, + [data-component="icon-button"][data-icon="thumbs-down"]:hover [data-slot="icon-svg"] path { + fill: currentColor; + } } } -/* Hover-revealed "Open in Diff Viewer" icon button in the edit-tool header */ -[data-component="tool-trigger"] [data-slot="basic-tool-tool-info"]:has([data-component="edit-trigger"]) { +/* Hover-revealed "Open in Diff Viewer" icon button in edit/write tool headers */ +[data-component="tool-trigger"] [data-slot="basic-tool-tool-info"]:has([data-component="edit-trigger"]), +[data-component="tool-trigger"] [data-slot="basic-tool-tool-info"]:has([data-component="write-trigger"]) { flex: 1 1 auto; } -[data-component="edit-trigger"] { +[data-component="edit-trigger"], +[data-component="write-trigger"] { display: flex; align-items: center; gap: 8px; @@ -51,7 +60,7 @@ overflow: hidden; } - [data-slot="edit-trigger-actions"] { + [data-slot="tool-trigger-actions"] { flex-shrink: 0; margin-left: auto; display: inline-flex; @@ -61,8 +70,8 @@ } } -[data-component="tool-trigger"]:hover [data-slot="edit-trigger-actions"], -[data-slot="edit-trigger-actions"]:focus-within { +[data-component="tool-trigger"]:hover [data-slot="tool-trigger-actions"], +[data-slot="tool-trigger-actions"]:focus-within { opacity: 1; } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 896e358356a..aeb10d3dd43 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -53,6 +53,7 @@ import { busy, createThrottledValue, useToolFade, useContextToolPending } from " import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" import { ShellRollingResults } from "./shell-rolling-results" import { extractFilePathFromHref } from "../file-path" +import { contents } from "./session-diff" // Windows CLI tools (e.g. winget) use \r to overwrite progress bars in-place. // Without this, every progress frame renders as a separate visual line. @@ -125,6 +126,12 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } +export interface MessageFeedbackControls { + enabled?: boolean + rating?: "up" | "down" + onRate?: (rating: "up" | "down" | null) => void +} + export interface MessagePartProps { part: PartType message: MessageType @@ -136,6 +143,7 @@ export interface MessagePartProps { turnDiffSummary?: () => JSX.Element animate?: boolean working?: boolean + feedback?: MessageFeedbackControls } export type PartComponent = Component @@ -971,6 +979,7 @@ export function Part(props: MessagePartProps) { turnDiffSummary={props.turnDiffSummary} animate={props.animate} working={props.working} + feedback={props.feedback} /> ) @@ -1342,6 +1351,52 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} /> + + + e.preventDefault()} + onClick={() => { + const next = props.feedback?.rating === "up" ? null : "up" + props.feedback?.onRate?.(next) + }} + aria-pressed={props.feedback?.rating === "up"} + aria-label={i18n.t("ui.message.feedback.helpful")} + /> + + + e.preventDefault()} + onClick={() => { + const next = props.feedback?.rating === "down" ? null : "down" + props.feedback?.onRate?.(next) + }} + aria-pressed={props.feedback?.rating === "down"} + aria-label={i18n.t("ui.message.feedback.notHelpful")} + /> + + @@ -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() @@ -2125,7 +2185,7 @@ ToolRegistry.register({ - + 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 canOpenDiff = () => !!data.openDiff && !!props.input.filePath && (!!view() || !!props.input.content) + const canOpenFile = () => !!data.openFile && !!props.input.filePath + + const openDiff = () => { + if (!data.openDiff || !props.input.filePath) return + const v = view() + data.openDiff({ + file: props.metadata?.filediff?.file || props.input.filePath, + before: v?.before ?? "", + after: v?.after ?? props.input.content ?? "", + additions: props.metadata?.filediff?.additions ?? 0, + deletions: props.metadata?.filediff?.deletions ?? 0, + }) + } const handleFileClick = (e: MouseEvent) => { - if (!data.openFile || !props.input.filePath) return e.stopPropagation() - data.openFile(props.input.filePath) + if (canOpenDiff()) { + openDiff() + return + } + if (canOpenFile()) { + data.openFile!(props.input.filePath!) + } + } + + const handleOpenDiffClick = (e: MouseEvent) => { + e.stopPropagation() + openDiff() } return ( @@ -2209,29 +2298,65 @@ ToolRegistry.register({ )} + + + + e.preventDefault()} + onClick={handleOpenDiffClick} + aria-label={i18n.t("ui.messagePart.openInDiffViewer")} + /> + + + } > - - + + + {(diff) => } + + } + >
- + + } + > + {(diff) => ( + + )} +
@@ -2246,6 +2371,7 @@ interface ApplyPatchFile { filePath: string relativePath: string type: "add" | "update" | "delete" | "move" + patch?: string diff: string before?: string after?: string @@ -2261,6 +2387,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 +2531,17 @@ ToolRegistry.register({ - -
- -
+ + {(diff) => ( +
+ +
+ )}
@@ -2446,15 +2581,17 @@ ToolRegistry.register({ } > - -
- -
+ + {(diff) => ( +
+ +
+ )}
)} diff --git a/packages/kilo-ui/src/components/select.css b/packages/kilo-ui/src/components/select.css index 5d255dc4af0..d7efba7bdf5 100644 --- a/packages/kilo-ui/src/components/select.css +++ b/packages/kilo-ui/src/components/select.css @@ -81,6 +81,7 @@ /* Section headers */ [data-slot="select-section"] { + padding: 4px 12px; font-size: var(--kilo-font-size-11); font-weight: 500; color: var(--text-weak); 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/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index 8c256487b12..5e1912f3aff 100644 --- a/packages/kilo-ui/src/stories/message-part.stories.tsx +++ b/packages/kilo-ui/src/stories/message-part.stories.tsx @@ -196,6 +196,35 @@ const editCompletedPart: ToolPart = { }, } +// Completed write tool that creates a new file — exercises canOpenDiff() via +// `props.input.content` so the "Open in Diff Viewer" icon button renders even +// when metadata.filediff has no diff payload. +const writeCompletedPart: ToolPart = { + id: "part-tool-write-done", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-write-done", + tool: "write", + state: { + status: "completed", + input: { + filePath: "src/greet.ts", + content: "export function greet(name: string) {\n return `Hello, ${name}!`\n}\n", + }, + output: "File written successfully", + title: "Write file", + metadata: { + filediff: { + file: "src/greet.ts", + additions: 3, + deletions: 0, + }, + }, + time: { start: now - 4000, end: now - 3500 }, + }, +} + // --- Reasoning part --- const reasoningPart: ReasoningPart = { @@ -243,6 +272,7 @@ const mockDataBash = createMockData([bashCompleted]) const mockDataContextGroup = createMockData([completedToolPart, grepCompleted, globCompleted, textPart]) // Completed edit tool with filediff — exercises the "Open in Diff Viewer" button path const mockDataEdit = createMockData([editCompletedPart]) +const mockDataWrite = createMockData([writeCompletedPart]) function AllProviders(props: { children: any; data?: MockData; onOpenDiff?: () => void }) { return ( @@ -402,7 +432,18 @@ export const WithEditToolOpenDiffAction: Story = { name: "WithEditTool (open-diff action visible)", render: () => ( {}}> - + + + + ), +} + +// --- Completed write tool with content → "Open in Diff Viewer" icon visible --- +export const WithWriteToolOpenDiffAction: Story = { + name: "WithWriteTool (open-diff action visible)", + render: () => ( + {}}> + ), diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index daf3437ee8f..85d524ab3b6 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,51 @@ # kilo-code +## 7.2.44 + +### Minor Changes + +- [#9857](https://github.com/Kilo-Org/kilocode/pull/9857) [`6fe38e4`](https://github.com/Kilo-Org/kilocode/commit/6fe38e47bc96b8649a97cd183fca602ddd7038e6) - Support copying, previewing, and exporting rendered Mermaid diagrams. + +- [#9968](https://github.com/Kilo-Org/kilocode/pull/9968) [`bf0c4df`](https://github.com/Kilo-Org/kilocode/commit/bf0c4df9d3067339e3438d51bf6ce2ff77d75092) - Add a searchable current-tabs menu to Agent Manager. + +- [#9764](https://github.com/Kilo-Org/kilocode/pull/9764) [`9886674`](https://github.com/Kilo-Org/kilocode/commit/98866740afd7f6c2fd06fecda1ffc69c1703974e) - Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation. + +- [#9718](https://github.com/Kilo-Org/kilocode/pull/9718) [`dcaccf3`](https://github.com/Kilo-Org/kilocode/commit/dcaccf38658415819b72390255b9f6555e4795e5) - Rate assistant responses with thumbs up/down. Click the thumbs buttons next to the copy button on any assistant message, or press `=` / `-` in the terminal UI. Only shown when telemetry is enabled; feedback is sent to Kilo to help improve model and prompt quality. + +- [#9897](https://github.com/Kilo-Org/kilocode/pull/9897) [`9c581d3`](https://github.com/Kilo-Org/kilocode/commit/9c581d39926688d2c1698afb540ae7670763a48d) - Unify the Changes panel into a single tab with a source dropdown to switch between workspace and current-session diffs. Show the session's additions and deletions on the sidebar changes badge, and warn in the panel when snapshots are disabled for the repository. + +### Patch Changes + +- [#9971](https://github.com/Kilo-Org/kilocode/pull/9971) [`de58267`](https://github.com/Kilo-Org/kilocode/commit/de58267b4026f25b21560f85b334a7fdd00b2d48) - Keep Agent Manager branch, mode, and model selectors usable in the new worktree dialog. + +- [#9989](https://github.com/Kilo-Org/kilocode/pull/9989) [`c46b40a`](https://github.com/Kilo-Org/kilocode/commit/c46b40ac1799bbbfae159b1d57205dc4903eff1b) - Show the autocomplete model selector with the same picker layout as other model selectors and save changes from the settings save bar. + +- [#10004](https://github.com/Kilo-Org/kilocode/pull/10004) [`6bb0f82`](https://github.com/Kilo-Org/kilocode/commit/6bb0f8261cadb91783f712b892efea17003617d7) - Keep Agent Manager worktree recovery metadata out of worktree git changes. + +- [#9970](https://github.com/Kilo-Org/kilocode/pull/9970) [`43204b6`](https://github.com/Kilo-Org/kilocode/commit/43204b680e82921b3139367f2e0fba3698d3901d) - Make Agent Manager session tabs compact, status-aware, and easier to scan when many tabs are open. + +- [#9915](https://github.com/Kilo-Org/kilocode/pull/9915) [`bcb47be`](https://github.com/Kilo-Org/kilocode/commit/bcb47be3b0cf71990fd3ee1ec562a716aefe3571) - Preserve the selected thinking level after compacting a session. + +- [#9688](https://github.com/Kilo-Org/kilocode/pull/9688) [`3095efc`](https://github.com/Kilo-Org/kilocode/commit/3095efcc4c141a2532f48dc77b38cf0309125c82) - Restore inline diff previews after session reloads while keeping large file contents stripped from VS Code webview messages. + +- [#9993](https://github.com/Kilo-Org/kilocode/pull/9993) [`98f5f65`](https://github.com/Kilo-Org/kilocode/commit/98f5f65c1a8a543687ae5b308805eec1a2c23dca) - Support global and per-project codebase indexing enablement. + +- [#9961](https://github.com/Kilo-Org/kilocode/pull/9961) [`aec2425`](https://github.com/Kilo-Org/kilocode/commit/aec2425f6e116ab6657d3ae048085073deec70b9) - Update regular Kilo editor tab titles to follow the current session title. + +- [#9991](https://github.com/Kilo-Org/kilocode/pull/9991) [`fa326f2`](https://github.com/Kilo-Org/kilocode/commit/fa326f21e33a8cb1a81b5e32a7735e96a66bef8a) - Support accepting command permission prompts with Enter and denying them with Escape. + +- [#9921](https://github.com/Kilo-Org/kilocode/pull/9921) [`e5e9d0b`](https://github.com/Kilo-Org/kilocode/commit/e5e9d0ba37bd1065aea5a9a83834c6749121e5bd) - Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled. + +- [#9922](https://github.com/Kilo-Org/kilocode/pull/9922) [`5fcc423`](https://github.com/Kilo-Org/kilocode/commit/5fcc4231c0a716e1012475b01ce2d77f99fdbe87) - Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. + +- [#9917](https://github.com/Kilo-Org/kilocode/pull/9917) [`42e43c4`](https://github.com/Kilo-Org/kilocode/commit/42e43c4e44a5ae783ca6eae51c05d28c2ef3cedf) - Capture the active VS Code terminal when using `@terminal` in Agent Manager. + +- Updated dependencies [[`3095efc`](https://github.com/Kilo-Org/kilocode/commit/3095efcc4c141a2532f48dc77b38cf0309125c82), [`9886674`](https://github.com/Kilo-Org/kilocode/commit/98866740afd7f6c2fd06fecda1ffc69c1703974e), [`e5e9d0b`](https://github.com/Kilo-Org/kilocode/commit/e5e9d0ba37bd1065aea5a9a83834c6749121e5bd)]: + - @opencode-ai/ui@7.2.43 + - @kilocode/sdk@7.3.0 + - @kilocode/kilo-ui@7.2.43 + - @kilocode/kilo-indexing@7.2.43 + ## 7.2.42 ### Minor Changes diff --git a/packages/kilo-vscode/README.md b/packages/kilo-vscode/README.md index 40687268e9a..328d3f5d623 100644 --- a/packages/kilo-vscode/README.md +++ b/packages/kilo-vscode/README.md @@ -35,7 +35,7 @@ ## Get Started 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). -2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly. +2. Create your account to access 500+ cutting-edge AI models including GPT-5.5, Claude Opus 4.7, Claude Sonnet 4.6, and Gemini 3.1 Pro Preview, with transparent pricing that matches provider rates exactly. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: Watch the video diff --git a/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md b/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md index c1d89d67351..ebb9764ef62 100644 --- a/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md +++ b/packages/kilo-vscode/docs/chat-ui-features/mermaid-diagram-features.md @@ -6,6 +6,7 @@ Chat Markdown renders fenced `mermaid` code blocks as diagrams after a response - Valid `mermaid` fences render inline as SVG diagrams. - The original Mermaid source remains available through the existing code-block copy button. +- Rendered diagrams include Copy and Download menus for Mermaid source, SVG, and PNG formats. - Invalid Mermaid syntax shows a contained error state and keeps the source visible. - Diagrams are not rendered while a message is streaming, which avoids repeated parse/render work on every token. - Diagram colors are derived from the active VS Code/Kilo CSS variables so light, dark, and high-contrast themes can render with matching backgrounds, text, borders, and link colors. @@ -13,4 +14,4 @@ Chat Markdown renders fenced `mermaid` code blocks as diagrams after a response ## Limitations - Mermaid is bundled by the current webview build, so bundle splitting remains a future optimization. -- Advanced legacy actions are not restored yet: AI syntax fixing, PNG open/save, export, and zoom modal. +- Advanced legacy actions are not restored yet: AI syntax fixing and zoom modal. diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index b11a766659b..0914b0366f7 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -38,7 +38,7 @@ export default [ // New code must stay ≤ 20. Do not raise these caps; refactor instead. { files: ["src/KiloProvider.ts"], - rules: { complexity: ["error", 150], "max-lines": ["error", 3420] }, + rules: { complexity: ["error", 150], "max-lines": ["error", 3600] }, }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], @@ -49,7 +49,10 @@ export default [ // (canvases must never leave the paint tree — see render.tsx), and // render-call wiring that must live at the top of // `AgentManagerContent` alongside the existing selection/session state. - rules: { complexity: ["error", 74], "max-lines": ["error", 3200] }, + // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider` + // wiring, which sits inside the provider chain and cannot be extracted + // without adding an intermediate wrapper component. + rules: { complexity: ["error", 74], "max-lines": ["error", 3210] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 25eb8875b7a..3e68d962b19 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.42", + "version": "7.2.44", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -949,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 a81b51650ba..00000000000 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ /dev/null @@ -1,270 +0,0 @@ -import * as vscode from "vscode" -import type { KiloConnectionService } from "./services/cli-backend" -import { buildWebviewHtml, getWebviewFontSize } from "./utils" -import { GitOps } from "./agent-manager/GitOps" -import { watchFontSizeConfig } from "./kilo-provider/font-size" -import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" -import { - appendOutput, - getWorkspaceRoot, - hashFileDiffs, - openWorkspaceRelativeFile, - resolveLocalDiffTarget, -} from "./review-utils" -import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" - -/** - * DiffViewerProvider opens a full-screen diff viewer in an editor tab. - * It shows the local workspace diff and forwards review comments back to the sidebar chat. - */ -export class DiffViewerProvider implements vscode.Disposable { - public static readonly viewType = "kilo-code.new.DiffViewerPanel" - - private panel: vscode.WebviewPanel | undefined - private diffInterval: ReturnType | undefined - private lastDiffHash: string | undefined - private cachedDiffTarget: DiffTarget | undefined - private gitOps: GitOps - private outputChannel: vscode.OutputChannel - private fontConfigDisposable: vscode.Disposable | undefined - private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly connectionService: KiloConnectionService, - ) { - this.gitOps = new GitOps({ log: (...args) => this.log(...args) }) - this.outputChannel = vscode.window.createOutputChannel("Kilo Diff Viewer") - } - - private log(...args: unknown[]) { - appendOutput(this.outputChannel, "DiffViewer", ...args) - } - - public setCommentHandler(handler: (comments: unknown[], autoSend: boolean) => void): void { - this.onSendComments = handler - } - - public openPanel(): void { - if (this.panel) { - this.panel.reveal(vscode.ViewColumn.One) - return - } - - const panel = vscode.window.createWebviewPanel(DiffViewerProvider.viewType, "Changes", vscode.ViewColumn.One, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [this.extensionUri], - }) - - this.wirePanel(panel) - } - - /** Re-wire a deserialized panel after extension restart. */ - public deserializePanel(panel: vscode.WebviewPanel): void { - this.wirePanel(panel) - } - - private wirePanel(panel: vscode.WebviewPanel): void { - this.panel = panel - - panel.iconPath = { - light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), - dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), - } - - panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg), undefined, []) - panel.webview.html = this.getHtml(panel.webview) - this.fontConfigDisposable?.dispose() - this.fontConfigDisposable = watchFontSizeConfig((msg) => this.post(msg)) - - panel.onDidDispose(() => { - this.log("Panel disposed") - this.stopDiffPolling() - this.fontConfigDisposable?.dispose() - this.fontConfigDisposable = undefined - this.panel = undefined - }) - } - - private onMessage(msg: Record): void { - const type = msg.type as string - - if (type === "webviewReady") { - this.post({ - type: "ready", - vscodeLanguage: vscode.env.language, - languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), - fontSize: getWebviewFontSize(), - workspaceDirectory: getWorkspaceRoot(), - }) - this.post({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) - this.startDiffPolling() - return - } - - if (type === "diffViewer.sendComments" && Array.isArray(msg.comments)) { - this.onSendComments?.(msg.comments, !!msg.autoSend) - return - } - - if (type === "diffViewer.close") { - this.panel?.dispose() - return - } - - if (type === "diffViewer.setDiffStyle" && (msg.style === "unified" || msg.style === "split")) { - return - } - - if (type === "diffViewer.setMarkdownRender" && typeof msg.render === "boolean") { - void setDiffMarkdownRender(msg.render) - return - } - - if (type === "diffViewer.revertFile" && typeof msg.file === "string") { - void this.revertFile(msg.file) - return - } - - if (type === "openFile" && typeof msg.filePath === "string") { - openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) - } - } - - private async revertFile(file: string): Promise { - const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget()) - if (!target) { - this.post({ - type: "diffViewer.revertFileResult", - file, - status: "error", - message: "Could not resolve diff target", - }) - return - } - - try { - const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) => - this.log(...args), - ) - const result = await diff.revertFile(target, file) - this.post({ - type: "diffViewer.revertFileResult", - file, - status: result.ok ? "success" : "error", - message: result.message, - }) - if (result.ok) void this.pollDiff() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.log("Failed to revert file:", message) - this.post({ type: "diffViewer.revertFileResult", file, status: "error", message }) - } - } - - private async resolveLocalDiffTarget(): Promise { - return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot()) - } - - private async initialFetch(): Promise { - this.post({ type: "diffViewer.loading", loading: true }) - - const target = await this.resolveLocalDiffTarget() - if (!target) { - this.post({ type: "diffViewer.diffs", diffs: [] }) - this.post({ type: "diffViewer.loading", loading: false }) - return - } - - this.cachedDiffTarget = target - - try { - await this.connectionService.connect(target.directory) - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diff( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - this.lastDiffHash = hashFileDiffs(diffs) - - this.log(`Initial diff: ${diffs.length} file(s)`) - this.post({ type: "diffViewer.diffs", diffs }) - } catch (err) { - this.log("Failed to fetch initial diff:", err) - } finally { - this.post({ type: "diffViewer.loading", loading: false }) - } - } - - private async pollDiff(): Promise { - const target = this.cachedDiffTarget - if (!target) { - await this.initialFetch() - return - } - - try { - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diff( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - const hash = hashFileDiffs(diffs) - - if (hash === this.lastDiffHash) return - this.lastDiffHash = hash - this.post({ type: "diffViewer.diffs", diffs }) - } catch (err) { - this.log("Failed to poll diff:", err) - } - } - - private startDiffPolling(): void { - this.stopDiffPolling() - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - - void this.initialFetch().then(() => { - if (!this.panel) return - this.diffInterval = setInterval(() => { - void this.pollDiff() - }, 2500) - }) - } - - private stopDiffPolling(): void { - if (this.diffInterval) { - clearInterval(this.diffInterval) - this.diffInterval = undefined - } - - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - } - - private post(message: Record): void { - if (this.panel?.webview) void this.panel.webview.postMessage(message) - } - - private getHtml(webview: vscode.Webview): string { - return buildWebviewHtml(webview, { - scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.js")), - styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.css")), - iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), - title: "Changes", - port: this.connectionService.getServerInfo()?.port, - extraStyles: "#root { display: flex; flex-direction: column; }", - }) - } - - public dispose(): void { - this.stopDiffPolling() - this.fontConfigDisposable?.dispose() - this.gitOps.dispose() - this.panel?.dispose() - this.outputChannel.dispose() - } -} diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1458bc45066..b56184a0474 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -16,7 +16,13 @@ import type { EditorContext, IndexingStatus } from "./services/cli-backend/types import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" import { buildWebviewHtml, getWebviewFontSize } from "./utils" -import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry" +import { saveImage } from "./kilo-provider/save-image" +import { + TelemetryProxy, + type TelemetryPropertiesProvider, + pushTelemetryState, + watchTelemetryState, +} from "./services/telemetry" import { sessionToWebview, indexProvidersById, @@ -44,6 +50,7 @@ import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./s import type { RemoteStatusService } from "./services/RemoteStatusService" import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" +import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error" import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" @@ -62,6 +69,7 @@ import { abortSession } from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, routeAutocompleteMessage, + validAutocompleteSetting, watchAutocompleteConfig, } from "./services/autocomplete/settings" import * as ModelState from "./kilo-provider/model-state" @@ -105,6 +113,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, @@ -122,11 +131,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, @@ -170,6 +177,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null + private cachedGlobalConfig: Config | null = null /** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */ private cachedIndexingStatusMessage: unknown = null /** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */ @@ -217,6 +225,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private initConnectionPromise: Promise | null = null private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null + private telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null @@ -251,10 +260,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, private readonly extensionContext?: vscode.ExtensionContext, - options?: KiloProviderOptions, + private readonly opts: KiloProviderOptions = {}, ) { - this.projectDirectory = options?.projectDirectory - this.slimEditMetadata = options?.slimEditMetadata ?? true + this.projectDirectory = opts.projectDirectory + this.slimEditMetadata = opts.slimEditMetadata ?? true TelemetryProxy.getInstance().setProvider(this) } @@ -269,6 +278,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.autoApproveBridge = createAutoApproveBridge(ctrl, (msg) => this.postMessage(msg), this.onBeforeMessage) this.onBeforeMessage = (msg) => this.autoApproveBridge!.handle(msg) } + + private setCurrentSession(session: Session | null): void { + this.currentSession = session + this.opts.tabTitle?.(nativeTitle(session)) + } + private sendRemoteStatus(): void { const s = this.remoteService?.getState() if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected }) @@ -352,10 +367,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) { @@ -466,12 +479,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.initializeConnection() } - /** - * Register a session created externally (e.g., worktree sessions from AgentManagerProvider). - * Sets currentSession, adds to trackedSessionIds, and notifies the webview. - */ + /** Register a session created externally and notify the webview. */ public registerSession(session: Session): void { - this.currentSession = session + this.setCurrentSession(session) this.contextSessionID = session.id this.trackedSessionIds.add(session.id) this.postMessage({ @@ -480,10 +490,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } - /** - * Add a session ID to the tracked set without changing currentSession. - * Used to re-register worktree sessions after clearSession wipes the set. - */ + /** Add a session ID to the tracked set without changing currentSession. */ public trackSession(sessionId: string): void { this.trackedSessionIds.add(sessionId) } @@ -582,6 +589,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), @@ -600,7 +609,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, turnId?: string) => + vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId, turnId }), + currentSessionId: this.currentSession?.id, createWorktree: async (baseBranch, branchName) => { await this.createWorktreeHandler?.(baseBranch, branchName) }, @@ -680,7 +691,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break case "clearSession": this.contextSessionID = this.currentSession?.id ?? this.contextSessionID - this.currentSession = null + this.setCurrentSession(null) this.focusSession() break case "loadMessages": @@ -743,7 +754,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] handleForkSession failed:", e), ) break - case "retryConnection": console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...") this.initializeConnection().catch((e) => @@ -756,6 +766,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "previewImage": this.handlePreviewImage(message.dataUrl, message.filename) break + case "saveImage": + return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message) case "openFile": if (message.filePath) { this.handleOpenFile(message.filePath, message.line, message.column) @@ -1039,7 +1051,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "enhancePromptResult", text: data.text, requestId: message.requestId }) }) .catch((err: unknown) => { - const msg = getErrorMessage(err) || "Failed to enhance prompt" + const raw = getErrorMessage(err) || "Failed to enhance prompt" + const msg = normalizeEnhancePromptErrorMessage(raw) console.error("[Kilo New] KiloProvider: Failed to enhance prompt:", err) vscode.window.showErrorMessage(`Enhance prompt failed: ${msg}`) this.postMessage({ @@ -1316,7 +1329,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) @@ -1342,7 +1355,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 } }) @@ -1582,7 +1595,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { - this.currentSession = null + this.setCurrentSession(null) this.focusSession(undefined) } this.postMessage({ type: "sessionDeleted", sessionID }) @@ -1611,7 +1624,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) { @@ -1712,13 +1725,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) const set = (m: unknown) => { this.cachedConfigMessage = m + if (m && typeof m === "object" && "globalConfig" in m) + this.cachedGlobalConfig = (m as { globalConfig?: Config }).globalConfig ?? null } const method = typeof msg.method === "number" ? msg.method : 0 const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined const keyChanged = msg.apiKeyChanged === true const code = typeof msg.code === "string" ? msg.code : undefined const config = msg.config && typeof msg.config === "object" ? (msg.config as Record) : undefined - if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key) + const metadata = + msg.metadata && typeof msg.metadata === "object" ? (msg.metadata as Record) : undefined + if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key, metadata) if (msg.type === "authorizeProviderOAuth") return authorizeOAuthAction(ctx, rid, pid, method) if (msg.type === "completeProviderOAuth") return completeOAuthAction(ctx, rid, pid, method, code) if (msg.type === "disconnectProvider") return disconnectProviderAction(ctx, rid, pid, this.cachedConfigMessage, set) @@ -2065,10 +2082,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const { data: config } = await retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }), ) + const { data: global } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = global ?? null const message = { type: "configLoaded", config, + globalConfig: global, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2083,6 +2103,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || this.connectionState !== "connected") return try { const { data: config } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = config ?? null this.postMessage({ type: "globalConfigLoaded", config }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch global config:", error) @@ -2146,8 +2167,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - this.cachedConfigMessage = { type: "configLoaded", config, features: configFeatures(config) } - this.postMessage({ type: "configUpdated", config, features: configFeatures(config) }) + const { data: global } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = global ?? null + this.cachedConfigMessage = { + type: "configLoaded", + config, + globalConfig: global, + features: configFeatures(config), + } + this.postMessage({ type: "configUpdated", config, globalConfig: global, features: configFeatures(config) }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -2331,8 +2359,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - this.cachedConfigMessage = { type: "configLoaded", config: merged, features: configFeatures(merged) } - this.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) + const { data: global } = await this.client.global.config.get({ throwOnError: true }) + this.cachedGlobalConfig = global ?? null + this.cachedConfigMessage = { + type: "configLoaded", + config: merged, + globalConfig: global, + features: configFeatures(merged), + } + this.postMessage({ + type: "configUpdated", + config: merged, + globalConfig: global, + features: configFeatures(merged), + }) if (refreshProviders) await this.fetchAndSendProviders() } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) @@ -2347,6 +2387,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "configUpdated", config: optimistic, + globalConfig: this.cachedGlobalConfig ?? undefined, features: features ?? configFeatures(optimistic as Config), }) } finally { @@ -2375,7 +2416,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) @@ -2748,7 +2789,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, @@ -2863,6 +2904,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ private async handleUpdateSetting(key: string, value: unknown): Promise { const { section, leaf } = buildSettingPath(key) + if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) await config.update(leaf, value, vscode.ConfigurationTarget.Global) } @@ -3043,12 +3085,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 } @@ -3403,6 +3445,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 ffcf7868562..c4d875c5420 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -3,7 +3,7 @@ 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" @@ -417,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 } @@ -481,6 +481,7 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "previewImage") return msg + if (m.type === "saveImage") return msg if (m.type === "agentManager.showExistingLocalTerminal") { this.terminalManager.syncLocalOnSessionSwitch() return null 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 7ed0c1e0114..888fb95eac6 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -86,6 +86,7 @@ import { KILO_DIR, LEGACY_DIR, migrateAgentManagerData } from "./constants" const SESSION_ID_FILE = "session-id" const METADATA_FILE = "metadata.json" +const GIT_METADATA_FILE = "kilo-agent-manager-metadata.json" export class WorktreeManager { private readonly root: string @@ -438,24 +439,21 @@ export class WorktreeManager { } async writeMetadata(worktreePath: string, sessionId: string, parentBranch: string, remote?: string): Promise { - const dir = path.join(worktreePath, KILO_DIR) - if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }) - const meta: Record = { sessionId, parentBranch } if (remote) meta.remote = remote - // Write both formats: session-id for backward compat, metadata.json for parentBranch+remote - await Promise.all([ - fs.promises.writeFile(path.join(dir, SESSION_ID_FILE), sessionId, "utf-8"), - fs.promises.writeFile(path.join(dir, METADATA_FILE), JSON.stringify(meta), "utf-8"), - ]) + const file = await this.gitMetadataPath(worktreePath) + if (!file) throw new Error(`Could not resolve git metadata directory for ${worktreePath}`) + await fs.promises.writeFile(file, JSON.stringify(meta), "utf-8") this.log(`Wrote metadata for session ${sessionId} to ${worktreePath}`) - await this.ensureWorktreeExclude(worktreePath) } async readMetadata( worktreePath: string, ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + const current = await this.readCurrentMetadata(worktreePath) + if (current) return current + // Check .kilo/ first, then legacy .kilocode/ for (const dirName of [KILO_DIR, LEGACY_DIR]) { const result = await this.readMetadataFrom(worktreePath, dirName) @@ -464,6 +462,44 @@ export class WorktreeManager { return undefined } + private async readCurrentMetadata( + worktreePath: string, + ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + try { + const file = await this.gitMetadataPath(worktreePath) + if (!file) return undefined + const content = await fs.promises.readFile(file, "utf-8") + const data = JSON.parse(content) as { sessionId?: string; parentBranch?: string; remote?: string } + if (!data.sessionId) return undefined + return { + sessionId: data.sessionId, + parentBranch: data.parentBranch, + remote: data.remote, + } + } catch (e) { + this.log(`readMetadata: git metadata unreadable in ${worktreePath}: ${e}`) + return undefined + } + } + + private async gitMetadataPath(worktreePath: string): Promise { + const dir = await this.worktreeGitDir(worktreePath) + if (!dir) return undefined + return path.join(dir, GIT_METADATA_FILE) + } + + private async worktreeGitDir(worktreePath: string): Promise { + const gitPath = path.join(worktreePath, ".git") + const stat = await fs.promises.stat(gitPath) + if (stat.isDirectory()) return gitPath + if (!stat.isFile()) return undefined + + const content = await fs.promises.readFile(gitPath, "utf-8") + const match = content.match(/^gitdir:\s*(.+)$/m) + if (!match) return undefined + return path.resolve(worktreePath, match[1].trim()) + } + private async readMetadataFrom( worktreePath: string, dirName: string, @@ -526,20 +562,6 @@ export class WorktreeManager { } } - private async ensureWorktreeExclude(worktreePath: string): Promise { - try { - const content = await fs.promises.readFile(path.join(worktreePath, ".git"), "utf-8") - const match = content.match(/^gitdir:\s*(.+)$/m) - if (!match) return - - const worktreeGitDir = path.resolve(worktreePath, match[1].trim()) - const mainGitDir = path.dirname(path.dirname(worktreeGitDir)) - await this.addExcludeEntry(path.join(mainGitDir, "info", "exclude"), `${KILO_DIR}/`, "Kilo Code session metadata") - } catch (error) { - this.log(`Warning: Failed to update git exclude for worktree: ${error}`) - } - } - /** * Returns true when target is strictly inside the managed worktrees directory. * Prevents sibling-prefix confusion such as "/worktrees-evil". diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 478c2f71daf..2bbe74ce358 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -600,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. @@ -609,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 }) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 765442070bd..ef52f68ad15 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -564,6 +564,12 @@ interface PreviewImageIn { filename: string } +interface SaveImageIn { + type: "saveImage" + dataUrl: string + filename: string +} + interface LoadMessagesIn { type: "loadMessages" sessionID: string @@ -750,6 +756,7 @@ export type AgentManagerInMessage = | OpenFileIn | GenericOpenFileIn | PreviewImageIn + | SaveImageIn | LoadMessagesIn | SendMessageIn | SendCommandIn 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..417f2c65f4d --- /dev/null +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -0,0 +1,216 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend" +import { appendOutput, getWorkspaceRoot, openWorkspaceRelativeFile } from "../review-utils" +import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" +import { buildWebviewHtml, getWebviewFontSize } from "../utils" +import { watchFontSizeConfig } from "../kilo-provider/font-size" +import type { DiffSourceCatalog } from "./sources/catalog" +import { turnSourceId } from "./sources/turn" +import type { PanelContext } from "./types" +import { SourceController } from "./SourceController" + +type CommentHandler = (comments: unknown[], autoSend: boolean) => void + +export interface DiffViewerProviderOptions { + sessionIdProvider?: () => string | undefined +} + +/** + * Single global "Changes" panel. Owns the webview panel lifecycle and + * routes webview messages to a SourceController, which owns the active + * DiffSource. + */ +export class DiffViewerProvider implements vscode.Disposable { + public static readonly viewType = "kilo-code.new.DiffViewerPanel" + + private panel: vscode.WebviewPanel | undefined + private ctx: PanelContext | undefined + private controller: SourceController | undefined + private panelDisposables: vscode.Disposable[] = [] + private commentHandler: CommentHandler | undefined + private fontConfigDisposable: vscode.Disposable | undefined + private 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. + * + * When `turnId` is passed, opens the panel scoped to that single turn with + * the source picker hidden — the view becomes a static "diff of this turn" + * rather than the switchable workspace/session viewer. + */ + openFromCommand(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }): void { + const sessionId = arg?.sessionId ?? this.sessionIdProvider() + const turnInitialSourceId = arg?.turnId && sessionId ? turnSourceId(sessionId, arg.turnId) : undefined + this.openPanel({ + workspaceRoot: getWorkspaceRoot(), + sessionId, + initialSourceId: turnInitialSourceId ?? arg?.initialSourceId, + hidePicker: !!turnInitialSourceId, + }) + } + + /** + * Called when VS Code restores a serialized panel after restart. State + * is not persisted, so we discard the panel instead of rewiring it. + */ + deserializePanel(panel: vscode.WebviewPanel): void { + panel.dispose() + } + + dispose(): void { + this.controller?.dispose() + this.controller = undefined + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined + this.disposePanel() + this.output.dispose() + } + + private createPanel(): void { + const panel = vscode.window.createWebviewPanel(DiffViewerProvider.viewType, "Changes", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }) + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), + } + panel.webview.html = this.getHtml(panel.webview) + this.panel = panel + + this.controller = new SourceController( + (id, ctx) => this.catalog.build(id, ctx), + (ctx) => this.catalog.listAvailable(ctx), + (msg) => void panel.webview.postMessage(msg), + ) + if (this.ctx) this.controller.setContext(this.ctx) + + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = watchFontSizeConfig((msg) => void panel.webview.postMessage(msg)) + + this.panelDisposables.push( + panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg as Record)), + panel.onDidDispose(() => this.onPanelDisposed()), + ) + } + + private onPanelDisposed(): void { + this.log("Panel disposed") + this.controller?.dispose() + this.controller = undefined + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined + this.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..cd8d01a3bf7 --- /dev/null +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -0,0 +1,190 @@ +import { hashFileDiffs } from "./shared/hash" +import { DIFF_POLL_INTERVAL_MS } from "./polling" +import type { DiffSource, DiffSourceDescriptor } from "./sources/types" +import type { PanelContext } from "./types" + +/** + * Owns the active DiffSource for a panel: builds it via the injected `build` + * function, runs an initial fetch, and then polls on a fixed interval with + * hash-dedup. Posts loading / diffs / notice messages to the webview, and + * disposes the source on swap or teardown. + * + * Sources are declarative — they only implement `fetch()` (and optionally + * `fetchFile` / `revert` / `dispose`). All lifecycle, polling, and message + * posting lives here so that concrete sources can be plain factory functions + * with closure state instead of classes with a `post`/`start`/`dispose` dance. + * + * Stale results are filtered via an internal epoch counter that bumps on + * every stop/activate, so in-flight fetches from a disposed source are + * dropped. + */ +export class SourceController { + private ctx: PanelContext | undefined + private activeId: string | undefined + private active: DiffSource | undefined + private interval: ReturnType | undefined + private lastHash: string | undefined + private epoch = 0 + + constructor( + private readonly build: (id: string, ctx: PanelContext) => DiffSource, + private readonly listAvailable: (ctx: PanelContext) => DiffSourceDescriptor[], + private readonly post: (msg: unknown) => void, + ) {} + + 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 fetches are dropped. */ + stop(): void { + this.epoch++ + this.stopPolling() + this.active?.dispose?.() + this.active = undefined + this.activeId = undefined + this.lastHash = undefined + } + + /** + * Build, initial-fetch, and start polling source `id` in the current context. + * Disposes any previously active source. Throws if the catalog can't build + * the id — callers should catch and log. + */ + async activate(id: string): 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 keepPolling = await this.runFetch(source, epoch, true) + // Prevents the polling interval from starting after teardown or swap. + if (this.epoch !== epoch || this.activeId !== id) return + if (keepPolling) this.startPolling(source, epoch) + } + + async revertFile(file: string): Promise { + const source = this.active + if (!source?.revert) { + this.post({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Revert is not supported for the current source", + }) + return + } + + const epoch = this.epoch + const result = await source.revert(file).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message } + }) + this.post({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + // Push fresh diffs immediately after a successful revert so the webview + // doesn't have to wait for the next polling tick. + if (result.ok && this.epoch === epoch && this.active === source) { + await this.runFetch(source, epoch, false) + } + } + + /** + * Lazy detail load for a single file. Forwards to the active source's + * `fetchFile`. Posts `diff: null` when the source can't resolve the file + * or doesn't support per-file detail, so the webview can clear its + * pending-loading indicator either way. + */ + async requestFile(file: string): Promise { + const source = this.active + const epoch = this.epoch + if (!source?.fetchFile) { + this.post({ type: "diffViewer.diffFile", file, diff: null }) + return + } + const diff = await source.fetchFile(file).catch(() => null) + // Drop the response if the source has been disposed/swapped while we waited. + if (this.epoch !== epoch) return + this.post({ type: "diffViewer.diffFile", file, diff }) + } + + dispose(): void { + this.stop() + } + + /** + * Run one fetch against the source and post results. Returns whether the + * controller should keep polling this source — false when the source + * requests a stop or the epoch has moved on. + */ + private async runFetch(source: DiffSource, epoch: number, initial: boolean): Promise { + if (initial) this.post({ type: "diffViewer.loading", loading: true }) + + try { + const result = await source.fetch() + if (this.epoch !== epoch) return false + + if (result.notice !== undefined) { + this.post({ type: "diffViewer.notice", notice: result.notice }) + } + + const hash = hashFileDiffs(result.diffs as never) + if (initial || hash !== this.lastHash) { + this.lastHash = hash + this.post({ type: "diffViewer.diffs", diffs: result.diffs }) + } + + return !result.stopPolling + } catch (err) { + if (this.epoch !== epoch) return false + // Errors are swallowed for the webview (it just needs the loading + // indicator cleared below), but we always log so initial-fetch + // failures leave a trace in the Extension Host output — previously + // they were silent and invisible in production. + console.log("[Kilo New] SourceController.fetch error", { initial, err }) + return true + } finally { + if (initial && this.epoch === epoch) { + this.post({ type: "diffViewer.loading", loading: false }) + } + } + } + + private startPolling(source: DiffSource, epoch: number): void { + this.stopPolling() + this.interval = setInterval(async () => { + // Self-cancel when the tick reports the source is done + const keep = await this.runFetch(source, epoch, false) + if (!keep) this.stopPolling() + }, DIFF_POLL_INTERVAL_MS) + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } +} diff --git a/packages/kilo-vscode/src/diff/polling.ts b/packages/kilo-vscode/src/diff/polling.ts new file mode 100644 index 00000000000..a054aebc67f --- /dev/null +++ b/packages/kilo-vscode/src/diff/polling.ts @@ -0,0 +1,5 @@ +/** + * Shared polling cadence for diff sources in + * `diff/sources/` and the Agent Manager's `WorktreeDiffController` + */ +export const DIFF_POLL_INTERVAL_MS = 2500 diff --git a/packages/kilo-vscode/src/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..13c3027649c --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -0,0 +1,81 @@ +import type { KiloConnectionService } from "../../services/cli-backend" +import type { PanelContext } from "../types" +import type { DiffSource, DiffSourceDescriptor } from "./types" +import { createWorktreeDiffSource, WORKSPACE_DESCRIPTOR, WORKSPACE_SOURCE_ID } from "./worktree" +import { + SESSION_PREFIX, + createSessionDiffSource, + sessionDescriptor, + sessionSourceId, + type SessionDiffFetch, + type SnapshotEnabledCheck, +} from "./session" +import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn" + +/** + * 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 ?? [] + } + + /** + * Turn diffs are stored on the user message itself (`summary.diffs`), not + * on the session-level snapshot. The `/session/:id/diff` endpoint ignores + * its `messageID` param today, so we fetch the message directly instead. + */ + private readonly turnFetch: TurnDiffFetch = async ({ sessionID, messageID, directory }) => { + const client = this.connection.getClient() + const { data } = await client.session.message({ sessionID, messageID, directory }, { throwOnError: true }) + const info = data?.info + if (!info || info.role !== "user") return [] + return info.summary?.diffs ?? [] + } + + private readonly checkSnapshotsEnabled: SnapshotEnabledCheck = async (directory) => { + const client = this.connection.getClient() + const { data } = await client.config.get({ directory }, { throwOnError: true }) + // Snapshot tracking defaults to true when omitted. + return data?.snapshot !== false + } + + constructor(private readonly connection: KiloConnectionService) {} + + listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { + if (ctx.hidePicker) return [] + 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 createWorktreeDiffSource(this.connection) + + if (id.startsWith(TURN_PREFIX)) { + const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") + if (!sessionId || !messageId) { + throw new Error(`DiffSourceCatalog.build: malformed turn id "${id}" (expected turn::)`) + } + return createTurnDiffSource(sessionId, messageId, this.turnFetch, ctx.workspaceRoot) + } + + if (id.startsWith(SESSION_PREFIX)) { + const sessionId = id.slice(SESSION_PREFIX.length) + if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) + return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + } + + throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) + } +} diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts new file mode 100644 index 00000000000..cf10f0e2a2a --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -0,0 +1,81 @@ +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import { normalize, text } from "@kilocode/kilo-ui/session-diff" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" + +export type SessionDiffFetch = (params: { sessionID: string; directory?: string }) => Promise + +export type SnapshotEnabledCheck = (directory?: string) => Promise + +export const SESSION_PREFIX = "session:" + +export function sessionSourceId(sessionId: string): string { + return `${SESSION_PREFIX}${sessionId}` +} + +export function sessionDescriptor(sessionId: string): DiffSourceDescriptor { + return { + id: sessionSourceId(sessionId), + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, + } +} + +/** + * Diff for the current session. Returns file diffs from the SDK's session + * snapshot endpoint, or a `snapshots-disabled` notice if snapshotting is + * turned off in the workspace config (in which case the controller stops + * polling because repeated fetches can't surface new data). + */ +export function createSessionDiffSource( + sessionId: string, + fetch: SessionDiffFetch, + workspaceRoot?: string, + checkSnapshotsEnabled?: SnapshotEnabledCheck, +): DiffSource { + // Cached across fetches so subsequent polling ticks skip the config lookup. + let snapshotsDisabled = false + + return { + descriptor: sessionDescriptor(sessionId), + + async fetch(): Promise { + if (snapshotsDisabled) { + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } + } + + if (checkSnapshotsEnabled) { + const enabled = await checkSnapshotsEnabled(workspaceRoot) + if (!enabled) { + snapshotsDisabled = true + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } + } + } + + const raw = await fetch({ sessionID: sessionId, directory: workspaceRoot }) + return { diffs: raw.map(toSessionDiffFile) } + }, + } +} + +/** + * Project a backend `SnapshotFileDiff` onto the `DiffFile` shape the viewer + * expects. Shared with `createTurnDiffSource` since both hit the same endpoint. + */ +export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { + // Empty patch means binary or summarized (>256 KB) — normalize() can't + // parse it, so short-circuit to empty strings. + const view = raw.patch === "" ? null : normalize(raw) + return { + file: raw.file, + before: view ? text(view, "deletions") : "", + after: view ? text(view, "additions") : "", + additions: raw.additions, + deletions: raw.deletions, + status: raw.status, + tracked: true, + generatedLike: false, + summarized: raw.patch === "", + } +} diff --git a/packages/kilo-vscode/src/diff/sources/turn.ts b/packages/kilo-vscode/src/diff/sources/turn.ts new file mode 100644 index 00000000000..24b6b5e1bc9 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/turn.ts @@ -0,0 +1,53 @@ +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import { toSessionDiffFile } from "./session" + +export const TURN_PREFIX = "turn:" + +export function turnSourceId(sessionId: string, messageId: string): string { + return `${TURN_PREFIX}${sessionId}:${messageId}` +} + +export function turnDescriptor(sessionId: string, messageId: string): DiffSourceDescriptor { + return { + id: turnSourceId(sessionId, messageId), + type: "turn", + // Group is irrelevant here because turn sources only open in hide-picker + // mode; the picker never renders them. Default to "Session" for cohesion. + group: "Session", + capabilities: { revert: false, comments: true }, + } +} + +/** + * Fetches the per-turn diffs attached to a user message. The session-level + * `/session/:id/diff` endpoint ignores `messageID`, so the per-turn view has + * to read from the message's own `summary.diffs`. + */ +export type TurnDiffFetch = (params: { + sessionID: string + messageID: string + directory?: string +}) => Promise + +/** + * Static diff for a single turn (the file changes attributed to one user + * message). Returns `stopPolling: true` so the controller runs `fetch` once + * and never schedules a polling tick — a completed turn's snapshot doesn't + * change. + */ +export function createTurnDiffSource( + sessionId: string, + messageId: string, + fetch: TurnDiffFetch, + workspaceRoot?: string, +): DiffSource { + return { + descriptor: turnDescriptor(sessionId, messageId), + + async fetch(): Promise { + const raw = await fetch({ sessionID: sessionId, messageID: messageId, directory: workspaceRoot }) + return { diffs: raw.map(toSessionDiffFile), stopPolling: true } + }, + } +} diff --git a/packages/kilo-vscode/src/diff/sources/types.ts b/packages/kilo-vscode/src/diff/sources/types.ts new file mode 100644 index 00000000000..6667de06fde --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/types.ts @@ -0,0 +1,65 @@ +import type { DiffFile } from "../types" + +export interface DiffSourceCapabilities { + revert: boolean + comments: boolean +} + +/** + * Closed enum of diff source kinds. Drives i18n key composition for types + * that appear in the picker: `diffViewer.source..label` and + * `diffViewer.source..tooltip`. Types that are only ever shown in + * hide-picker mode (e.g. `turn`) don't need matching i18n entries because + * `DiffPickerHeader` never renders them. + */ +export type DiffSourceType = "workspace" | "session" | "turn" + +export interface DiffSourceDescriptor { + /** Unique within a panel context. E.g. "workspace", "session:". */ + id: string + type: DiffSourceType + group: "Session" | "Git" + /** kilo-ui icon name. */ + icon?: string + capabilities: DiffSourceCapabilities +} + +/** + * Well-known notice kinds that a source can surface to the diff viewer. + * The webview maps these to translated messages. + */ +export type DiffSourceNotice = "snapshots-disabled" + +export interface DiffSourceFetch { + diffs: DiffFile[] + notice?: DiffSourceNotice + /** + * When true the controller stops polling the source after this fetch. + * Used for terminal states like snapshots-disabled, where repeat fetches + * can't surface new data. + */ + stopPolling?: boolean +} + +/** + * A DiffSource is a plain data producer for a given context (local workspace, + * session changes, a turn, a git ref...). The SourceController owns one active + * source at a time, calls `fetch` on activation and on a polling tick, and + * forwards the results to the webview. + */ +export interface DiffSource { + readonly descriptor: DiffSourceDescriptor + + fetch(): Promise + + /** + * Lazy detail load for a single file, for sources that emit summarized + * entries (no `before`/`after` content) so the webview can fetch full + * content on demand. + */ + fetchFile?(file: string): Promise + + revert?(file: string): Promise<{ ok: boolean; message: string }> + + dispose?(): void +} diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts new file mode 100644 index 00000000000..dc1f377900d --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -0,0 +1,112 @@ +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 { resolveLocalDiffTarget } from "../shared/target" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" + +export const WORKSPACE_SOURCE_ID = "workspace" + +export const WORKSPACE_DESCRIPTOR: DiffSourceDescriptor = { + id: WORKSPACE_SOURCE_ID, + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, +} + +/** + * Diffs between the local working tree and the base branch. Each fetch returns + * a summary (one entry per changed file, no content); the viewer loads + * `before`/`after` per file on demand via `fetchFile`. Runs entirely in the + * extension host — no `kilo serve` round-trip. + */ +export function createWorktreeDiffSource(connection: KiloConnectionService): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) + const git = new GitOps({ log }) + + // Cached between fetches so repeated polling doesn't re-resolve the base + // branch every tick. Reset only on dispose (when the source is swapped out). + let target: DiffTarget | undefined + + const resolveTarget = async (): Promise => { + if (target) return target + target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + return target + } + + return { + descriptor: WORKSPACE_DESCRIPTOR, + + async fetch(): Promise { + const current = await resolveTarget() + if (!current) return { diffs: [] } + + const entries = await diffSummary(git, current.directory, current.baseBranch, log) + const diffs = entries.map(toDiffFile) + log(`Diff: ${diffs.length} file(s)`) + return { diffs } + }, + + async fetchFile(file: string): Promise { + if (!file) return null + const current = await resolveTarget() + if (!current) return null + + try { + const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + if (!entry) return null + return toDiffFile(entry) + } catch (err) { + log("Failed to fetch worktree diff file:", err) + return null + } + }, + + async revert(file: string): Promise<{ ok: boolean; message: string }> { + const current = await resolveTarget() + if (!current) return { ok: false, message: "Could not resolve diff target" } + + try { + const client = connection.getClient() + const diff = new WorktreeDiffClient(client, git, log) + return await diff.revertFile(current, file) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + log("Failed to revert file:", message) + return { ok: false, message } + } + }, + + dispose(): void { + git.dispose() + output.dispose() + target = undefined + }, + } +} + +/** + * 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..c3260a413b4 --- /dev/null +++ b/packages/kilo-vscode/src/diff/types.ts @@ -0,0 +1,25 @@ +export interface PanelContext { + workspaceRoot: string | undefined + sessionId?: string + /** Overrides the computed default source on open. */ + initialSourceId?: string + /** + * Hides the source picker header in the diff viewer. Used for panels that + * open in a fixed view (e.g. a specific turn's diff) + */ + hidePicker?: boolean +} + +/** Mirrors `WorktreeFileDiff` in webview-ui/src/types/messages/agent-manager.ts. */ +export interface DiffFile { + file: string + before: string + after: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string +} diff --git a/packages/kilo-vscode/src/enhance-prompt-error.ts b/packages/kilo-vscode/src/enhance-prompt-error.ts new file mode 100644 index 00000000000..f53f3c854f5 --- /dev/null +++ b/packages/kilo-vscode/src/enhance-prompt-error.ts @@ -0,0 +1,22 @@ +/** + * Convert raw Enhance Prompt provider failures into actionable messages. + */ +export function normalizeEnhancePromptErrorMessage(raw: string): string { + const base = raw || "Failed to enhance prompt" + const normalized = base.toLowerCase() + + const looksLikeQuotaError = + normalized.includes("insufficient_quota") || + normalized.includes("insufficient quota") || + normalized.includes("exceeded your current quota") || + (normalized.includes("quota") && normalized.includes("billing")) + + if (!looksLikeQuotaError) return base + + const details = base === "Failed to enhance prompt" ? "" : ` Provider response: ${base}` + return ( + "Enhance Prompt failed due to provider quota/billing limits. " + + "Check your provider account billing/quota and API access, then retry." + + details + ) +} diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index a393f7843e0..8d01db16641 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" @@ -23,6 +24,10 @@ 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; @@ -52,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()) @@ -67,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) @@ -174,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) => @@ -200,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) }) @@ -326,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; turnId?: string; initialSourceId?: string }) => { + diffViewerProvider.openFromCommand(arg) + }, + ), vscode.commands.registerCommand("kilo-code.new.openSubAgentViewer", (sessionID: string, title?: string) => { subAgentViewerProvider.openPanel(sessionID, title) }), @@ -468,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/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/save-image.ts b/packages/kilo-vscode/src/kilo-provider/save-image.ts new file mode 100644 index 00000000000..6e24fa5380f --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/save-image.ts @@ -0,0 +1,25 @@ +import * as path from "path" +import * as vscode from "vscode" +import { parseImage } from "../image-preview" + +type ImageMessage = { + dataUrl: string + filename: string +} + +export function saveImage(dir: string, msg: ImageMessage) { + void save(dir, msg).catch((err) => console.error("[Kilo New] KiloProvider: Failed to save image:", err)) +} + +async function save(dir: string, msg: ImageMessage) { + const img = parseImage(msg.dataUrl, msg.filename) + if (!img) return undefined + + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(dir, img.name)), + filters: { Images: [img.ext] }, + saveLabel: "Save", + }) + if (!uri) return undefined + return vscode.workspace.fs.writeFile(uri, img.data) +} diff --git a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts index a478d9b13c4..dc4d54fe5b0 100644 --- a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -7,13 +7,15 @@ interface Msg { baseBranch?: string branchName?: string sessionId?: string + turnId?: string } interface Ctx { post: (msg: unknown) => void openAgentManager: () => Thenable openAdvancedWorktree: () => Thenable - openChanges: () => Thenable + openChanges: (sessionId?: string, turnId?: string) => Thenable + currentSessionId?: string createWorktree?: (baseBranch?: string, branchName?: string) => Promise continueInWorktree?: ( sessionId: string, @@ -53,7 +55,7 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { } if (message.type === "openChanges") { - await ctx.openChanges() + await ctx.openChanges(ctx.currentSessionId, message.turnId) return true } diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts index 1c5c3eafe49..b8fa13b3c0b 100644 --- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts +++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts @@ -16,6 +16,7 @@ // Max chars to keep for truncated output fields (bash metadata.output etc.) const OUTPUT_CAP = 4000 +const PATCH_CAP = 64_000 // --------------------------------------------------------------------------- // Helpers @@ -32,11 +33,22 @@ function cap(v: unknown, limit = OUTPUT_CAP): string | undefined { return v.slice(0, limit) + `\n… (truncated, ${v.length - limit} chars omitted)` } +function patch(v: unknown): string | undefined { + if (typeof v !== "string") return undefined + if (v.length > PATCH_CAP) return undefined + return v +} + +function withPatch(v: unknown): { patch: string } | {} { + const kept = patch(v) + return kept ? { patch: kept } : {} +} + // --------------------------------------------------------------------------- // Per-tool slimmers // --------------------------------------------------------------------------- -/** edit: strip filediff.before/after (webview falls back to input.oldString/newString). */ +/** edit: strip filediff.before/after while preserving bounded patches for inline diffs. */ function slimEdit(state: Record): Record { const next = { ...state } const meta = state.metadata @@ -50,6 +62,7 @@ function slimEdit(state: Record): Record { if (isObj(fd)) { result.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), + ...withPatch(fd.patch), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } @@ -59,7 +72,7 @@ function slimEdit(state: Record): Record { return next } -/** apply_patch: strip files[].before/after/diff, metadata.diff, + input.patchText. */ +/** apply_patch: strip full file contents and input patch text while preserving bounded rendered patches. */ function slimPatch(state: Record): Record { const next = { ...state } const meta = state.metadata @@ -67,14 +80,18 @@ function slimPatch(state: Record): Record { const slim: Record = {} if (meta.diagnostics) slim.diagnostics = meta.diagnostics if (Array.isArray(meta.files)) { - slim.files = (meta.files as Record[]).map((f) => ({ - filePath: f.filePath, - relativePath: f.relativePath, - type: f.type, - additions: f.additions, - deletions: f.deletions, - movePath: f.movePath, - })) + slim.files = (meta.files as Record[]).map((f) => { + const diff = patch(f.patch) ?? patch(f.diff) + return { + filePath: f.filePath, + relativePath: f.relativePath, + type: f.type, + ...withPatch(diff), + additions: f.additions, + deletions: f.deletions, + movePath: f.movePath, + } + }) } next.metadata = slim } @@ -101,6 +118,7 @@ function slimMultiedit(state: Record): Record if (isObj(fd)) { rs.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), + ...withPatch(fd.patch), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } @@ -130,6 +148,7 @@ function slimWrite(state: Record): Record { if (isObj(fd)) { slim.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), + ...withPatch(fd.patch), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index 4def9b0fda7..fb0c4beebfc 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -1,8 +1,13 @@ /** * KiloClaw panel provider for the VS Code extension. * - * Owns the Stream Chat WebSocket connection (in the extension host Node.js runtime) - * and relays messages to/from the webview via postMessage. + * Owns the Kilo Chat HTTP client + event-service WebSocket connection + * (in the extension host Node.js runtime) and relays messages to/from + * the webview via postMessage. + * + * Architecture: extension host owns both clients and reactive state; + * the webview is a stateless renderer that issues commands via + * postMessage and receives state diffs back. */ import * as vscode from "vscode" @@ -11,35 +16,79 @@ import type { KiloConnectionService } from "../services/cli-backend" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildWebviewHtml } from "../utils" import { watchFontSizeConfig } from "../kilo-provider/font-size" -import { connect, history, presence, type ClawChatClient } from "./chat-client" +import { TokenManager } from "./token-manager" +import { KiloChatApiError, KiloChatClient } from "./kilo-chat-client" +import { EventServiceClient, WebSocketAuthError } from "./event-service-client" +import { ulid } from "./ulid" import type { + ActionDeliveryFailedEvent, + BotStatusEvent, + BotStatusRecord, + ChatToken, + ClawStatus, + ContentBlock, + ConversationActivityEvent, + ConversationCreatedEvent, + ConversationLeftEvent, + ConversationListItem, + ConversationRenamedEvent, + ConversationStatusEvent, + ConversationStatusRecord, + ExecApprovalDecision, KiloClawInMessage, KiloClawOutMessage, KiloClawState, - ClawStatus, - ChatCredentials, - ChatMessage, + Message, + MessageCreatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + MessageUpdatedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + TypingMember, + TypingEvent, } from "./types" -const MAX_MESSAGES = 500 const STATUS_POLL_MS = 10_000 +const BOT_STATUS_NUDGE_MS = 15_000 +const TYPING_TIMEOUT_MS = 5_000 +const MESSAGES_PAGE = 50 +const CONVERSATIONS_PAGE = 50 export class KiloClawProvider implements vscode.Disposable { static readonly viewType = "kilo-code.new.KiloClawPanel" private panel: vscode.WebviewPanel | null = null - private chat: ClawChatClient | null = null private timer: ReturnType | null = null + private botNudge: ReturnType | null = null private subs: Array<() => void> = [] private chatSubs: Array<() => void> = [] - private messages: ChatMessage[] = [] - private status: ClawStatus | null = null - private online = false - private connected = false private disposed = false private initializing = false private generation = 0 + // Clients (created lazily per init) + private events: EventServiceClient | null = null + private chat: KiloChatClient | null = null + private tokens: TokenManager | null = null + + // Reactive state mirrored to the webview + private status: ClawStatus | null = null + private currentUserId: string | null = null + private sandboxId: string | null = null + private conversations: ConversationListItem[] = [] + private conversationsCursor: string | null = null + private hasMoreConversations = false + private activeConversationId: string | null = null + private messages: Message[] = [] + private hasMoreMessages = false + private botStatus: BotStatusRecord | null = null + private conversationStatus: ConversationStatusRecord | null = null + private typingMembers: TypingMember[] = [] + private typingTimers = new Map>() + private subscribedSandboxContext: string | null = null + private subscribedConversationContext: string | null = null + constructor( private readonly uri: vscode.Uri, private readonly connection: KiloConnectionService, @@ -106,14 +155,18 @@ export class KiloClawProvider implements vscode.Disposable { }) this.subs.push(() => disposeSub.dispose()) - // Pause status polling when the panel is not visible to avoid unnecessary HTTP traffic + // Pause status polling + bot nudge when the panel is not visible const viewSub = panel.onDidChangeViewState(() => { - if (panel.visible) this.startPolling() - else this.stopPolling() + if (panel.visible) { + this.startPolling() + this.startBotNudge() + } else { + this.stopPolling() + this.stopBotNudge() + } }) this.subs.push(() => viewSub.dispose()) - // Subscribe to language changes broadcast by other KiloProvider instances const unsub = this.connection.onLanguageChanged((locale) => { this.post({ type: "kiloclaw.locale", locale }) }) @@ -130,17 +183,59 @@ export class KiloClawProvider implements vscode.Disposable { switch (msg.type) { case "kiloclaw.ready": await this.init() - break - case "kiloclaw.send": - await this.sendChat(msg.text) - break + return case "kiloclaw.openExternal": { const uri = vscode.Uri.parse(msg.url) if (uri.scheme === "https" || uri.scheme === "http") { void vscode.env.openExternal(uri) } - break + return } + case "kiloclaw.selectConversation": + await this.selectConversation(msg.conversationId) + return + case "kiloclaw.createConversation": + await this.createConversation(msg.title) + return + case "kiloclaw.renameConversation": + await this.renameConversation(msg.conversationId, msg.title) + return + case "kiloclaw.leaveConversation": + await this.leaveConversation(msg.conversationId) + return + case "kiloclaw.loadMoreConversations": + await this.loadMoreConversations() + return + case "kiloclaw.sendMessage": + await this.sendMessage(msg.conversationId, msg.content, msg.inReplyToMessageId) + return + case "kiloclaw.editMessage": + await this.editMessage(msg.conversationId, msg.messageId, msg.content) + return + case "kiloclaw.deleteMessage": + await this.deleteMessage(msg.conversationId, msg.messageId) + return + case "kiloclaw.loadMoreMessages": + await this.loadMoreMessages(msg.conversationId, msg.before) + return + case "kiloclaw.addReaction": + await this.addReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.removeReaction": + await this.removeReaction(msg.conversationId, msg.messageId, msg.emoji) + return + case "kiloclaw.executeAction": + await this.executeAction(msg.conversationId, msg.messageId, msg.groupId, msg.value) + return + case "kiloclaw.sendTyping": + await this.sendTyping(msg.conversationId) + return + case "kiloclaw.sendTypingStop": + await this.sendTypingStop(msg.conversationId) + return + case "kiloclaw.markRead": + await this.markRead(msg.conversationId) + return } } @@ -153,14 +248,13 @@ export class KiloClawProvider implements vscode.Disposable { return gen !== this.generation || this.disposed } + // ── init / lifecycle ──────────────────────────────────────────────── + private async init(): Promise { if (this.initializing || this.disposed) return this.initializing = true const gen = this.generation - // Track whether we deferred to waitForConnection — if so, keep - // `initializing` true so duplicate kiloclaw.ready messages are - // harmlessly ignored until the connection arrives. let deferred = false try { @@ -174,89 +268,195 @@ export class KiloClawProvider implements vscode.Disposable { return } - const credentials = await this.fetchCreds(client, gen) - if (!credentials) return - - // Connect to Stream Chat - try { - await this.connectChat(credentials, gen) - } catch (err: unknown) { - if (this.stale(gen)) return - const msg = err instanceof Error ? err.message : String(err) - console.error("[Kilo New] KiloClaw chat connect failed:", msg) - this.post({ - type: "kiloclaw.state", - state: { - phase: "ready", - locale: this.locale, - status: this.status, - connected: false, - online: false, - messages: [], - }, - }) - this.post({ type: "kiloclaw.error", error: msg || "Failed to connect to chat" }) - this.startPolling() - return - } - + const ok = await this.bootstrap(client, gen) + if (!ok) return if (this.stale(gen)) return - // Push ready state const state: KiloClawState = { phase: "ready", locale: this.locale, status: this.status, - connected: this.connected, - online: this.online, + currentUserId: this.currentUserId ?? "", + sandboxId: this.sandboxId ?? "", + conversations: this.conversations, + hasMoreConversations: this.hasMoreConversations, + activeConversationId: this.activeConversationId, messages: this.messages, + hasMoreMessages: this.hasMoreMessages, + botStatus: this.botStatus, + conversationStatus: this.conversationStatus, + typingMembers: this.typingMembers, } this.post({ type: "kiloclaw.state", state }) this.startPolling() + this.startBotNudge() } finally { if (!deferred) this.initializing = false } } /** - * Fetch and validate instance status + chat credentials. - * Returns credentials on success, null when stale or after posting a state. - * - * Matches the TUI flow in packages/opencode/src/kilocode/kilo-commands.tsx:67,75 — - * any failure of status() (SDK error, non-2xx from the gateway, missing data, or - * missing userId) funnels to noInstance (SetupView). Any failure of - * chatCredentials() funnels to needsUpgrade (UpgradeView). The upstream Kilo API - * returns a non-2xx when no instance is provisioned, which the gateway mirrors - * and the SDK surfaces as res.error — not a thrown exception. + * Resolve instance status, fetch chat token, and wire up all clients. + * Returns `true` if everything is ready, `false` if a non-ready phase + * was already posted (loading / noInstance / needsUpgrade / error). */ - private async fetchCreds(client: KiloClient, gen: number): Promise { - const res = await client.kilo.claw.status().catch(() => null) - if (this.stale(gen)) return null + private async bootstrap(client: KiloClient, gen: number): Promise { + const ok = await this.resolveStatus(client, gen) + if (!ok) return false + if (this.stale(gen)) return false - const data = res?.data as (ClawStatus & { userId?: string }) | undefined - if (!res || (res as Record).error || !data || !data.userId) { + const envelope = await this.fetchChatToken(gen) + if (!envelope) return false + if (this.stale(gen)) return false + + if (!(await this.openChatStream(envelope, gen))) return false + if (this.stale(gen)) return false + + if (!this.sandboxId) { this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) - return null + return false } - this.status = data + await this.loadInitialSnapshots() + return true + } - const creds = await client.kilo.claw.chatCredentials().catch(() => null) - if (this.stale(gen)) return null + private async resolveStatus(client: KiloClient, gen: number): Promise { + const statusRes = await client.kilo.claw.status().catch(() => null) + if (this.stale(gen)) return false - if (!creds || (creds as Record).error || !creds.data) { + const statusData = statusRes?.data as (ClawStatus & { userId?: string }) | undefined + if (!statusRes || (statusRes as Record).error || !statusData || !statusData.userId) { + this.post({ type: "kiloclaw.state", state: { phase: "noInstance", locale: this.locale } }) + return false + } + this.status = statusData + this.currentUserId = statusData.userId + this.sandboxId = statusData.sandboxId ?? null + return true + } + + private async fetchChatToken(gen: number): Promise { + const tokens = new TokenManager(() => { + try { + return this.connection.getClient() + } catch { + return null + } + }) + try { + const envelope = await tokens.getOrFetch() + this.tokens = tokens + return envelope + } catch (err) { + if (this.stale(gen)) return null + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw chat token fetch failed:", message) + // Token fetch typically fails when the instance hasn't been upgraded + // to support kilo-chat — surface that as the upgrade prompt. this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) return null } - - return creds.data as ChatCredentials } - /** - * Ensure the CLI backend is running and return its SDK client. - * Returns `null` when the backend isn't available yet (caller should defer). - */ - private async resolveClient() { + private async openChatStream(envelope: ChatToken, gen: number): Promise { + const tokens = this.tokens! + const events = new EventServiceClient({ + url: envelope.eventServiceUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.events = events + + const chat = new KiloChatClient({ + baseUrl: envelope.kiloChatUrl, + getToken: () => tokens.get(), + onUnauthorized: () => { + tokens.clear() + this.post({ type: "kiloclaw.error", error: "Authentication expired" }) + }, + }) + this.chat = chat + + try { + await events.connect() + } catch (err) { + if (this.stale(gen)) return false + if (err instanceof WebSocketAuthError) { + this.post({ type: "kiloclaw.state", state: { phase: "needsUpgrade", locale: this.locale } }) + return false + } + const message = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] KiloClaw event-service connect failed:", message) + this.post({ + type: "kiloclaw.state", + state: { phase: "error", locale: this.locale, error: message || "Failed to connect to chat" }, + }) + return false + } + + this.attachEventHandlers(events, chat) + this.subscribeSandboxContext() + return true + } + + private async loadInitialSnapshots(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw listConversations failed:", message) + } + + try { + const res = await this.chat.getBotStatus(target) + if (this.sandboxId !== target) return + this.botStatus = res.status ?? null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getBotStatus failed:", message) + } + + // Auto-select the most recent conversation so the panel opens straight + // into the user's ongoing chat instead of the "select a conversation" + // empty state. Only runs on first init (or after the active one was + // explicitly cleared) — preserves the user's selection across reconnects. + if (!this.activeConversationId && this.conversations.length > 0) { + const latest = this.conversations.reduce((best, c) => { + const ax = best.lastActivityAt ?? best.joinedAt + const bx = c.lastActivityAt ?? c.joinedAt + return bx > ax ? c : best + }) + this.activeConversationId = latest.conversationId + this.subscribeConversationContext(latest.conversationId) + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(latest.conversationId) + if (this.activeConversationId === latest.conversationId) { + this.conversationStatus = res.status ?? null + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + } + + void this.markRead(latest.conversationId) + } + } + + private async resolveClient(): Promise { if (this.connection.getConnectionState() !== "connected") { try { const dir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? homedir() @@ -274,95 +474,673 @@ export class KiloClawProvider implements vscode.Disposable { } } - private async connectChat(creds: ChatCredentials, gen: number): Promise { - // Disconnect previous client to avoid duplicate websockets/listeners - this.disconnectChat() - - const client = await connect(creds) - - // If the panel was disposed or reinitialized while connect() was in flight, - // tear down the freshly-created client immediately to avoid leaked websockets. - if (this.stale(gen)) { - client.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw stale disconnect failed:", err?.message ?? err) - }) - return - } - - this.chat = client - - // Load history - const bot = `bot-${creds.channelId.replace(/^default-/, "")}` - this.messages = history(this.chat.channel, bot) - this.online = presence(this.chat.channel, bot) - this.connected = true - - // Subscribe to events and relay to webview - const unsub = this.chat.onMessage((msg) => { - // Dedupe: if a message with this id already exists, treat as update - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx !== -1) { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - return + private waitForConnection(): void { + const unsub = this.connection.onStateChange((state) => { + if (state === "connected" && !this.disposed) { + unsub() + this.initializing = false + void this.init() } - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) - } - this.post({ type: "kiloclaw.message", message: msg }) }) - this.chatSubs.push(unsub) + this.subs.push(unsub) + } - const unsubUpdated = this.chat.onMessageUpdated((msg) => { - const idx = this.messages.findIndex((m) => m.id === msg.id) - if (idx === -1) { - this.messages = [...this.messages, msg] - if (this.messages.length > MAX_MESSAGES) { - this.messages = this.messages.slice(-MAX_MESSAGES) + // ── Subscriptions ─────────────────────────────────────────────────── + + private subscribeSandboxContext(): void { + if (!this.events || !this.sandboxId) return + const ctx = `/kiloclaw/${this.sandboxId}` + this.events.subscribe([ctx]) + this.subscribedSandboxContext = ctx + } + + private subscribeConversationContext(conversationId: string): void { + if (!this.events || !this.sandboxId) return + if (this.subscribedConversationContext) { + this.events.unsubscribe([this.subscribedConversationContext]) + } + const ctx = `/kiloclaw/${this.sandboxId}/${conversationId}` + this.events.subscribe([ctx]) + this.subscribedConversationContext = ctx + } + + private unsubscribeConversationContext(): void { + if (!this.events || !this.subscribedConversationContext) return + this.events.unsubscribe([this.subscribedConversationContext]) + this.subscribedConversationContext = null + } + + private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void { + // Reset on reconnect — the event stream may have missed events while + // disconnected, so refetch authoritative state. + const offReconnect = events.onReconnect(() => { + void this.refreshOnReconnect() + }) + this.chatSubs.push(offReconnect) + + // ── Sandbox-scoped events ───────────────────────────────────────── + + this.chatSubs.push( + events.on("conversation.created", (ctx, e: ConversationCreatedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + // Newer servers include the full conversation snapshot — splice it in + // immediately so the list updates without a roundtrip. Fall back to a + // refetch when the snapshot is absent (older servers / safety net). + if (e.conversation) { + this.conversations = mergeConversations([e.conversation], this.conversations) + this.broadcastConversations({ replace: true }) + return } - } else { - this.messages = this.messages.map((m, i) => (i === idx ? msg : m)) - } - this.post({ type: "kiloclaw.messageUpdated", message: msg }) - }) - this.chatSubs.push(unsubUpdated) + void this.refreshConversations() + }), + ) - const unsubPresence = this.chat.onPresence((val) => { - this.online = val - this.post({ type: "kiloclaw.presence", online: val }) - }) - this.chatSubs.push(unsubPresence) + this.chatSubs.push( + events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, title: e.title } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.left", (ctx, e: ConversationLeftEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.filter((c) => c.conversationId !== e.conversationId) + if (this.activeConversationId === e.conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.hasMoreMessages = false + this.conversationStatus = null + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ + type: "kiloclaw.messages", + conversationId: e.conversationId, + messages: [], + hasMore: false, + replace: true, + }) + this.post({ type: "kiloclaw.conversationStatus", status: null }) + } + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + this.conversations = this.conversations.map((c) => + c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c, + ) + this.broadcastConversations({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("bot.status", (ctx, e: BotStatusEvent) => { + if (!this.sandboxId || ctx !== `/kiloclaw/${this.sandboxId}`) return + if (e.sandboxId !== this.sandboxId) return + this.botStatus = { online: e.online, at: e.at, updatedAt: Date.now() } + this.post({ type: "kiloclaw.botStatus", status: this.botStatus }) + }), + ) + + // ── Conversation-scoped events ──────────────────────────────────── + + this.chatSubs.push( + events.on("message.created", (ctx, e: MessageCreatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + // Skip if already in cache (race with HTTP response) + if (this.messages.some((m) => m.id === e.messageId)) return + const server = this.toMessageFromCreated(e) + // Reconcile optimistic message via clientId — send the full server + // message so the webview replaces content, not just the id. Without + // this, the webview would display stale client-side content under + // the new id until the next full broadcast. + if (e.clientId) { + const pending = `pending-${e.clientId}` + const idx = this.messages.findIndex((m) => m.id === pending) + if (idx !== -1) { + this.messages = this.messages.map((m, i) => (i === idx ? server : m)) + this.post({ + type: "kiloclaw.messageReplaced", + conversationId: this.activeConversationId ?? "", + pendingId: pending, + message: server, + }) + return + } + } + this.messages = [...this.messages, server] + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.updated", (ctx, e: MessageUpdatedEvent) => { + if (ctx !== this.subscribedConversationContext) return + const idx = this.messages.findIndex((m) => m.id === e.messageId) + if (idx === -1) return + this.messages = this.messages.map((m, i) => + i === idx ? { ...m, content: e.content, clientUpdatedAt: e.clientUpdatedAt } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.deleted", (ctx, e: MessageDeletedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("message.delivery_failed", (ctx, e: MessageDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => (m.id === e.messageId ? { ...m, deliveryFailed: true } : m)) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("action.delivery_failed", (ctx, e: ActionDeliveryFailedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => { + if (m.id !== e.messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== e.groupId) return b + return { ...b, resolved: undefined } + }), + } + }) + this.broadcastMessages({ replace: true }) + this.post({ type: "kiloclaw.error", error: "Couldn't reach the bot — please try again" }) + }), + ) + + this.chatSubs.push( + events.on("reaction.added", (ctx, e: ReactionAddedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionAdded(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("reaction.removed", (ctx, e: ReactionRemovedEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.messages = this.messages.map((m) => + m.id === e.messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, e.emoji, e.memberId) } : m, + ) + this.broadcastMessages({ replace: true }) + }), + ) + + this.chatSubs.push( + events.on("typing", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (this.currentUserId && e.memberId === this.currentUserId) return + this.upsertTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("typing.stop", (ctx, e: TypingEvent) => { + if (ctx !== this.subscribedConversationContext) return + this.removeTypingMember(e.memberId) + }), + ) + + this.chatSubs.push( + events.on("conversation.status", (ctx, e: ConversationStatusEvent) => { + if (ctx !== this.subscribedConversationContext) return + if (e.conversationId !== this.activeConversationId) return + this.conversationStatus = { + conversationId: e.conversationId, + contextTokens: e.contextTokens, + contextWindow: e.contextWindow, + model: e.model, + provider: e.provider, + at: e.at, + updatedAt: Date.now(), + } + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + }), + ) } - private disconnectChat(): void { - for (const unsub of this.chatSubs) unsub() - this.chatSubs = [] + private async refreshOnReconnect(): Promise { + if (!this.chat || !this.sandboxId) return + await this.refreshConversations() + if (this.activeConversationId) { + await this.refreshActiveMessages() + } + } - if (this.chat) { - this.chat.disconnect().catch((err) => { - console.error("[Kilo New] KiloClaw disconnect failed:", err?.message ?? err) - }) - this.chat = null + // ── Mutations ─────────────────────────────────────────────────────── + + private async selectConversation(conversationId: string): Promise { + if (!this.chat) return + this.activeConversationId = conversationId + this.subscribeConversationContext(conversationId) + this.post({ type: "kiloclaw.activeConversation", conversationId }) + this.typingMembers = [] + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + await this.refreshActiveMessages() + + try { + const res = await this.chat.getConversationStatus(conversationId) + // The user may have switched conversations while we awaited the fetch; + // only apply the status if it still matches the active conversation. + if (this.activeConversationId !== conversationId) return + this.conversationStatus = res.status ?? null + this.post({ type: "kiloclaw.conversationStatus", status: this.conversationStatus }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw getConversationStatus failed:", message) + return } - this.connected = false - this.online = false + if (this.activeConversationId !== conversationId) return + void this.markRead(conversationId) } - private async sendChat(text: string): Promise { - if (!this.chat) { - this.post({ type: "kiloclaw.error", error: "Chat not connected" }) - return + private async createConversation(title?: string): Promise { + if (!this.chat || !this.sandboxId) return + try { + // The server now returns the full conversation snapshot alongside + // `conversationId`. We rely on `refreshConversations()` to pick up the + // canonical list-item shape rather than mapping the detail payload here. + const res = await this.chat.createConversation({ sandboxId: this.sandboxId, title }) + await this.refreshConversations() + await this.selectConversation(res.conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to create conversation") }) + } + } + + private async renameConversation(conversationId: string, title: string): Promise { + if (!this.chat) return + this.conversations = this.conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c)) + this.broadcastConversations({ replace: true }) + try { + await this.chat.renameConversation(conversationId, title) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to rename conversation") }) + void this.refreshConversations() + } + } + + private async leaveConversation(conversationId: string): Promise { + if (!this.chat) return + try { + await this.chat.leaveConversation(conversationId) + // Optimistic removal — server will also fire conversation.left. + this.conversations = this.conversations.filter((c) => c.conversationId !== conversationId) + if (this.activeConversationId === conversationId) { + this.activeConversationId = null + this.unsubscribeConversationContext() + this.messages = [] + this.post({ type: "kiloclaw.activeConversation", conversationId: null }) + this.post({ type: "kiloclaw.messages", conversationId, messages: [], hasMore: false, replace: true }) + } + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to leave conversation") }) + } + } + + private async loadMoreConversations(): Promise { + if (!this.chat || !this.sandboxId || !this.hasMoreConversations || !this.conversationsCursor) return + const target = this.sandboxId + const cursor = this.conversationsCursor + try { + const res = await this.chat.listConversations({ + sandboxId: target, + limit: CONVERSATIONS_PAGE, + cursor, + }) + // Sandbox could have changed (reauth / cleanup). Also a newer refresh + // may have already moved the cursor — skip merging stale results. + if (this.sandboxId !== target || this.conversationsCursor !== cursor) return + this.conversations = mergeConversations(this.conversations, res.conversations) + this.conversationsCursor = res.nextCursor + this.hasMoreConversations = res.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load conversations") }) + } + } + + private async sendMessage( + conversationId: string, + content: ContentBlock[], + inReplyToMessageId?: string, + ): Promise { + if (!this.chat) return + if (!this.currentUserId) return + + // kilo-chat validates clientId as a ULID (Crockford Base32); generate + // it here so the webview doesn't need to know the format. + const clientId = ulid() + const pendingId = `pending-${clientId}` + const optimistic: Message = { + id: pendingId, + senderId: this.currentUserId, + content, + inReplyToMessageId: inReplyToMessageId ?? null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + + if (conversationId === this.activeConversationId) { + this.messages = [...this.messages, optimistic] + this.post({ type: "kiloclaw.messageOptimistic", conversationId, message: optimistic }) + } + + try { + await this.chat.sendMessage({ conversationId, content, clientId, inReplyToMessageId }) + // Server will fire `message.created` — reconciliation happens there. + } catch (err) { + console.error("[Kilo New] KiloClaw sendMessage failed:", err instanceof Error ? err.message : err) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to send message") }) + if (conversationId === this.activeConversationId) { + this.messages = this.messages.filter((m) => m.id !== pendingId) + this.post({ type: "kiloclaw.messageRemoved", conversationId, messageId: pendingId }) + } + } + } + + private async editMessage(conversationId: string, messageId: string, content: ContentBlock[]): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, content, clientUpdatedAt: Date.now() } : m, + ) + this.broadcastMessages({ replace: true }) } try { - await this.chat.send(text) + await this.chat.editMessage(messageId, { conversationId, content, timestamp: Date.now() }) } catch (err) { - console.error("[Kilo New] KiloClaw send failed:", err instanceof Error ? err.message : err) - this.post({ type: "kiloclaw.error", error: "Failed to send message" }) + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to edit message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } } } + private async deleteMessage(conversationId: string, messageId: string): Promise { + if (!this.chat) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? { ...m, deleted: true } : m)) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.deleteMessage(messageId, conversationId) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to delete message") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async loadMoreMessages(conversationId: string, before: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + const res = await this.chat.listMessages(conversationId, { before, limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only merge if the active conversation is still the same. + if (this.activeConversationId !== conversationId) return + const sorted = sortMessagesAscending(res.messages) + this.messages = mergeMessages(sorted, this.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to load messages") }) + } + } + + private async addReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionAdded(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.addReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to add reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async removeReaction(conversationId: string, messageId: string, emoji: string): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => + m.id === messageId ? { ...m, reactions: applyReactionRemoved(m.reactions, emoji, this.currentUserId!) } : m, + ) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.removeReaction(messageId, { conversationId, emoji }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to remove reaction") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async executeAction( + conversationId: string, + messageId: string, + groupId: string, + value: ExecApprovalDecision, + ): Promise { + if (!this.chat || !this.currentUserId) return + const snapshot = this.messages.find((m) => m.id === messageId) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => { + if (m.id !== messageId) return m + return { + ...m, + content: m.content.map((b) => { + if (b.type !== "actions") return b + if (b.groupId !== groupId) return b + return { ...b, resolved: { value, resolvedBy: this.currentUserId!, resolvedAt: Date.now() } } + }), + } + }) + this.broadcastMessages({ replace: true }) + } + try { + await this.chat.executeAction(conversationId, messageId, { groupId, value }) + } catch (err) { + this.post({ type: "kiloclaw.error", error: this.formatError(err, "Failed to execute action") }) + if (snapshot && conversationId === this.activeConversationId) { + this.messages = this.messages.map((m) => (m.id === messageId ? snapshot : m)) + this.broadcastMessages({ replace: true }) + } + } + } + + private async sendTyping(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTyping(conversationId) + } catch (err) { + // Typing is fire-and-forget; don't surface errors. + void err + } + } + + private async sendTypingStop(conversationId: string): Promise { + if (!this.chat || conversationId !== this.activeConversationId) return + try { + await this.chat.sendTypingStop(conversationId) + } catch (err) { + void err + } + } + + private async markRead(conversationId: string): Promise { + if (!this.chat) return + if (conversationId !== this.activeConversationId) return + // The mark-read endpoint requires `lastSeenMessageId`. With no messages + // loaded there is nothing to mark — silently skip. + const last = lastNonPendingMessageId(this.messages) + if (!last) return + try { + await this.chat.markConversationRead(conversationId, { lastSeenMessageId: last }) + } catch (err) { + void err + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private async refreshConversations(): Promise { + if (!this.chat) return + const target = this.sandboxId + if (!target) return + try { + const list = await this.chat.listConversations({ sandboxId: target, limit: CONVERSATIONS_PAGE }) + // Defensive: sandbox could theoretically change during the fetch + // (cleanup or reauth). Skip the write if so. + if (this.sandboxId !== target) return + this.conversations = list.conversations + this.conversationsCursor = list.nextCursor + this.hasMoreConversations = list.hasMore + this.broadcastConversations({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshConversations failed:", message) + } + } + + private async refreshActiveMessages(): Promise { + if (!this.chat) return + const target = this.activeConversationId + if (!target) return + try { + const res = await this.chat.listMessages(target, { limit: MESSAGES_PAGE }) + // The user may have switched conversations while we awaited the fetch; + // only apply the messages if the active conversation is still `target`. + if (this.activeConversationId !== target) return + this.messages = sortMessagesAscending(res.messages) + this.hasMoreMessages = res.hasMore + this.broadcastMessages({ replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] KiloClaw refreshActiveMessages failed:", message) + } + } + + private upsertTypingMember(memberId: string): void { + const now = Date.now() + const idx = this.typingMembers.findIndex((m) => m.memberId === memberId) + if (idx === -1) { + this.typingMembers = [...this.typingMembers, { memberId, at: now }] + } else { + this.typingMembers = this.typingMembers.map((m, i) => (i === idx ? { ...m, at: now } : m)) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typing", conversationId: this.activeConversationId, memberId }) + } + const existing = this.typingTimers.get(memberId) + if (existing) clearTimeout(existing) + this.typingTimers.set( + memberId, + setTimeout(() => this.removeTypingMember(memberId), TYPING_TIMEOUT_MS), + ) + } + + private removeTypingMember(memberId: string): void { + this.typingMembers = this.typingMembers.filter((m) => m.memberId !== memberId) + const t = this.typingTimers.get(memberId) + if (t) { + clearTimeout(t) + this.typingTimers.delete(memberId) + } + if (this.activeConversationId) { + this.post({ type: "kiloclaw.typingStop", conversationId: this.activeConversationId, memberId }) + } + } + + private toMessageFromCreated(e: MessageCreatedEvent): Message { + return { + id: e.messageId, + senderId: e.senderId, + content: e.content, + inReplyToMessageId: e.inReplyToMessageId, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + } + } + + private broadcastConversations(opts: { replace: boolean }): void { + this.post({ + type: "kiloclaw.conversations", + conversations: this.conversations, + hasMore: this.hasMoreConversations, + replace: opts.replace, + }) + } + + private broadcastMessages(opts: { replace: boolean }): void { + if (!this.activeConversationId) return + this.post({ + type: "kiloclaw.messages", + conversationId: this.activeConversationId, + messages: this.messages, + hasMore: this.hasMoreMessages, + replace: opts.replace, + }) + } + + private formatError(err: unknown, fallback: string): string { + if (err instanceof KiloChatApiError) { + const body = err.body as Record | null + if (body && typeof body.error === "string") return body.error + } + if (err instanceof Error) return err.message || fallback + return fallback + } + + // ── Polling / nudges / cleanup ────────────────────────────────────── + private startPolling(): void { if (this.timer) return this.timer = setInterval(() => void this.poll(), STATUS_POLL_MS) @@ -374,6 +1152,22 @@ export class KiloClawProvider implements vscode.Disposable { this.timer = null } + private startBotNudge(): void { + if (this.botNudge) return + this.botNudge = setInterval(() => { + if (!this.chat || !this.sandboxId) return + this.chat.requestBotStatus(this.sandboxId).catch((err) => { + console.debug("[Kilo New] KiloClaw requestBotStatus failed:", (err as Error)?.message ?? err) + }) + }, BOT_STATUS_NUDGE_MS) + } + + private stopBotNudge(): void { + if (!this.botNudge) return + clearInterval(this.botNudge) + this.botNudge = null + } + private async poll(): Promise { try { const client = this.connection.getClient() @@ -387,33 +1181,115 @@ export class KiloClawProvider implements vscode.Disposable { } } - /** Subscribe to connection state changes and re-run init() once connected. */ - private waitForConnection(): void { - const unsub = this.connection.onStateChange((state) => { - if (state === "connected" && !this.disposed) { - unsub() - this.initializing = false - void this.init() - } - }) - this.subs.push(unsub) - } - private cleanup(): void { this.generation++ for (const unsub of this.subs) unsub() this.subs = [] + for (const unsub of this.chatSubs) unsub() + this.chatSubs = [] - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } + this.stopPolling() + this.stopBotNudge() - this.disconnectChat() + for (const t of this.typingTimers.values()) clearTimeout(t) + this.typingTimers.clear() + + this.events?.disconnect() + this.events = null + this.chat = null + this.tokens?.clear() + this.tokens = null + + this.subscribedSandboxContext = null + this.subscribedConversationContext = null this.messages = [] + this.conversations = [] + this.conversationsCursor = null + this.hasMoreConversations = false + this.activeConversationId = null + this.hasMoreMessages = false + this.botStatus = null + this.conversationStatus = null + this.typingMembers = [] this.initializing = false this.status = null + this.currentUserId = null + this.sandboxId = null } } + +// ── Pure helpers ────────────────────────────────────────────────────── + +function applyReactionAdded( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + const existing = reactions.find((r) => r.emoji === emoji) + if (existing) { + if (existing.memberIds.includes(memberId)) return reactions + return reactions.map((r) => + r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r, + ) + } + return [...reactions, { emoji, count: 1, memberIds: [memberId] }] +} + +function applyReactionRemoved( + reactions: { emoji: string; count: number; memberIds: string[] }[], + emoji: string, + memberId: string, +): { emoji: string; count: number; memberIds: string[] }[] { + return reactions + .map((r) => { + if (r.emoji !== emoji) return r + const memberIds = r.memberIds.filter((id) => id !== memberId) + return { ...r, count: memberIds.length, memberIds } + }) + .filter((r) => r.count > 0) +} + +/** Merge two ascending-sorted message arrays by id, keeping the most recent updates. */ +function mergeMessages(older: Message[], newer: Message[]): Message[] { + const seen = new Map() + for (const m of older) seen.set(m.id, m) + for (const m of newer) seen.set(m.id, m) + return [...seen.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +function mergeConversations( + existing: ConversationListItem[], + incoming: ConversationListItem[], +): ConversationListItem[] { + const seen = new Map() + for (const c of existing) seen.set(c.conversationId, c) + for (const c of incoming) seen.set(c.conversationId, c) + return [...seen.values()].sort((a, b) => { + const ax = a.lastActivityAt ?? a.joinedAt + const bx = b.lastActivityAt ?? b.joinedAt + return bx - ax + }) +} + +/** listMessages returns newest-first; the UI renders oldest-first. */ +function sortMessagesAscending(messages: Message[]): Message[] { + return [...messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} + +/** + * Pick the latest server-confirmed message id from an ascending list. Pending + * (optimistic) messages use `pending-` ids that the server doesn't + * recognise, so they're skipped — the new mark-read contract requires a real + * message id. + */ +function lastNonPendingMessageId(messages: Message[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (!m) continue + if (m.id.startsWith("pending-")) continue + return m.id + } + return null +} diff --git a/packages/kilo-vscode/src/kiloclaw/chat-client.ts b/packages/kilo-vscode/src/kiloclaw/chat-client.ts deleted file mode 100644 index b1f15e4108a..00000000000 --- a/packages/kilo-vscode/src/kiloclaw/chat-client.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * KiloClaw Stream Chat client wrapper for the VS Code extension host. - * - * Port of packages/opencode/src/kilocode/claw/client.ts adapted for Node.js. - * No Bun patches needed — the extension host runs in standard Node.js. - * stream-chat resolves to its Node.js CJS entry point automatically. - */ - -import type { Channel, Event } from "stream-chat" -import type { ChatCredentials, ChatMessage } from "./types" - -export type ClawChatClient = { - channel: Channel - disconnect: () => Promise - send: (text: string) => Promise - onMessage: (cb: (msg: ChatMessage) => void) => () => void - onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void - onPresence: (cb: (online: boolean) => void) => () => void -} - -function botId(creds: ChatCredentials): string { - return `bot-${creds.channelId.replace(/^default-/, "")}` -} - -function toMessage(raw: Record, bot: string): ChatMessage { - const user = raw.user as Record | undefined - const uid = (user?.id as string) ?? (raw.user_id as string) ?? "" - return { - id: (raw.id as string) ?? "", - text: (raw.text as string) ?? "", - user: uid, - created: raw.created_at ? new Date(raw.created_at as string).toISOString() : new Date().toISOString(), - bot: uid === bot, - } -} - -export async function connect(creds: ChatCredentials): Promise { - const { StreamChat } = await import("stream-chat") - // Use a fresh instance instead of the singleton to avoid stale state - // (cached channels, event listeners) when credentials rotate. - const client = new StreamChat(creds.apiKey) - - await client.connectUser({ id: creds.userId }, creds.userToken) - - const channel = client.channel("messaging", creds.channelId) - try { - await channel.watch({ presence: true }) - } catch (err) { - // Disconnect the user to avoid leaking a partial connection - await client.disconnectUser().catch(() => {}) - throw err - } - - const bot = botId(creds) - - return { - channel, - async disconnect() { - await client.disconnectUser() - }, - async send(text: string) { - await channel.sendMessage({ text }) - }, - onMessage(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.new", handler) - return () => channel.off("message.new", handler) - }, - onMessageUpdated(cb) { - const handler = (event: Event) => { - if (event.message) cb(toMessage(event.message as unknown as Record, bot)) - } - channel.on("message.updated", handler) - return () => channel.off("message.updated", handler) - }, - onPresence(cb) { - const handler = (event: Event) => { - if (event.user?.id === bot) { - cb(event.user.online ?? false) - } - } - client.on("user.presence.changed", handler) - return () => client.off("user.presence.changed", handler) - }, - } -} - -export function history(channel: Channel, bot: string): ChatMessage[] { - const state = channel.state.messages - return state.map((raw) => toMessage(raw as unknown as Record, bot)) -} - -export function presence(channel: Channel, bot: string): boolean { - const member = channel.state.members?.[bot] - return !!member?.user?.online -} diff --git a/packages/kilo-vscode/src/kiloclaw/event-service-client.ts b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts new file mode 100644 index 00000000000..fea08acbc43 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/event-service-client.ts @@ -0,0 +1,384 @@ +/** + * Event Service WebSocket client for the VS Code extension host. + * + * Minimal inline port of `@kilocode/event-service` (cloud monorepo). Connects + * to the kilo events Cloudflare Worker using a two-step ticket flow: + * 1. POST `/connect-ticket` with `Authorization: Bearer ` to mint a + * single-use ticket (30 s TTL). + * 2. Open WebSocket to `/connect?ticket=` with subprotocol + * `kilo.events.v1`. + * + * Runs in Node.js (the VS Code extension host). `WebSocket` is available in + * Node 22+ without any import, matching the environment used elsewhere in + * this extension (see `src/services/cli-backend/sdk-sse-adapter.ts`). + */ + +import type { KiloChatEventMap, KiloChatEventName } from "./types" + +const WS_SUBPROTOCOL = "kilo.events.v1" +const HANDSHAKE_TIMEOUT_MS = 10_000 +const PING_INTERVAL_MS = 15_000 +const TICKET_FETCH_TIMEOUT_MS = 10_000 + +export class WebSocketAuthError extends Error { + constructor(message = "WebSocket authentication failed") { + super(message) + this.name = "WebSocketAuthError" + } +} + +export class WebSocketConnectError extends Error { + constructor( + message: string, + public readonly code: number, + ) { + super(message) + this.name = "WebSocketConnectError" + } +} + +export class HandshakeTimeoutError extends Error { + constructor() { + super("WebSocket handshake timed out") + this.name = "HandshakeTimeoutError" + } +} + +// Close codes that signal the server rejected us for auth/policy reasons +// and reconnecting with the same token is pointless. Everything else +// (including 1006 "abnormal closure" from flaky networks) is transient. +function isAuthCloseCode(code: number): boolean { + if (code === 1008) return true // Policy Violation + if (code === 4401 || code === 4403) return true // Custom auth rejection + return false +} + +export type EventHandler = (context: string, payload: unknown) => void + +export type EventServiceConfig = { + url: string + getToken: () => Promise + onUnauthorized?: () => void +} + +/** + * The event-service base URL is configured as a WebSocket URL (`wss://…` / + * `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip + * the trailing slash and swap the protocol so `fetch()` accepts the URL. + */ +function toHttpBase(wsBase: string): string { + const trimmed = wsBase.replace(/\/$/, "") + if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6) + if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5) + return trimmed +} + +export class EventServiceClient { + private readonly url: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + + private ws: WebSocket | null = null + private connected = false + private destroyed = false + private reconnectAttempts = 0 + private hasConnectedBefore = false + private reconnectTimer: ReturnType | null = null + private pingTimer: ReturnType | null = null + private handshakeTimer: ReturnType | null = null + private abortHandshake: ((err: Error) => void) | null = null + + private eventHandlers = new Map>() + private activeContexts = new Set() + private reconnectHandlers = new Set<() => void>() + + constructor(config: EventServiceConfig) { + this.url = config.url + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + async connect(): Promise { + this.destroyed = false + this.reconnectAttempts = 0 + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + try { + await this.connectOnce() + } catch (err) { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + } + } + + disconnect(): void { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.clearHandshakeTimer() + if (this.abortHandshake) { + this.abortHandshake(new Error("disconnected")) + } + if (this.ws) { + this.ws.close() + this.ws = null + } + this.stopPing() + this.connected = false + } + + isConnected(): boolean { + return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + subscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.add(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.subscribe", contexts }) + } + } + + unsubscribe(contexts: string[]): void { + for (const ctx of contexts) this.activeContexts.delete(ctx) + if (this.isConnected()) { + this.sendJson({ type: "context.unsubscribe", contexts }) + } + } + + on(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void { + const set = this.eventHandlers.get(event) ?? new Set() + // The raw dispatcher receives `unknown` payloads; the caller supplied a + // typed handler. We trust server payloads here — they're validated at the + // kilo-chat worker edge before broadcast. + const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N]) + set.add(wrapped) + this.eventHandlers.set(event, set) + return () => { + set.delete(wrapped) + if (set.size === 0) this.eventHandlers.delete(event) + } + } + + onReconnect(handler: () => void): () => void { + this.reconnectHandlers.add(handler) + return () => this.reconnectHandlers.delete(handler) + } + + // ── private ──────────────────────────────────────────────────────── + + private handleAuthFailure(err: unknown): boolean { + if (err instanceof WebSocketAuthError) { + this.destroyed = true + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.onUnauthorized?.() + return true + } + return false + } + + private async connectOnce(): Promise { + if (this.ws) { + const old = this.ws + this.ws = null + old.close() + } + + const token = await this.getToken() + const ticket = await this.fetchTicket(token) + + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL]) + this.ws = ws + + let settled = false + const settleResolve = () => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + resolve() + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + this.clearHandshakeTimer() + this.abortHandshake = null + reject(err) + } + this.abortHandshake = settleReject + + this.handshakeTimer = setTimeout(() => { + this.handshakeTimer = null + if (this.ws === ws) ws.close(1000, "handshake-timeout") + settleReject(new HandshakeTimeoutError()) + }, HANDSHAKE_TIMEOUT_MS) + + ws.addEventListener("open", () => { + const isReconnect = this.hasConnectedBefore + this.connected = true + this.hasConnectedBefore = true + this.reconnectAttempts = 0 + this.resubscribeContexts() + if (isReconnect) { + for (const h of this.reconnectHandlers) h() + } + settleResolve() + this.startPing() + }) + + ws.addEventListener("message", (event: MessageEvent) => { + this.handleMessage(String(event.data)) + }) + + ws.addEventListener("close", (event: CloseEvent) => { + if (this.ws !== ws) return + const wasConnected = this.connected + this.connected = false + this.stopPing() + this.clearHandshakeTimer() + // A handshake failure always fires `close` after `error`, so we + // settle here with a classification based on the close code: + // explicit auth/policy codes → fatal; anything else → transient + // and the caller (`connect`) will schedule a reconnect. + if (!wasConnected) { + if (isAuthCloseCode(event.code)) { + settleReject(new WebSocketAuthError()) + } else { + settleReject( + new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code), + ) + } + return + } + if (!this.destroyed) this.scheduleReconnect() + }) + + ws.addEventListener("error", () => { + // Swallowed: the `close` event fires right after and carries the + // close code we need to distinguish auth failures from network + // blips. Settling here loses that context. + }) + }) + } + + /** + * Mint a single-use connection ticket. The event-service issues a 30 s ticket + * scoped to the bearer JWT; the WebSocket upgrade then consumes it. We + * surface 401/403 as `WebSocketAuthError` so the caller can drop the cached + * token and prompt re-auth. + * + * `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only + * accepts `http(s)`, so we rewrite the protocol before the HTTP call. + */ + private async fetchTicket(token: string): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS) + try { + const res = await fetch(toHttpBase(this.url) + "/connect-ticket", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + signal: ctrl.signal, + }) + if (res.status === 401 || res.status === 403) { + throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`) + } + if (!res.ok) { + throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status) + } + const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null + if (!body || typeof body.ticket !== "string" || !body.ticket) { + throw new WebSocketConnectError("Malformed event-service ticket response", 0) + } + return body.ticket + } catch (err) { + if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err + if ((err as { name?: string })?.name === "AbortError") { + throw new HandshakeTimeoutError() + } + throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0) + } finally { + clearTimeout(timer) + } + } + + private clearHandshakeTimer(): void { + if (this.handshakeTimer !== null) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + private sendJson(msg: unknown): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)) + } + } + + private handleMessage(data: string): void { + if (data === "pong") return + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return + } + if (!parsed || typeof parsed !== "object") return + const m = parsed as Record + if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") { + const handlers = this.eventHandlers.get(m.event) + if (handlers) { + for (const h of handlers) h(m.context, m.payload) + } + return + } + if (m.type === "error") { + console.warn("[Kilo New] event-service server error", m) + } + } + + private startPing(): void { + this.stopPing() + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send("ping") + } + }, PING_INTERVAL_MS) + } + + private stopPing(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + } + + private resubscribeContexts(): void { + if (this.activeContexts.size > 0) { + this.sendJson({ + type: "context.subscribe", + contexts: Array.from(this.activeContexts), + }) + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts) + const delay = base * (0.5 + Math.random() * 0.5) + this.reconnectAttempts++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connectOnce().catch((err) => { + if (this.handleAuthFailure(err)) return + if (!this.destroyed) this.scheduleReconnect() + }) + }, delay) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts new file mode 100644 index 00000000000..5583b32af51 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -0,0 +1,270 @@ +/** + * HTTP client for the kilo-chat Cloudflare Worker. + * + * Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo) tailored + * to what the VS Code extension needs: conversation list + details, message + * CRUD, reactions, typing, and action execution. No zod runtime validation — + * the kilo-chat worker is the source of truth and validates at its edge. + */ + +import type { + BotStatusRecord, + ContentBlock, + ConversationDetail, + ConversationListItem, + ConversationStatusRecord, + ExecApprovalDecision, + Message, +} from "./types" + +export type KiloChatClientConfig = { + baseUrl: string + getToken: () => Promise + onUnauthorized?: () => void +} + +export class KiloChatApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) + this.name = "KiloChatApiError" + } +} + +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + // Fall back to a compact JSON dump so validation errors (zod issues, etc.) + // show up in the extension's Output channel without a separate logging hop. + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + +type HttpOpts = { + method?: string + body?: unknown + query?: Record +} + +// Per-conversation send queues. sendMessage chains onto the tail of its +// conversation's queue so concurrent callers can't race ahead and get a lower +// server-assigned ULID than a later send. +type SendQueue = Map> + +export class KiloChatClient { + private readonly baseUrl: string + private readonly getToken: () => Promise + private readonly onUnauthorized: (() => void) | undefined + private readonly sendQueues: SendQueue = new Map() + + constructor(config: KiloChatClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, "") + this.getToken = config.getToken + this.onUnauthorized = config.onUnauthorized + } + + // ── Conversations ──────────────────────────────────────────────── + + listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{ + conversations: ConversationListItem[] + hasMore: boolean + nextCursor: string | null + }> { + return this.request("/v1/conversations", { + query: { + sandboxId: opts?.sandboxId, + limit: opts?.limit, + cursor: opts?.cursor ?? undefined, + }, + }) + } + + getConversation(conversationId: string): Promise { + return this.request(`/v1/conversations/${conversationId}`) + } + + createConversation(req: { + sandboxId: string + title?: string + }): Promise<{ conversationId: string; conversation?: ConversationDetail }> { + return this.request("/v1/conversations", { method: "POST", body: req }) + } + + renameConversation(conversationId: string, title: string): Promise<{ ok: true }> { + return this.request(`/v1/conversations/${conversationId}`, { + method: "PATCH", + body: { title }, + }) + } + + async leaveConversation(conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/conversations/${conversationId}/leave`, { method: "POST" }) + } + + /** + * Mark messages up to `lastSeenMessageId` as read for the current user. + * The server enforces monotonic `lastReadAt` and returns whether the read + * pointer advanced plus whether the badge bucket was cleared. + */ + markConversationRead( + conversationId: string, + req: { lastSeenMessageId: string }, + ): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> { + return this.request(`/v1/conversations/${conversationId}/mark-read`, { + method: "POST", + body: req, + }) + } + + // ── Messages ───────────────────────────────────────────────────── + + sendMessage(req: { + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + clientId?: string + }): Promise<{ messageId: string; clientId?: string; message?: Message }> { + const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve() + const send = () => + this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", { + method: "POST", + body: req, + }) + const next = prev.then(send, send) + this.sendQueues.set(req.conversationId, next) + const cleanup = () => { + if (this.sendQueues.get(req.conversationId) === next) { + this.sendQueues.delete(req.conversationId) + } + } + void next.then(cleanup, cleanup) + return next + } + + editMessage( + messageId: string, + req: { conversationId: string; content: ContentBlock[]; timestamp: number }, + ): Promise<{ messageId?: string; message?: Message }> { + return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req }) + } + + async deleteMessage(messageId: string, conversationId: string): Promise { + // Returns 200 JSON with `{ ok }`-style payload; we don't need the body. + await this.request(`/v1/messages/${messageId}`, { + method: "DELETE", + query: { conversationId }, + }) + } + + listMessages( + conversationId: string, + opts?: { before?: string; limit?: number }, + ): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> { + return this.request(`/v1/conversations/${conversationId}/messages`, { + query: { before: opts?.before, limit: opts?.limit }, + }) + } + + executeAction( + conversationId: string, + messageId: string, + req: { groupId: string; value: ExecApprovalDecision }, + ): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> { + return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, { + method: "POST", + body: req, + }) + } + + // ── Reactions ──────────────────────────────────────────────────── + + addReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ id: string; operationId?: string }> { + return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req }) + } + + async removeReaction( + messageId: string, + req: { conversationId: string; emoji: string }, + ): Promise<{ removed: boolean; id: string | null; operationId?: string }> { + return this.request<{ removed: boolean; id: string | null; operationId?: string }>( + `/v1/messages/${messageId}/reactions`, + { + method: "DELETE", + query: req, + }, + ) + } + + // ── Typing ─────────────────────────────────────────────────────── + + async sendTyping(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing`, { method: "POST" }) + } + + async sendTypingStop(conversationId: string): Promise { + await this.request(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" }) + } + + // ── Bot / conversation status ──────────────────────────────────── + + getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> { + return this.request(`/v1/sandboxes/${sandboxId}/bot-status`) + } + + async requestBotStatus(sandboxId: string): Promise { + await this.request(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" }) + } + + getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> { + return this.request(`/v1/conversations/${conversationId}/conversation-status`) + } + + // ── private ────────────────────────────────────────────────────── + + private async request(path: string, opts: HttpOpts = {}): Promise { + const token = await this.getToken() + let url = `${this.baseUrl}${path}` + + if (opts.query) { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue + params.set(k, String(v)) + } + const qs = params.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { Authorization: `Bearer ${token}` } + if (opts.body !== undefined) headers["Content-Type"] = "application/json" + + const res = await fetch(url, { + method: opts.method ?? "GET", + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }) + + if (!res.ok) { + if (res.status === 401 || res.status === 403) this.onUnauthorized?.() + const body: unknown = await res.json().catch(() => null) + throw new KiloChatApiError(res.status, body) + } + + if (res.status === 204) return undefined as unknown as T + return (await res.json()) as T + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/token-manager.ts b/packages/kilo-vscode/src/kiloclaw/token-manager.ts new file mode 100644 index 00000000000..745b9c7c3f8 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/token-manager.ts @@ -0,0 +1,112 @@ +/** + * KiloChat access-token cache. + * + * Mirrors the web client pattern (see `apps/web/src/app/(app)/claw/kilo-chat/token.ts` + * in the cloud monorepo). The token is minted by the Kilo gateway + * (`kilo.claw.chatCredentials`) and kept in memory with a 5-minute freshness + * buffer. The gateway operation is a historical name — it used to return + * Stream Chat credentials and was repurposed when Kilo migrated to its own + * kilo-chat service. + * + * Concurrent callers share the same inflight fetch so we never double-issue. + * A short retry cooldown prevents tight loops when the gateway is flaky. + */ + +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { ChatToken } from "./types" + +const FRESHNESS_BUFFER_MS = 5 * 60 * 1000 +const RETRY_BACKOFF_MS = 5_000 + +export class TokenManager { + private cached: ChatToken | null = null + private expiresAtMs = 0 + private inflight: Promise | null = null + private lastFailedAt = 0 + + constructor(private readonly getClient: () => KiloClient | null) {} + + /** Latest resolved token info (may be stale). Used for URL extraction. */ + peek(): ChatToken | null { + return this.cached + } + + /** Drop the cached token; next `get` will refetch. */ + clear(): void { + this.cached = null + this.expiresAtMs = 0 + this.lastFailedAt = 0 + this.inflight = null + } + + async get(): Promise { + const info = await this.getOrFetch() + return info.token + } + + /** Resolve the full token envelope (URLs + token + expiry). */ + async getOrFetch(): Promise { + if (this.cached && Date.now() < this.expiresAtMs - FRESHNESS_BUFFER_MS) { + return this.cached + } + if (this.lastFailedAt && Date.now() - this.lastFailedAt < RETRY_BACKOFF_MS) { + throw new Error("Kilo chat token fetch on cooldown after recent failure") + } + if (!this.inflight) { + this.inflight = this.fetch() + .then((info) => { + this.cached = info + this.expiresAtMs = new Date(info.expiresAt).getTime() + this.lastFailedAt = 0 + this.inflight = null + return info + }) + .catch((err) => { + this.lastFailedAt = Date.now() + this.inflight = null + throw err + }) + } + return this.inflight + } + + private async fetch(): Promise { + const client = this.getClient() + if (!client) throw new Error("Kilo backend not connected") + const res = await client.kilo.claw.chatCredentials() + const errResponse = (res as Record | null)?.error + if (!res || errResponse || !res.data) { + // Propagate the server's error detail when present so the extension's + // Output channel makes it obvious whether this is an auth problem, + // "no active instance" (404), or a transient 5xx. + const detail = this.formatErrorDetail(errResponse) + throw new Error(`kilo-chat credentials fetch failed${detail ? `: ${detail}` : ""}`) + } + const data = res.data as Partial + const missing: string[] = [] + if (!data.token) missing.push("token") + if (!data.expiresAt) missing.push("expiresAt") + if (!data.kiloChatUrl) missing.push("kiloChatUrl") + if (!data.eventServiceUrl) missing.push("eventServiceUrl") + if (missing.length > 0) { + throw new Error( + `Malformed kilo-chat credentials response: missing ${missing.join(", ")} (received keys: ${Object.keys(data).join(", ") || ""})`, + ) + } + return { + token: data.token!, + expiresAt: data.expiresAt!, + kiloChatUrl: data.kiloChatUrl!, + eventServiceUrl: data.eventServiceUrl!, + } + } + + private formatErrorDetail(err: unknown): string { + if (!err) return "" + if (typeof err === "string") return err + if (typeof err === "object" && err && "error" in err && typeof err.error === "string") { + return err.error + } + return JSON.stringify(err) + } +} diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index 51f4e28631f..20b796a67d4 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -3,14 +3,29 @@ * * Defines the postMessage protocol between the extension host (Node.js) * and the KiloClaw webview (SolidJS). The extension host owns all network - * connections (SDK + Stream Chat) and relays data to the webview. + * connections (Kilo Chat HTTP + event-service WebSocket) and relays data + * to the webview. * - * SYNC: Shared types (ClawStatus, ChatMessage, KiloClawState, KiloClawOutMessage) - * are mirrored in webview-ui/kiloclaw/lib/types.ts — keep both in sync. + * SYNC: Shared types are mirrored in webview-ui/kiloclaw/lib/types.ts — + * keep both in sync. */ +// ── Instance status (KiloClaw worker) ─────────────────────────────── + export type ClawStatus = { - status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null + // `recovering` and `restoring` are transitional states the worker reports + // while bringing an instance back from an unexpected stop or a snapshot + // restore (cloud: `services/kiloclaw/src/index.ts`). + status: + | "provisioned" + | "starting" + | "restarting" + | "recovering" + | "running" + | "stopped" + | "destroying" + | "restoring" + | null sandboxId?: string flyRegion?: string machineSize?: { cpus: number; memory_mb: number } @@ -19,23 +34,207 @@ export type ClawStatus = { lastStoppedAt?: string | null channelCount?: number secretCount?: number + userId?: string + botName?: string | null } -export type ChatCredentials = { - apiKey: string - userId: string - userToken: string - channelId: string +// ── Kilo Chat token envelope (gateway response) ───────────────────── + +export type ChatToken = { + token: string + expiresAt: string // ISO timestamp + kiloChatUrl: string + eventServiceUrl: string } -export type ChatMessage = { +// ── Kilo Chat content blocks ──────────────────────────────────────── +// Mirrors `@kilocode/kilo-chat` schemas. See cloud/packages/kilo-chat/src/schemas.ts. + +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny" + +export type TextBlock = { type: "text"; text: string } + +export type ActionItem = { + label: string + style: "primary" | "danger" | "secondary" + value: ExecApprovalDecision +} + +export type ActionsBlock = { + type: "actions" + groupId: string + actions: ActionItem[] + resolved?: { + value: ExecApprovalDecision + resolvedBy: string + resolvedAt: number + } +} + +export type ContentBlock = TextBlock | ActionsBlock + +// ── Kilo Chat reactions ───────────────────────────────────────────── + +export type ReactionSummary = { + emoji: string + count: number + memberIds: string[] +} + +// ── Kilo Chat message ─────────────────────────────────────────────── + +export type Message = { id: string - text: string - user: string - created: string // ISO string (serializable via postMessage) - bot: boolean + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + updatedAt: number | null + clientUpdatedAt: number | null + deleted: boolean + deliveryFailed: boolean + reactions: ReactionSummary[] } +// ── Conversations ─────────────────────────────────────────────────── + +export type ConversationListItem = { + conversationId: string + title: string | null + lastActivityAt: number | null + lastReadAt: number | null + joinedAt: number +} + +export type ConversationMember = { id: string; kind: "user" | "bot" } + +export type ConversationDetail = { + id: string + title: string | null + createdBy: string + createdAt: number + members: ConversationMember[] +} + +// ── Bot / conversation status (telemetry) ─────────────────────────── + +export type BotStatusRecord = { + online: boolean + at: number + updatedAt: number +} + +export type ConversationStatusRecord = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number + updatedAt: number +} + +// ── Typed Kilo Chat events (server → client) ─────────────────────── +// Event names mirror `@kilocode/kilo-chat/events`. + +/** + * Snapshot of the message that was replied to. Server includes this on + * `message.created` so clients can render a reply preview without a follow-up + * fetch. `deleted` mirrors the soft-deletion state at the time of replying. + */ +export type ReplyToSnapshot = { + messageId: string + senderId: string + content: ContentBlock[] + deleted?: boolean +} + +export type MessageCreatedEvent = { + messageId: string + senderId: string + content: ContentBlock[] + inReplyToMessageId: string | null + clientId?: string + replyTo?: ReplyToSnapshot | null +} + +export type MessageUpdatedEvent = { + messageId: string + content: ContentBlock[] + clientUpdatedAt: number | null +} + +export type MessageDeletedEvent = { messageId: string } +export type MessageDeliveryFailedEvent = { messageId: string } + +export type TypingEvent = { memberId: string } +export type TypingStopEvent = { memberId: string } + +export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } +export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string } + +/** + * Server fans out the full conversation snapshot on `conversation.created` so + * clients can append to their list without a follow-up fetch. Older servers may + * still send only the `conversationId`, so the snapshot is optional. + */ +export type ConversationCreatedEvent = { + conversationId: string + conversation?: ConversationListItem +} +export type ConversationRenamedEvent = { conversationId: string; title: string } +export type ConversationLeftEvent = { conversationId: string } +export type ConversationReadEvent = { conversationId: string; memberId: string; lastReadAt: number } +export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number } + +export type ActionExecutedEvent = { + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + executedBy: string +} +export type ActionDeliveryFailedEvent = { + conversationId: string + messageId: string + groupId: string +} + +export type BotStatusEvent = { sandboxId: string; online: boolean; at: number } +export type ConversationStatusEvent = { + conversationId: string + contextTokens: number + contextWindow: number + model: string | null + provider: string | null + at: number +} + +export type KiloChatEventMap = { + "message.created": MessageCreatedEvent + "message.updated": MessageUpdatedEvent + "message.deleted": MessageDeletedEvent + "message.delivery_failed": MessageDeliveryFailedEvent + typing: TypingEvent + "typing.stop": TypingStopEvent + "reaction.added": ReactionAddedEvent + "reaction.removed": ReactionRemovedEvent + "conversation.created": ConversationCreatedEvent + "conversation.renamed": ConversationRenamedEvent + "conversation.left": ConversationLeftEvent + "conversation.read": ConversationReadEvent + "conversation.activity": ConversationActivityEvent + "action.executed": ActionExecutedEvent + "action.delivery_failed": ActionDeliveryFailedEvent + "bot.status": BotStatusEvent + "conversation.status": ConversationStatusEvent +} + +export type KiloChatEventName = keyof KiloChatEventMap + +// ── Webview ↔ extension state ─────────────────────────────────────── + +export type TypingMember = { memberId: string; at: number } + // Full state snapshot pushed to the webview // Every phase carries `locale` so the webview can resolve translations immediately. export type KiloClawState = @@ -47,24 +246,65 @@ export type KiloClawState = phase: "ready" locale: string status: ClawStatus | null - connected: boolean - online: boolean - messages: ChatMessage[] + currentUserId: string + sandboxId: string + conversations: ConversationListItem[] + hasMoreConversations: boolean + activeConversationId: string | null + messages: Message[] + hasMoreMessages: boolean + botStatus: BotStatusRecord | null + conversationStatus: ConversationStatusRecord | null + typingMembers: TypingMember[] } -// Messages: Webview → Extension Host +// ── Messages: Webview → Extension Host ────────────────────────────── + export type KiloClawInMessage = | { type: "kiloclaw.ready" } - | { type: "kiloclaw.send"; text: string } | { type: "kiloclaw.openExternal"; url: string } + | { type: "kiloclaw.selectConversation"; conversationId: string } + | { type: "kiloclaw.createConversation"; title?: string } + | { type: "kiloclaw.renameConversation"; conversationId: string; title: string } + | { type: "kiloclaw.leaveConversation"; conversationId: string } + | { type: "kiloclaw.loadMoreConversations" } + | { + type: "kiloclaw.sendMessage" + conversationId: string + content: ContentBlock[] + inReplyToMessageId?: string + } + | { type: "kiloclaw.editMessage"; conversationId: string; messageId: string; content: ContentBlock[] } + | { type: "kiloclaw.deleteMessage"; conversationId: string; messageId: string } + | { type: "kiloclaw.loadMoreMessages"; conversationId: string; before: string } + | { type: "kiloclaw.addReaction"; conversationId: string; messageId: string; emoji: string } + | { type: "kiloclaw.removeReaction"; conversationId: string; messageId: string; emoji: string } + | { + type: "kiloclaw.executeAction" + conversationId: string + messageId: string + groupId: string + value: ExecApprovalDecision + } + | { type: "kiloclaw.sendTyping"; conversationId: string } + | { type: "kiloclaw.sendTypingStop"; conversationId: string } + | { type: "kiloclaw.markRead"; conversationId: string } + +// ── Messages: Extension Host → Webview ────────────────────────────── -// Messages: Extension Host → Webview export type KiloClawOutMessage = | { type: "kiloclaw.state"; state: KiloClawState } - | { type: "kiloclaw.message"; message: ChatMessage } - | { type: "kiloclaw.messageUpdated"; message: ChatMessage } - | { type: "kiloclaw.presence"; online: boolean } | { type: "kiloclaw.status"; data: ClawStatus | null } | { type: "kiloclaw.locale"; locale: string } | { type: "kiloclaw.error"; error: string } + | { type: "kiloclaw.conversations"; conversations: ConversationListItem[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.activeConversation"; conversationId: string | null } + | { type: "kiloclaw.messages"; conversationId: string; messages: Message[]; hasMore: boolean; replace: boolean } + | { type: "kiloclaw.messageOptimistic"; conversationId: string; message: Message } + | { type: "kiloclaw.messageReplaced"; conversationId: string; pendingId: string; message: Message } + | { type: "kiloclaw.messageRemoved"; conversationId: string; messageId: string } + | { type: "kiloclaw.botStatus"; status: BotStatusRecord | null } + | { type: "kiloclaw.conversationStatus"; status: ConversationStatusRecord | null } + | { type: "kiloclaw.typing"; conversationId: string; memberId: string } + | { type: "kiloclaw.typingStop"; conversationId: string; memberId: string } | { type: "fontSizeChanged"; fontSize: number } diff --git a/packages/kilo-vscode/src/kiloclaw/ulid.ts b/packages/kilo-vscode/src/kiloclaw/ulid.ts new file mode 100644 index 00000000000..a55bda569d3 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/ulid.ts @@ -0,0 +1,41 @@ +/** + * Minimal ULID generator for the extension host. + * + * Produces a 26-character Crockford Base32 identifier: 10 time chars + * followed by 16 random chars. The kilo-chat worker validates clientId + * as a ULID, so `generateClientId` must emit only Crockford-legal + * characters — `toString(36)` is NOT safe because base36 includes + * I, L, O, and U which are excluded from Crockford Base32. + */ + +// Crockford Base32 — no I, L, O, U (reduces transcription ambiguity). +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const ENCODING_LEN = ENCODING.length +const TIME_LEN = 10 +const RANDOM_LEN = 16 + +function encodeTime(ts: number): string { + let out = "" + let n = ts + for (let i = 0; i < TIME_LEN; i++) { + const mod = n % ENCODING_LEN + out = ENCODING[mod] + out + n = (n - mod) / ENCODING_LEN + } + return out +} + +function encodeRandom(): string { + const bytes = new Uint8Array(RANDOM_LEN) + globalThis.crypto.getRandomValues(bytes) + let out = "" + for (let i = 0; i < RANDOM_LEN; i++) { + out += ENCODING[bytes[i]! % ENCODING_LEN] + } + return out +} + +/** Generate a ULID at the current epoch. */ +export function ulid(): string { + return encodeTime(Date.now()) + encodeRandom() +} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index cf7cb1f908d..cae5ba1e0c8 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -2,14 +2,14 @@ * Provider action handlers extracted from KiloProvider to stay under max-lines. * These are pure async functions that operate on the SDK client — no vscode dependency. */ -import type { KiloClient } from "@kilocode/sdk/v2" +import type { Config, KiloClient } from "@kilocode/sdk/v2" import { validateProviderID as validateProviderIDShared } from "./shared/custom-provider" import { resolveCustomProviderAuth, sanitizeCustomProviderConfig, withCustomProviderDeletions, } from "./shared/custom-provider" -import { KILO_AUTO, parseModelString } from "./shared/provider-model" +import { CUSTOM_PROVIDER_PACKAGE, KILO_AUTO, parseModelString } from "./shared/provider-model" import { configFeatures } from "./features" /** @@ -22,6 +22,28 @@ function disabledWithout(list: string[] | undefined, id: string) { return (list ?? []).filter((item) => item !== id) } +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function customProvider(config: unknown) { + return record(config) && config.npm === CUSTOM_PROVIDER_PACKAGE +} + +function same(a: unknown, b: unknown): boolean { + if (a === b) return true + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + return a.every((value, index) => same(value, b[index])) + } + if (!record(a) || !record(b)) return false + const akeys = Object.keys(a).sort() + const bkeys = Object.keys(b).sort() + if (akeys.length !== bkeys.length) return false + return akeys.every((key, index) => key === bkeys[index] && same(a[key], b[key])) +} + /** Fetch auth methods alongside the provider list. Auth states default to empty (endpoint not yet available). */ export async function fetchProviderData(client: KiloClient, dir: string) { const authRequest = @@ -123,6 +145,8 @@ export function computeDefaultSelection( type PostMessage = (message: unknown) => void type GetErrorMessage = (error: unknown) => string +type SetCachedConfig = (msg: unknown) => void +type AuthMetadata = Record interface ActionContext { client: KiloClient @@ -155,11 +179,93 @@ function validateID( return null } -export async function connectProvider(ctx: ActionContext, requestId: string, providerID: string, apiKey: string) { +function cleanMetadata(input?: Record): AuthMetadata | undefined { + const entries = Object.entries(input ?? {}) + .map(([key, value]) => [key, typeof value === "string" ? value.trim() : ""] as const) + .filter(([key, value]) => key !== "" && value !== "") + if (entries.length === 0) return undefined + return Object.fromEntries(entries) +} + +async function configs(ctx: ActionContext) { + const [{ data: global }, { data: merged }] = await Promise.all([ + ctx.client.global.config.get({ throwOnError: true }), + ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }), + ]) + return { global: global ?? {}, merged: merged ?? {} } +} + +async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfig) { + const [{ data: config }, { data: global }] = await Promise.all([ + ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }), + ctx.client.global.config.get({ throwOnError: true }), + ]) + if (!config) return + const features = configFeatures(config) + setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) +} + +async function saveGlobal(ctx: ActionContext, config: Config) { + await ctx.client.global.config.update({ config }, { throwOnError: true }) +} + +async function saveProject(ctx: ActionContext, config: Config) { + await ctx.client.config.update({ config, directory: ctx.workspaceDir }, { throwOnError: true }) +} + +async function removeAuth(ctx: ActionContext, id: string, configured: boolean) { + try { + await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) + } catch (err) { + if (!configured) throw err + console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err) + } +} + +async function removeCustom(ctx: ActionContext, id: string, global: Config, merged: Config) { + const cfg = global.provider?.[id] + const effective = merged.provider?.[id] + const tasks = [] + if (customProvider(cfg)) { + tasks.push( + saveGlobal(ctx, { + provider: { [id]: null }, + disabled_providers: disabledWithout(global.disabled_providers, id), + }), + ) + } + if (customProvider(effective)) { + tasks.push(saveProject(ctx, { provider: { [id]: null } })) + } + await Promise.all(tasks) +} + +async function disableConfigured(ctx: ActionContext, id: string, config: Config) { + const disabled = config.disabled_providers ?? [] + if (disabled.includes(id)) return + await saveGlobal(ctx, { disabled_providers: [...disabled, id] }) +} + +async function enableConfigured(ctx: ActionContext, id: string, config: Config) { + const disabled = disabledWithout(config.disabled_providers, id) + if (disabled.length === (config.disabled_providers ?? []).length) return + await saveGlobal(ctx, { disabled_providers: disabled }) +} + +export async function connectProvider( + ctx: ActionContext, + requestId: string, + providerID: string, + apiKey: string, + metadata?: Record, +) { const id = validateID(ctx, requestId, providerID, "connect") if (!id) return try { - await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: apiKey } }, { throwOnError: true }) + const meta = cleanMetadata(metadata) + const auth = meta ? { type: "api" as const, key: apiKey, metadata: meta } : { type: "api" as const, key: apiKey } + await ctx.client.auth.set({ providerID: id, auth }, { throwOnError: true }) await ctx.disposeGlobal(`provider connect (${id})`) await ctx.fetchAndSendProviders() ctx.postMessage({ type: "providerConnected", requestId, providerID: id }) @@ -230,64 +336,45 @@ export async function disconnectProvider( requestId: string, providerID: string, cachedConfigMessage: unknown, - setCachedConfig: (msg: unknown) => void, + setCachedConfig: SetCachedConfig, ) { const id = validateID(ctx, requestId, providerID, "disconnect") if (!id) return try { - const globalConfig = (await ctx.client.global.config.get({ throwOnError: true })).data ?? {} - const configured = !!globalConfig.provider?.[id] + const config = await configs(ctx) + const cfg = config.global.provider?.[id] + const effective = config.merged.provider?.[id] + const configured = !!cfg || !!effective + const custom = customProvider(cfg) || customProvider(effective) const { response } = await fetchProviderData(ctx.client, ctx.workspaceDir) const active = response.all.find((item) => item.id === id) - const oauth = active?.source === "custom" && configured + const oauth = active?.source === "custom" && configured && !custom - // Remove auth store entry. Config-sourced providers may not have an auth - // store entry (credentials come from config or env), so failure is non-fatal. - // For auth-only providers, failure means disconnect failed. - try { - await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) - } catch (err) { - if (!configured) throw err - console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err) - } + // Config-sourced providers may not have auth store entries because + // credentials can come from config or env, so auth removal is non-fatal. + await removeAuth(ctx, id, configured) if (id === "kilo") { ctx.postMessage({ type: "profileData", data: null }) } - // Config-sourced providers stay "connected" after auth.remove because the - // server rebuilds state from config. Add to disabled_providers so the server - // excludes them. The config entry is preserved (user may re-enable later). - // This mirrors the server's disabled provider config behavior. - if (configured && !oauth) { - const disabled = globalConfig.disabled_providers ?? [] - if (!disabled.includes(id)) { - const merged = ( - await ctx.client.global.config.update( - { config: { disabled_providers: [...disabled, id] } }, - { throwOnError: true }, - ) - ).data - if (merged) { - setCachedConfig({ type: "configLoaded", config: merged, features: configFeatures(merged) }) - ctx.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) - } - } + if (custom) { + await removeCustom(ctx, id, config.global, config.merged) + } + + // Config-sourced built-in providers stay "connected" after auth.remove + // because the server rebuilds state from config. Add to disabled_providers + // so the server excludes them while preserving config for re-enable. + if (configured && !oauth && !custom) { + await disableConfigured(ctx, id, config.global) } if (oauth) { - const disabled = disabledWithout(globalConfig.disabled_providers, id) - if (disabled.length !== (globalConfig.disabled_providers ?? []).length) { - const merged = ( - await ctx.client.global.config.update({ config: { disabled_providers: disabled } }, { throwOnError: true }) - ).data - if (merged) { - setCachedConfig({ type: "configLoaded", config: merged }) - ctx.postMessage({ type: "configUpdated", config: merged }) - } - } + await enableConfigured(ctx, id, config.global) } + if (configured) await refreshConfig(ctx, setCachedConfig) + await ctx.disposeGlobal(`provider disconnect (${id})`) await ctx.fetchAndSendProviders() ctx.postMessage({ type: "providerDisconnected", requestId, providerID: id }) @@ -336,9 +423,11 @@ export async function saveCustomProvider( { throwOnError: true }, ) - const msg = { type: "configLoaded", config: updated, features: configFeatures(updated) } + const merged = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) + const config = merged.data ?? updated + const msg = { type: "configLoaded", config, globalConfig: updated, features: configFeatures(config) } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config: updated, features: configFeatures(updated) }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features: configFeatures(config) }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/src/review-utils.ts b/packages/kilo-vscode/src/review-utils.ts index 1e2a7c7927a..42dac76b284 100644 --- a/packages/kilo-vscode/src/review-utils.ts +++ b/packages/kilo-vscode/src/review-utils.ts @@ -1,9 +1,6 @@ import * as path from "path" import * as vscode from "vscode" import { inspect } from "util" -import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" -import { GitOps } from "./agent-manager/GitOps" -import { resolveBase } from "./agent-manager/local-diff" export function appendOutput(channel: vscode.OutputChannel, prefix: string, ...args: unknown[]): void { const msg = args @@ -18,60 +15,6 @@ export function getWorkspaceRoot(): string | undefined { return undefined } -export async function resolveLocalDiffTarget( - gitOps: GitOps, - log: (...args: unknown[]) => void, - root?: string, -): Promise<{ directory: string; baseBranch: string } | undefined> { - if (!root) { - log("Local diff: no workspace root") - return - } - - const branch = await gitOps.currentBranch(root) - if (!branch || branch === "HEAD") { - log("Local diff: detached HEAD or no branch") - return - } - - const tracking = await gitOps.resolveTrackingBranch(root, branch) - const fallback = tracking ? undefined : await gitOps.resolveDefaultBranch(root, branch) - const raw = tracking || fallback || "HEAD" - const base = await resolveBase(gitOps, root, raw) - - log(`Local diff: branch=${branch} tracking=${tracking ?? "none"} default=${fallback ?? "none"} base=${base}`) - - return { directory: root, baseBranch: base } -} - -export function hashFileDiffs( - diffs: Array< - SnapshotFileDiff & { - tracked?: boolean - generatedLike?: boolean - summarized?: boolean - stamp?: string - } - >, -): string { - return diffs - .map((diff) => { - const content = diff.summarized ? "" : diff.patch - return [ - diff.file, - diff.status, - diff.additions, - diff.deletions, - diff.tracked ? "tracked" : "untracked", - diff.generatedLike ? "generated" : "source", - diff.summarized ? "summary" : "detail", - diff.stamp ?? "", - content, - ].join(":") - }) - .join("|") -} - export function openFileInEditor( filePath: string, line?: number, @@ -96,5 +39,5 @@ export function openWorkspaceRelativeFile(relativePath: string, line?: number, c if (!root) return const resolved = path.resolve(root, relativePath) if (!resolved.startsWith(root + path.sep) && resolved !== root) return - openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffViewerProvider") + openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffPanel") } diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts deleted file mode 100644 index 020ae9651ae..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { ResponseMetaData } from "./types" -import type { KiloConnectionService } from "../cli-backend" -import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" - -export class AutocompleteModel { - private connectionService: KiloConnectionService | null = null - private currentModel: string = DEFAULT_AUTOCOMPLETE_MODEL.id - public profileName: string | null = null - public profileType: string | null = null - - constructor(connectionService?: KiloConnectionService) { - if (connectionService) { - this.connectionService = connectionService - } - } - - public setModel(model: string): void { - this.currentModel = model - } - - /** - * Set the connection service (can be called after construction when service becomes available) - */ - public setConnectionService(service: KiloConnectionService): void { - this.connectionService = service - } - - /** - * Generate a FIM (Fill-in-the-Middle) completion via the CLI backend. - * Uses the SDK's kilo.fim() SSE endpoint which handles auth and streaming. - * - * @param signal - Optional AbortSignal to cancel the SSE stream early (e.g. when the user types again) - */ - public async generateFimResponse( - prefix: string, - suffix: string, - onChunk: (text: string) => void, - signal?: AbortSignal, - ): Promise { - if (!this.connectionService) { - throw new Error("Connection service is not available") - } - - const client = await this.connectionService.getClientAsync() - - let cost = 0 - let inputTokens = 0 - let outputTokens = 0 - - // Capture SSE-level errors so they propagate to the caller. The SDK's SSE - // client catches HTTP errors (402, 401, 429, 5xx) internally and silently - // ends the stream. Without this, errors never reach ErrorBackoff. - let sseError: Error | undefined - - const temp = getAutocompleteModel(this.currentModel).temperature - - const { stream } = await client.kilo.fim( - { - prefix, - suffix, - model: this.currentModel, - maxTokens: 256, - temperature: temp, - }, - { - signal, - sseMaxRetryAttempts: 1, - onSseError: (error) => { - sseError = error instanceof Error ? error : new Error(String(error)) - }, - }, - ) - - for await (const chunk of stream) { - const choice = chunk.choices?.[0] - const content = choice?.delta?.content ?? choice?.text - if (content) onChunk(content) - if (chunk.usage) { - inputTokens = chunk.usage.prompt_tokens ?? 0 - outputTokens = chunk.usage.completion_tokens ?? 0 - } - if (chunk.cost !== undefined) cost = chunk.cost - } - - if (sseError) throw sseError - - return { - cost, - inputTokens, - outputTokens, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - } - - public getModelName(): string { - return this.currentModel - } - - public getProviderDisplayName(): string { - return getAutocompleteModel(this.currentModel).provider - } - - /** - * Check if the model has valid credentials. - * With CLI backend, credentials are managed by the backend — we just need a connection. - */ - public hasValidCredentials(): boolean { - if (!this.connectionService) { - return false - } - return this.connectionService.getConnectionState() === "connected" - } - - /** - * Check the user's credit balance via the profile endpoint. - * Returns true if the user has a positive balance, false otherwise. - * Returns false on any error (not connected, fetch failed, etc.). - */ - public async hasBalance(): Promise { - if (!this.connectionService) return false - try { - const client = await this.connectionService.getClientAsync() - const result = await client.kilo.profile().catch(() => null) - return (result?.data?.balance?.balance ?? 0) > 0 - } catch { - return false - } - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 1beb1485ea4..a272f677372 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -2,13 +2,13 @@ import crypto from "crypto" import * as vscode from "vscode" import { t } from "./shims/i18n" import { TelemetryProxy, TelemetryEventName } from "../telemetry" -import { AutocompleteModel } from "./AutocompleteModel" import { AutocompleteStatusBar } from "./AutocompleteStatusBar" import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider" import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider" import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry" import type { KiloConnectionService } from "../cli-backend" -import { getAutocompleteModel } from "../../shared/autocomplete-models" +import { hasValidCredentials } from "./fim" +import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" const CONFIG_SECTION = "kilo-code.new.autocomplete" @@ -42,7 +42,7 @@ async function writeSettings(patch: Partial): Promi export class AutocompleteServiceManager { private static _instance: AutocompleteServiceManager | null = null - private readonly model: AutocompleteModel + private readonly connectionService: KiloConnectionService private readonly context: vscode.ExtensionContext private settings: AutocompleteServiceSettings | null = null @@ -71,18 +71,17 @@ export class AutocompleteServiceManager { } this.context = context + this.connectionService = connectionService AutocompleteServiceManager._instance = this - // Register Internal Components - this.model = new AutocompleteModel(connectionService) - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "" // Register the providers this.codeActionProvider = new AutocompleteCodeActionProvider() this.inlineCompletionProvider = new AutocompleteInlineCompletionProvider( this.context, - this.model, + DEFAULT_AUTOCOMPLETE_MODEL.id, + connectionService, this.updateCostTracking.bind(this), () => this.settings, workspacePath, @@ -121,7 +120,7 @@ export class AutocompleteServiceManager { this.settings = readSettings() if (this.settings.model) { - this.model.setModel(this.settings.model) + this.inlineCompletionProvider.setModel(this.settings.model) } await this.updateGlobalContext() @@ -320,15 +319,15 @@ export class AutocompleteServiceManager { } private getCurrentModelName(): string { - return this.model.getModelName() + return this.inlineCompletionProvider.getModelId() } private getCurrentProviderName(): string { - return this.model.getProviderDisplayName() + return getAutocompleteModel(this.inlineCompletionProvider.getModelId()).provider } private hasNoUsableProvider(): boolean { - return !this.model.hasValidCredentials() + return !hasValidCredentials(this.connectionService) } /** @@ -368,7 +367,6 @@ export class AutocompleteServiceManager { snoozed: this.isSnoozed(), model: this.getCurrentModelName(), provider: this.getCurrentProviderName(), - profileName: this.model.profileName, hasNoUsableProvider: this.hasNoUsableProvider(), totalSessionCost: this.sessionCost, completionCount: this.completionCount, diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts deleted file mode 100644 index cce80476b89..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { AutocompleteModel } from "../AutocompleteModel" -import type { KiloConnectionService } from "../../cli-backend" - -const mockClient = { - kilo: { - fim: vi.fn(), - }, -} - -function createMockConnectionService(state: "connecting" | "connected" | "disconnected" | "error" = "connected") { - return { - getConnectionState: vi.fn().mockReturnValue(state), - getClient: vi.fn().mockReturnValue(mockClient), - getClientAsync: - state === "connected" - ? vi.fn().mockResolvedValue(mockClient) - : vi.fn().mockRejectedValue(new Error(`CLI backend is not connected (state: ${state})`)), - onStateChange: vi.fn().mockReturnValue(() => {}), - } as unknown as KiloConnectionService -} - -describe("AutocompleteModel", () => { - beforeEach(() => { - mockClient.kilo.fim.mockReset() - }) - - describe("constructor", () => { - it("defaults profileName and profileType to null", () => { - const model = new AutocompleteModel() - expect(model.profileName).toBeNull() - expect(model.profileType).toBeNull() - }) - }) - - describe("setConnectionService", () => { - it("sets the connection service after construction", () => { - const model = new AutocompleteModel() - expect(model.hasValidCredentials()).toBe(false) - - const connection = createMockConnectionService("connected") - model.setConnectionService(connection) - expect(model.hasValidCredentials()).toBe(true) - }) - }) - - describe("hasValidCredentials", () => { - it("returns true when connected", () => { - const connection = createMockConnectionService("connected") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(true) - }) - - it("returns false when disconnected", () => { - const connection = createMockConnectionService("disconnected") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(false) - }) - - it("returns false when connecting", () => { - const connection = createMockConnectionService("connecting") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(false) - }) - - it("returns false when in error state", () => { - const connection = createMockConnectionService("error") - const model = new AutocompleteModel(connection) - expect(model.hasValidCredentials()).toBe(false) - }) - - it("returns false without connection service", () => { - const model = new AutocompleteModel() - expect(model.hasValidCredentials()).toBe(false) - }) - }) - - describe("getModelName", () => { - it("returns the default model", () => { - const model = new AutocompleteModel() - expect(model.getModelName()).toBe("mistralai/codestral-2508") - }) - }) - - describe("getProviderDisplayName", () => { - it("returns the default provider", () => { - const model = new AutocompleteModel() - expect(model.getProviderDisplayName()).toBe("Mistral AI") - }) - - it("returns the selected provider", () => { - const model = new AutocompleteModel() - model.setModel("inception/mercury-edit") - - expect(model.getProviderDisplayName()).toBe("Inception") - }) - }) - - describe("generateFimResponse", () => { - it("throws when connection service is not available", async () => { - const model = new AutocompleteModel() - await expect(model.generateFimResponse("prefix", "suffix", vi.fn())).rejects.toThrow( - "Connection service is not available", - ) - }) - - it("throws when not connected", async () => { - const connection = createMockConnectionService("disconnected") - const model = new AutocompleteModel(connection) - await expect(model.generateFimResponse("prefix", "suffix", vi.fn())).rejects.toThrow( - "CLI backend is not connected", - ) - }) - - it("streams chunks and returns metadata", async () => { - const chunks = [ - { choices: [{ delta: { content: "hello" } }] }, - { - choices: [{ delta: { content: " world" } }], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - cost: 0.001, - }, - ] - - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () { - for (const chunk of chunks) yield chunk - })(), - }) - - const model = new AutocompleteModel(connection) - const received: string[] = [] - const result = await model.generateFimResponse("prefix", "suffix", (text) => received.push(text)) - - expect(received).toEqual(["hello", " world"]) - expect(result).toEqual({ - cost: 0.001, - inputTokens: 10, - outputTokens: 5, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - }) - - it("streams text-completion chunks", async () => { - const chunks = [{ choices: [{ text: "hello" }] }, { choices: [{ text: " world" }] }] - - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () { - for (const chunk of chunks) yield chunk - })(), - }) - - const model = new AutocompleteModel(connection) - const received: string[] = [] - await model.generateFimResponse("prefix", "suffix", (text) => received.push(text)) - - expect(received).toEqual(["hello", " world"]) - }) - - it("passes model parameters to fim call", async () => { - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () {})(), - }) - - const model = new AutocompleteModel(connection) - const signal = new AbortController().signal - await model.generateFimResponse("pre", "suf", vi.fn(), signal) - - expect(mockClient.kilo.fim).toHaveBeenCalledWith( - { - prefix: "pre", - suffix: "suf", - model: "mistralai/codestral-2508", - maxTokens: 256, - temperature: 0.2, - }, - expect.objectContaining({ signal }), - ) - }) - - it("passes selected model parameters to fim call", async () => { - const connection = createMockConnectionService("connected") - mockClient.kilo.fim.mockResolvedValue({ - stream: (async function* () {})(), - }) - - const model = new AutocompleteModel(connection) - model.setModel("inception/mercury-edit") - await model.generateFimResponse("pre", "suf", vi.fn()) - - expect(mockClient.kilo.fim).toHaveBeenCalledWith( - expect.objectContaining({ - model: "inception/mercury-edit", - temperature: 0, - }), - expect.any(Object), - ) - }) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts index cc1398cf57c..1244b25b9ec 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts @@ -61,26 +61,6 @@ vi.mock("vscode", () => { } }) -vi.mock("../AutocompleteModel", () => { - class AutocompleteModel { - public profileName = "test-profile" - - public getModelName(): string { - return "test-model" - } - - public getProviderDisplayName(): string { - return "test-provider" - } - - public hasValidCredentials(): boolean { - return true - } - } - - return { AutocompleteModel } -}) - vi.mock("../AutocompleteStatusBar", () => { class AutocompleteStatusBar { public update = vi.fn() @@ -99,6 +79,14 @@ vi.mock("../classic-auto-complete/AutocompleteInlineCompletionProvider", () => { class AutocompleteInlineCompletionProvider { public provideInlineCompletionItems_Internal = vi.fn() public dispose = vi.fn() + public resetBackoff = vi.fn() + private modelId = "test-model" + public setModel(id: string) { + this.modelId = id + } + public getModelId(): string { + return this.modelId + } constructor(..._args: any[]) {} } @@ -164,7 +152,12 @@ async function createManager(): Promise { postStateToWebview: vi.fn().mockResolvedValue(undefined), } - const connection = { onStateChange: vi.fn().mockReturnValue(() => {}), ...cline } + const connection = { + onStateChange: vi.fn().mockReturnValue(() => {}), + onEventFiltered: vi.fn().mockReturnValue(() => {}), + getConnectionState: vi.fn().mockReturnValue("connected"), + ...cline, + } const manager = new AutocompleteServiceManager(context, connection as any) diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts index 9ee596bc23b..4cc2eb0a535 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const state = new Map() -const update = vi.fn(async (key: string, value: unknown) => { +const update = vi.fn((key: string, value: unknown) => { state.set(key, value) }) @@ -44,36 +44,21 @@ describe("autocomplete settings", () => { expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508") }) - it("persists supported model updates", async () => { - const post = vi.fn() - const { routeAutocompleteMessage } = await import("../settings") + it("validates supported model updates", async () => { + const { validAutocompleteSetting } = await import("../settings") - await routeAutocompleteMessage( - { type: "updateAutocompleteSetting", key: "model", value: "inception/mercury-edit" }, - post, - ) - - expect(update).toHaveBeenCalledWith("model", "inception/mercury-edit", 1) - expect(post).toHaveBeenCalledWith(expect.objectContaining({ type: "autocompleteSettingsLoaded" })) + expect(validAutocompleteSetting("model", "inception/mercury-edit")).toBe(true) }) it("rejects unsupported model updates", async () => { - const post = vi.fn() - const { routeAutocompleteMessage } = await import("../settings") + const { validAutocompleteSetting } = await import("../settings") - await routeAutocompleteMessage({ type: "updateAutocompleteSetting", key: "model", value: "other/model" }, post) - - expect(update).not.toHaveBeenCalled() - expect(post).not.toHaveBeenCalled() + expect(validAutocompleteSetting("model", "other/model")).toBe(false) }) it("rejects non-boolean toggle updates", async () => { - const post = vi.fn() - const { routeAutocompleteMessage } = await import("../settings") + const { validAutocompleteSetting } = await import("../settings") - await routeAutocompleteMessage({ type: "updateAutocompleteSetting", key: "enableAutoTrigger", value: "true" }, post) - - expect(update).not.toHaveBeenCalled() - expect(post).not.toHaveBeenCalled() + expect(validAutocompleteSetting("enableAutoTrigger", "true")).toBe(false) }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts index 328ba612eb4..3971e785a11 100644 --- a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode" -import { AutocompleteModel } from "../AutocompleteModel" import type { AutocompleteContext, VisibleCodeContext } from "../types" import { removePrefixOverlap } from "../continuedev/core/autocomplete/postprocessing/removePrefixOverlap.js" import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry" @@ -7,6 +6,7 @@ import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/usel import { VisibleCodeTracker } from "../context/VisibleCodeTracker" import { FileIgnoreController } from "../shims/FileIgnoreController" import type { KiloConnectionService } from "../../cli-backend" +import { generateFim, hasValidCredentials } from "../fim" import { getAutocompleteModel } from "../../../shared/autocomplete-models" import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils" @@ -29,14 +29,14 @@ interface ChatCompletionResponseSender { * acceptance events correlate. */ export class ChatTextAreaAutocomplete { - private model: AutocompleteModel + private connection: KiloConnectionService readonly telemetry: AutocompleteTelemetry private ignore: FileIgnoreController | null = null private dir = "" private watcher: vscode.FileSystemWatcher | undefined constructor(connectionService: KiloConnectionService, telemetry?: AutocompleteTelemetry) { - this.model = new AutocompleteModel(connectionService) + this.connection = connectionService this.telemetry = telemetry ?? new AutocompleteTelemetry("chat-textarea") this.watcher = vscode.workspace.createFileSystemWatcher("**/{.kilocodeignore,.gitignore}") const invalidate = () => { @@ -77,18 +77,18 @@ export class ChatTextAreaAutocomplete { async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> { const cfg = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") - this.model.setModel(getAutocompleteModel(cfg.get("model") ?? "").id) + const entry = getAutocompleteModel(cfg.get("model") ?? "") const startTime = Date.now() // Build context for telemetry const context: AutocompleteContext = { languageId: "chat", // Chat textarea doesn't have a language ID - modelId: this.model.getModelName(), - provider: this.model.getProviderDisplayName(), + modelId: entry.id, + provider: entry.provider, } - // Check if model has valid credentials (but don't require FIM) - if (!this.model.hasValidCredentials()) { + // Check for valid credentials (but don't require FIM) + if (!hasValidCredentials(this.connection)) { return { suggestion: "" } } @@ -101,7 +101,7 @@ export class ChatTextAreaAutocomplete { let response = "" try { - await this.model.generateFimResponse(prefix, suffix, (chunk) => { + await generateFim(this.connection, entry.id, prefix, suffix, (chunk) => { response += chunk }) @@ -116,7 +116,7 @@ export class ChatTextAreaAutocomplete { context, ) - const cleanedSuggestion = this.cleanSuggestion(response, userText) + const cleanedSuggestion = this.cleanSuggestion(response, userText, entry.id) // Track if suggestion was filtered or returned if (!cleanedSuggestion) { @@ -147,12 +147,12 @@ export class ChatTextAreaAutocomplete { return buildChatPrefix(userText, visibleCodeContext?.editors) } - public cleanSuggestion(suggestion: string, userText: string): string { + public cleanSuggestion(suggestion: string, userText: string, modelId: string): string { const cleaned = postprocessAutocompleteSuggestion({ suggestion: removePrefixOverlap(suggestion, userText), prefix: userText, suffix: "", - model: this.model.getModelName() ?? "unknown", + model: modelId || "unknown", }) if (cleaned === undefined) return "" return finalizeChatSuggestion(cleaned) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 01e51ac0095..78c16079f52 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -16,14 +16,13 @@ import { import { findMatchingSuggestion as _findMatchingSuggestion, applyFirstLineOnly as _applyFirstLineOnly, - countLines as _countLines, - shouldShowOnlyFirstLine as _shouldShowOnlyFirstLine, - getFirstLine as _getFirstLine, calcDebounceDelay, MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn, } from "./inline-utils" import { FimPromptBuilder } from "./FillInTheMiddle" -import { AutocompleteModel } from "../AutocompleteModel" +import { hasValidCredentials } from "../fim" +import type { KiloConnectionService } from "../../cli-backend" +import { getAutocompleteModel } from "../../../shared/autocomplete-models" import { ContextRetrievalService } from "../continuedev/core/autocomplete/context/ContextRetrievalService" import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde" import { RecentlyVisitedRangesService } from "../continuedev/core/vscode-test-harness/src/autocomplete/RecentlyVisitedRangesService" @@ -91,18 +90,6 @@ export function applyFirstLineOnly( */ export const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.autocomplete.inline-completion.accepted" -export function countLines(text: string): number { - return _countLines(text) -} - -export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { - return _shouldShowOnlyFirstLine(prefix, suggestion) -} - -export function getFirstLine(text: string): string { - return _getFirstLine(text) -} - export function stringToInlineCompletions(text: string, position: vscode.Position): vscode.InlineCompletionItem[] { if (text === "") { return [] @@ -120,7 +107,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple /** Tracks all pending/in-flight requests */ private pendingRequests: PendingRequest[] = [] private fimPromptBuilder: FimPromptBuilder - private model: AutocompleteModel + private contextProvider: AutocompleteContextProvider + private connectionService: KiloConnectionService private costTrackingCallback: CostTrackingCallback private getSettings: () => AutocompleteServiceSettings | null private recentlyVisitedRangesService: RecentlyVisitedRangesService @@ -148,7 +136,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple constructor( context: vscode.ExtensionContext, - model: AutocompleteModel, + modelId: string, + connectionService: KiloConnectionService, costTrackingCallback: CostTrackingCallback, getSettings: () => AutocompleteServiceSettings | null, workspacePath: string, @@ -156,7 +145,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple onFatalError?: (status: number | null) => void, ) { this.telemetry = telemetry - this.model = model + this.connectionService = connectionService this.costTrackingCallback = costTrackingCallback this.getSettings = getSettings this.onFatalError = onFatalError ?? null @@ -169,13 +158,13 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const ide = new VsCodeIde(context) this.contextService = new ContextRetrievalService(ide) - const contextProvider: AutocompleteContextProvider = { + this.contextProvider = { ide, contextService: this.contextService, - model, + modelId, ignoreController: this.ignoreController, } - this.fimPromptBuilder = new FimPromptBuilder(contextProvider) + this.fimPromptBuilder = new FimPromptBuilder(this.contextProvider) this.recentlyVisitedRangesService = new RecentlyVisitedRangesService(ide) this.recentlyEditedTracker = new RecentlyEditedTracker(ide) @@ -229,17 +218,29 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const prompt = await this.fimPromptBuilder.getFimPrompts( autocompleteInput, - this.model.getModelName() ?? "codestral", + this.contextProvider.modelId || "codestral", ) return { prompt, prefix, suffix } } + /** + * Update the autocomplete model ID. The context provider (shared with the + * FIM prompt builder) is mutated in place so downstream consumers pick up + * the new value on the next request. + */ + public setModel(modelId: string): void { + this.contextProvider.modelId = modelId + } + + public getModelId(): string { + return this.contextProvider.modelId + } + private processSuggestion( suggestionText: string, prefix: string, suffix: string, - model: AutocompleteModel, telemetryContext: AutocompleteContext, languageId?: string, ): FillInAtCursorSuggestion { @@ -252,7 +253,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple suggestion: suggestionText, prefix, suffix, - model: model.getModelName() || "", + model: this.contextProvider.modelId || "", languageId, }) @@ -343,14 +344,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // Build telemetry context const telemetryContext: AutocompleteContext = { languageId: document.languageId, - modelId: this.model?.getModelName(), - provider: this.model?.getProviderDisplayName(), + modelId: this.contextProvider.modelId, + provider: getAutocompleteModel(this.contextProvider.modelId).provider, } this.telemetry?.captureSuggestionRequested(telemetryContext) - if (!this.model || !this.model.hasValidCredentials()) { - // bail if no model is available or no valid API credentials configured + if (!hasValidCredentials(this.connectionService)) { + // bail if no valid API credentials configured // this prevents errors when autocomplete is enabled but no provider is set up return [] } @@ -364,8 +365,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // instead of sending a probe FIM request. If the user has added credits, // reset the backoff so autocomplete resumes. if (this.backoff.getFatalStatus() === 402 && this.backoff.shouldProbe()) { - const funded = await this.model.hasBalance() - if (funded) { + if (await this.hasBalance()) { this.backoff.reset() this.fatalNotified = false } @@ -585,24 +585,24 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // Build telemetry context for this request const telemetryContext: AutocompleteContext = { languageId, - modelId: this.model?.getModelName(), - provider: this.model?.getProviderDisplayName(), + modelId: this.contextProvider.modelId, + provider: getAutocompleteModel(this.contextProvider.modelId).provider, } // Defense-in-depth: credentials may become invalid between the provider gate and the actual - // debounced execution (e.g., profile reload calling AutocompleteModel.cleanup()). - // In that case, do not attempt an LLM call at all. - if (!this.model || !this.model.hasValidCredentials()) { + // debounced execution. In that case, do not attempt an LLM call at all. + if (!hasValidCredentials(this.connectionService)) { return } try { - // Curry processSuggestion with prefix, suffix, model, telemetry context, and languageId + // Curry processSuggestion with prefix, suffix, telemetry context, and languageId const curriedProcessSuggestion = (text: string) => - this.processSuggestion(text, prefix, suffix, this.model, telemetryContext, languageId) + this.processSuggestion(text, prefix, suffix, telemetryContext, languageId) const result = await this.fimPromptBuilder.getFromFIM( - this.model, + this.connectionService, + this.contextProvider.modelId, prompt, curriedProcessSuggestion, controller.signal, @@ -654,4 +654,19 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } } } + + /** + * Check the user's credit balance via the profile endpoint. + * Returns true if the user has a positive balance, false otherwise. + * Returns false on any error (not connected, fetch failed, etc.). + */ + private async hasBalance(): Promise { + try { + const client = await this.connectionService.getClientAsync() + const result = await client.kilo.profile().catch(() => null) + return (result?.data?.balance?.balance ?? 0) > 0 + } catch { + return false + } + } } diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts index 6c6b2a1e0a0..6a36ae3ab32 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/FillInTheMiddle.ts @@ -7,7 +7,8 @@ import { } from "../types" import { getProcessedSnippets } from "./getProcessedSnippets" import { getTemplateForModel } from "../continuedev/core/autocomplete/templating/AutocompleteTemplate" -import { AutocompleteModel } from "../AutocompleteModel" +import { generateFim } from "../fim" +import type { KiloConnectionService } from "../../cli-backend" export type { FimAutocompletePrompt, FimCompletionResult } @@ -22,7 +23,7 @@ export class FimPromptBuilder { autocompleteInput, autocompleteInput.filepath, this.contextProvider.contextService, - this.contextProvider.model, + this.contextProvider.modelId, this.contextProvider.ide, this.contextProvider.ignoreController, ) @@ -57,7 +58,8 @@ export class FimPromptBuilder { * Execute FIM-based completion using the model */ async getFromFIM( - model: AutocompleteModel, + connection: KiloConnectionService, + modelId: string, prompt: FimAutocompletePrompt, processSuggestion: (text: string) => FillInAtCursorSuggestion, signal?: AbortSignal, @@ -82,7 +84,7 @@ export class FimPromptBuilder { response += text } logtime("prep fim") - const usageInfo = await model.generateFimResponse(formattedPrefix, prunedSuffix, onChunk, signal) + const usageInfo = await generateFim(connection, modelId, formattedPrefix, prunedSuffix, onChunk, signal) logtime("fim network") console.log("[FIM] response:", response) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts index 7821b658364..fc75473d7d1 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { getProcessedSnippets } from "../getProcessedSnippets" import { AutocompleteInput, AutocompleteContextProvider } from "../../types" import { AutocompleteSnippetType } from "../../continuedev/core/autocomplete/types" -import { AutocompleteModel } from "../../AutocompleteModel" import { RooIgnoreController } from "../../../../core/ignore/RooIgnoreController" import crypto from "crypto" import { ContextRetrievalService } from "../../continuedev/core/autocomplete/context/ContextRetrievalService" @@ -85,16 +84,11 @@ function createAutocompleteInput(filepath: string = "/test.ts"): AutocompleteInp describe("AutocompleteContextProvider", () => { let contextProvider: AutocompleteContextProvider - let mockModel: AutocompleteModel let mockIgnoreController: Promise | undefined beforeEach(() => { vi.clearAllMocks() - mockModel = { - getModelName: vi.fn().mockReturnValue("codestral"), - } as any - mockIgnoreController = undefined const ide = new VsCodeIde({} as any) @@ -102,7 +96,7 @@ describe("AutocompleteContextProvider", () => { contextProvider = { ide, contextService, - model: mockModel, + modelId: "codestral", ignoreController: mockIgnoreController, } }) @@ -114,7 +108,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -150,7 +144,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -195,7 +189,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -225,7 +219,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ), @@ -251,7 +245,7 @@ describe("AutocompleteContextProvider", () => { contextProvider = { ide, contextService, - model: mockModel, + modelId: "codestral", ignoreController: mockIgnoreController, } }) @@ -292,7 +286,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -353,7 +347,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) @@ -377,7 +371,7 @@ describe("AutocompleteContextProvider", () => { contextProvider = { ide, contextService, - model: mockModel, + modelId: "codestral", ignoreController: undefined, } @@ -406,7 +400,7 @@ describe("AutocompleteContextProvider", () => { input, "/test.ts", contextProvider.contextService, - contextProvider.model, + contextProvider.modelId, contextProvider.ide, contextProvider.ignoreController, ) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteInlineCompletionProvider.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteInlineCompletionProvider.test.ts deleted file mode 100644 index fa64a47745b..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteInlineCompletionProvider.test.ts +++ /dev/null @@ -1,2528 +0,0 @@ -import * as vscode from "vscode" -import { - AutocompleteInlineCompletionProvider, - findMatchingSuggestion, - applyFirstLineOnly, - stringToInlineCompletions, - shouldShowOnlyFirstLine, - getFirstLine, - countLines, - CostTrackingCallback, -} from "../AutocompleteInlineCompletionProvider" -import { FillInAtCursorSuggestion } from "../../types" -import { MockTextDocument } from "../../../mocking/MockTextDocument" -import { AutocompleteModel } from "../../AutocompleteModel" -import { AutocompleteTelemetry } from "../AutocompleteTelemetry" -import * as AutocompleteContextProviderModule from "../getProcessedSnippets" - -// Mock RooIgnoreController to prevent vscode.RelativePattern errors -vi.mock("../../../../core/ignore/RooIgnoreController", () => { - return { - RooIgnoreController: class MockRooIgnoreController { - initialize = vi.fn().mockResolvedValue(undefined) - validateAccess = vi.fn().mockReturnValue(true) - dispose = vi.fn() - }, - } -}) - -// Mock AutocompleteTelemetry class - don't mock it, let it be created normally -// The tests will create real instances or null as needed - -// Mock vscode InlineCompletionTriggerKind enum and event listeners -vi.mock("vscode", async () => { - const actual = await vi.importActual("vscode") - return { - ...actual, - InlineCompletionTriggerKind: { - Invoke: 0, - Automatic: 1, - }, - // Mock InlineCompletionItem class for use in stringToInlineCompletions - InlineCompletionItem: class MockInlineCompletionItem { - insertText: string | { value: string } - range?: { start: { line: number; character: number }; end: { line: number; character: number } } - command?: { command: string; title: string } - - constructor( - insertText: string | { value: string }, - range?: { start: { line: number; character: number }; end: { line: number; character: number } }, - command?: { command: string; title: string }, - ) { - this.insertText = insertText - this.range = range - this.command = command - } - }, - window: { - ...actual.window, - onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), - }, - workspace: { - ...actual.workspace, - onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), - }, - commands: { - ...actual.commands, - registerCommand: vi.fn(() => ({ dispose: vi.fn() })), - }, - } -}) - -describe("findMatchingSuggestion", () => { - describe("failed lookups", () => { - it("should return empty string when matching a failed lookup (text is empty string)", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("exact") - expect(result!.fillInAtCursor).toBeDefined() - }) - - it("should skip failed lookups and find successful suggestions", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - { - text: "console.log('success');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("console.log('success');") - expect(result!.matchType).toBe("exact") - }) - - it("should return empty string for failed lookup even when other suggestions exist", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('other');", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - { - text: "", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("exact") - }) - }) - - describe("exact matching", () => { - it("should return suggestion text when prefix and suffix match exactly", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("console.log('Hello, World!');") - expect(result!.matchType).toBe("exact") - }) - - it("should return null when prefix does not match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("different prefix", "\nconst y = 2", suggestions) - expect(result).toBeNull() - }) - - it("should return null when suffix does not match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "different suffix", suggestions) - expect(result).toBeNull() - }) - - it("should return null when suggestions array is empty", () => { - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", []) - expect(result).toBeNull() - }) - }) - - describe("backward deletion support", () => { - it("should return deleted prefix portion plus suggestion when user backspaces", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - ] - - // User backspaced from "foo" to "f" - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("oohenk") - expect(result!.matchType).toBe("backward_deletion") - }) - - it("should return full prefix plus suggestion when user deletes entire prefix", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "world", - prefix: "hello", - suffix: "!", - }, - ] - - // User deleted entire prefix - const result = findMatchingSuggestion("", "!", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("helloworld") - expect(result!.matchType).toBe("backward_deletion") - }) - - it("should return null when suffix does not match during backward deletion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - ] - - // User backspaced but suffix changed - const result = findMatchingSuggestion("f", "baz", suggestions) - expect(result).toBeNull() - }) - - it("should return null when current prefix is not a prefix of stored prefix", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - ] - - // Current prefix "x" is not a prefix of "foo" - const result = findMatchingSuggestion("x", "bar", suggestions) - expect(result).toBeNull() - }) - - it("should not use backward deletion when suggestion text is empty", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "", - prefix: "foo", - suffix: "bar", - }, - ] - - // User backspaced - should return null because the original suggestion was empty - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result).toBeNull() - }) - - it("should prefer exact match over backward deletion match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "henk", - prefix: "foo", - suffix: "bar", - }, - { - text: "exact", - prefix: "f", - suffix: "bar", - }, - ] - - // Should match the exact prefix "f" first (most recent) - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("exact") - expect(result!.matchType).toBe("exact") - }) - - it("should handle multi-character backward deletion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "test()", - prefix: "function myFunc", - suffix: " { }", - }, - ] - - // User deleted "unc" from "function myFunc" - const result = findMatchingSuggestion("function myF", " { }", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("unctest()") - expect(result!.matchType).toBe("backward_deletion") - }) - }) - - describe("partial typing support", () => { - it("should return remaining suggestion when user has partially typed", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "cons" after the prefix - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("ole.log('Hello, World!');") - expect(result!.matchType).toBe("partial_typing") - }) - - it("should return full suggestion when no partial typing", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("console.log('test');") - expect(result!.matchType).toBe("exact") - }) - - it("should return null when partially typed content does not match suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "xyz" which doesn't match the suggestion - const result = findMatchingSuggestion("const x = 1xyz", "\nconst y = 2", suggestions) - expect(result).toBeNull() - }) - - it("should return empty string when user has typed entire suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1console.log('test');", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("partial_typing") - }) - - it("should return null when suffix has changed during partial typing", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed partial content but suffix changed - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 3", suggestions) - expect(result).toBeNull() - }) - - it("should handle multi-character partial typing", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "function test() { return 42; }", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "function te" - const result = findMatchingSuggestion("const x = 1function te", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("st() { return 42; }") - expect(result!.matchType).toBe("partial_typing") - }) - - it("should be case-sensitive in partial matching", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "Console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - // User typed "cons" (lowercase) but suggestion starts with "Console" (uppercase) - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).toBeNull() - }) - }) - - describe("multiple suggestions", () => { - it("should prefer most recent matching suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "second suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("second suggestion") - expect(result!.matchType).toBe("exact") - }) - - it("should match different suggestions based on context", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "second suggestion", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - ] - - const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result1).not.toBeNull() - expect(result1!.text).toBe("first suggestion") - expect(result1!.matchType).toBe("exact") - - const result2 = findMatchingSuggestion("const a = 1", "\nconst b = 2", suggestions) - expect(result2).not.toBeNull() - expect(result2!.text).toBe("second suggestion") - expect(result2!.matchType).toBe("exact") - }) - - it("should prefer exact match over partial match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('partial');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "exact match", - prefix: "const x = 1cons", - suffix: "\nconst y = 2", - }, - ] - - // User is at position that matches exact prefix of second suggestion - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).not.toBeNull() - expect(result!.text).toBe("exact match") - expect(result!.matchType).toBe("exact") - }) - }) - - describe("match type tracking", () => { - it("should return exact matchType for exact prefix/suffix match", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "test", - prefix: "foo", - suffix: "bar", - }, - ] - - const result = findMatchingSuggestion("foo", "bar", suggestions) - expect(result?.matchType).toBe("exact") - }) - - it("should return partial_typing matchType when user has typed part of suggestion", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result?.matchType).toBe("partial_typing") - }) - - it("should return backward_deletion matchType when user has backspaced", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "test", - prefix: "foo", - suffix: "bar", - }, - ] - - const result = findMatchingSuggestion("f", "bar", suggestions) - expect(result?.matchType).toBe("backward_deletion") - }) - }) - - describe("fillInAtCursor tracking", () => { - it("should return a fillInAtCursor for tracking visibility", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - ] - - const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - - expect(result).not.toBeNull() - expect(result!.fillInAtCursor).toBeDefined() - expect(result!.fillInAtCursor.text).toBe("console.log('test');") - expect(result!.fillInAtCursor.prefix).toBe("const x = 1") - expect(result!.fillInAtCursor.suffix).toBe("\nconst y = 2") - }) - - it("should return the same fillInAtCursor for the same suggestion", () => { - const suggestion: FillInAtCursorSuggestion = { - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - } - const suggestions: FillInAtCursorSuggestion[] = [suggestion] - - const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - const result2 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - - // Should be the same object reference - expect(result1!.fillInAtCursor).toBe(result2!.fillInAtCursor) - }) - - it("should return different fillInAtCursor for different suggestions", () => { - const suggestions: FillInAtCursorSuggestion[] = [ - { - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }, - { - text: "second suggestion", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }, - ] - - const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - const result2 = findMatchingSuggestion("const a = 1", "\nconst b = 2", suggestions) - - expect(result1!.fillInAtCursor).not.toBe(result2!.fillInAtCursor) - }) - }) -}) - -describe("shouldShowOnlyFirstLine", () => { - it.each([ - ["\\n", "\nconst y = 2"], - ["\\r\\n", "\r\nconst y = 2"], - ])("returns false when suggestion starts with %s", (_label, suggestion) => { - expect(shouldShowOnlyFirstLine("const x = foo", suggestion)).toBe(false) - }) - - it("returns true when cursor is mid-line (current line has non-whitespace)", () => { - expect(shouldShowOnlyFirstLine("const x = 1\nconst y = foo", "bar\nbaz")).toBe(true) - }) - - it("returns false at start of line when suggestion is 3+ lines but current line has no word characters", () => { - // When current line has only whitespace (no word characters), show the whole block - expect(shouldShowOnlyFirstLine("const x = 1\n ", "l1\nl2\nl3")).toBe(false) - }) - - it("returns true at start of line when suggestion is 3+ lines and current line has word characters", () => { - // When current line has word characters (e.g., partial code), show only first line - expect(shouldShowOnlyFirstLine("const x = 1\n const", "l1\nl2\nl3")).toBe(true) - }) - - it("returns false at start of line when suggestion is 2 lines", () => { - expect(shouldShowOnlyFirstLine("const x = 1\n", "l1\nl2")).toBe(false) - }) - - it("returns false when current line has only non-word characters (e.g., indentation after comma)", () => { - // Simulates typing comma + Enter in an array of objects, then getting a multi-line suggestion - // The prefix ends with whitespace-only line (indentation), suggestion is the next object block - const prefix = "const items = [\n\t{ name: 'first' },\n\t" - const suggestion = "{ name: 'second' },\n\t{ name: 'third' }," - expect(shouldShowOnlyFirstLine(prefix, suggestion)).toBe(false) - }) -}) - -describe("countLines", () => { - it("returns 0 for empty string", () => { - expect(countLines("")).toBe(0) - }) - - it("counts mixed \\n and \\r\\n correctly", () => { - expect(countLines("l1\nl2\r\nl3")).toBe(3) - }) - - it("does not count trailing newline as an additional line", () => { - expect(countLines("l1\nl2\n")).toBe(2) - }) -}) - -describe("getFirstLine", () => { - it("returns the entire text when there is no newline", () => { - expect(getFirstLine("console.log('test');")).toBe("console.log('test');") - }) - - it.each([ - ["\\n", "first line\nsecond line"], - ["\\r\\n", "first line\r\nsecond line"], - ])("returns first line for %s line endings", (_label, text) => { - expect(getFirstLine(text)).toBe("first line") - }) - - it("returns empty string when text starts with a newline", () => { - expect(getFirstLine("\nsecond line")).toBe("") - }) -}) - -describe("applyFirstLineOnly", () => { - it("returns null when input is null", () => { - expect(applyFirstLineOnly(null, "const x = foo")).toBeNull() - }) - - it("returns result unchanged when text is empty", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "", - prefix: "const x = ", - suffix: ";", - } - const input = { text: "", matchType: "exact" as const, fillInAtCursor } - const result = applyFirstLineOnly(input, "const x = foo") - expect(result).not.toBeNull() - expect(result!.text).toBe("") - expect(result!.matchType).toBe("exact") - }) - - it("truncates to first line and preserves matchType when enabled", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "line1\nline2\nline3", - prefix: "const x = ", - suffix: ";", - } - const result = applyFirstLineOnly( - { - text: "line1\nline2\nline3", - matchType: "partial_typing", - fillInAtCursor, - }, - "const x = foo", - ) - expect(result).not.toBeNull() - expect(result!.text).toBe("line1") - expect(result!.matchType).toBe("partial_typing") - }) - - it("preserves original fillInAtCursor when truncating (for consistent telemetry keys)", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "line1\nline2\nline3", - prefix: "const x = ", - suffix: ";", - } - const result = applyFirstLineOnly( - { - text: "line1\nline2\nline3", - matchType: "exact", - fillInAtCursor, - }, - "const x = foo", // mid-line prefix triggers truncation - ) - expect(result).not.toBeNull() - expect(result!.text).toBe("line1") - // The fillInAtCursor should remain unchanged for consistent telemetry tracking - // (same suggestion should generate same key regardless of truncation) - expect(result!.fillInAtCursor.text).toBe("line1\nline2\nline3") - // prefix and suffix should be preserved - expect(result!.fillInAtCursor.prefix).toBe("const x = ") - expect(result!.fillInAtCursor.suffix).toBe(";") - }) - - it("preserves fillInAtCursor when no truncation occurs", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "singleLine", - prefix: "prefix", - suffix: "suffix", - } - const result = applyFirstLineOnly( - { - text: "singleLine", - matchType: "exact", - fillInAtCursor, - }, - "const x = foo", // mid-line but single-line suggestion - ) - expect(result).not.toBeNull() - expect(result!.text).toBe("singleLine") - // fillInAtCursor should be unchanged since no truncation happened - expect(result!.fillInAtCursor).toStrictEqual(fillInAtCursor) - }) - - it("does not truncate when suggestion starts with newline", () => { - const fillInAtCursor: FillInAtCursorSuggestion = { - text: "\nline1\nline2", - prefix: "const x = ", - suffix: ";", - } - const result = applyFirstLineOnly({ text: "\nline1\nline2", matchType: "exact", fillInAtCursor }, "const x = foo") - expect(result).not.toBeNull() - expect(result!.text).toBe("\nline1\nline2") - expect(result!.matchType).toBe("exact") - }) -}) - -describe("stringToInlineCompletions", () => { - it("should return empty array when text is empty string", () => { - const position = new vscode.Position(0, 10) - const result = stringToInlineCompletions("", position) - - expect(result).toEqual([]) - }) - - it("should return inline completion item when text is non-empty", () => { - const position = new vscode.Position(0, 10) - const text = "console.log('test');" - const result = stringToInlineCompletions(text, position) - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe(text) - expect(result[0].range).toEqual(new vscode.Range(position, position)) - }) - - it("should create range at the specified position", () => { - const position = new vscode.Position(5, 20) - const text = "some code" - const result = stringToInlineCompletions(text, position) - - expect(result[0].range).toEqual(new vscode.Range(position, position)) - }) - - it("should handle multi-line text", () => { - const position = new vscode.Position(0, 0) - const text = "line1\nline2\nline3" - const result = stringToInlineCompletions(text, position) - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe(text) - }) -}) - -describe("AutocompleteInlineCompletionProvider", () => { - let provider: AutocompleteInlineCompletionProvider - let mockDocument: vscode.TextDocument - let mockPosition: vscode.Position - let mockContext: vscode.InlineCompletionContext - let mockToken: vscode.CancellationToken - let mockModel: AutocompleteModel - let mockCostTrackingCallback: CostTrackingCallback - let mockSettings: { enableAutoTrigger: boolean } | null - let mockExtensionContext: vscode.ExtensionContext - let mockClineProvider: { cwd: string } - let mockTelemetry: AutocompleteTelemetry - - // Helper to call provideInlineCompletionItems and advance timers - // With leading edge debounce, first call executes immediately, subsequent calls wait for 300ms of inactivity - async function provideWithDebounce( - doc: vscode.TextDocument, - pos: vscode.Position, - ctx: vscode.InlineCompletionContext, - token: vscode.CancellationToken, - ) { - const promise = provider.provideInlineCompletionItems(doc, pos, ctx, token) - await vi.advanceTimersByTimeAsync(300) // Advance past debounce delay for any pending calls - return promise - } - - beforeEach(() => { - vi.useFakeTimers() - mockDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - mockPosition = new vscode.Position(0, 11) // After "const x = 1" - mockContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Invoke, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - mockToken = {} as vscode.CancellationToken - mockSettings = { enableAutoTrigger: true } - - // Create mock extension context - mockExtensionContext = { - subscriptions: [], - workspaceState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn().mockReturnValue([]), - }, - globalState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn().mockReturnValue([]), - setKeysForSync: vi.fn(), - }, - extensionPath: "/mock/extension/path", - extensionUri: vscode.Uri.file("/mock/extension/path"), - storagePath: "/mock/storage/path", - storageUri: vscode.Uri.file("/mock/storage/path"), - globalStoragePath: "/mock/global/storage/path", - globalStorageUri: vscode.Uri.file("/mock/global/storage/path"), - logPath: "/mock/log/path", - logUri: vscode.Uri.file("/mock/log/path"), - extensionMode: 1, // Development - asAbsolutePath: vi.fn((relativePath: string) => `/mock/extension/path/${relativePath}`), - secrets: { - get: vi.fn(), - store: vi.fn(), - delete: vi.fn(), - onDidChange: vi.fn(), - }, - environmentVariableCollection: {} as any, - extension: {} as any, - languageModelAccessInformation: {} as any, - } as unknown as vscode.ExtensionContext - - vi.spyOn(AutocompleteContextProviderModule, "getProcessedSnippets").mockResolvedValue({ - filepathUri: "file:///test.ts", - helper: { - filepath: "file:///test.ts", - lang: { name: "typescript", singleLineComment: "//" }, - prunedPrefix: "const x = 1", - prunedSuffix: "\nconst y = 2", - }, - snippetsWithUris: [], - workspaceDirs: [], - }) - - // Create mock dependencies - mockModel = { - generateFimResponse: vi.fn().mockResolvedValue({ - cost: 0, - inputTokens: 0, - outputTokens: 0, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }), - getModelName: vi.fn().mockReturnValue("test-model"), - getProviderDisplayName: vi.fn().mockReturnValue("test-provider"), - hasValidCredentials: vi.fn().mockReturnValue(true), // Default to true for tests - } as unknown as AutocompleteModel - mockCostTrackingCallback = vi.fn() as CostTrackingCallback - mockClineProvider = { cwd: "/test/workspace" } - mockTelemetry = new AutocompleteTelemetry() - - provider = new AutocompleteInlineCompletionProvider( - mockExtensionContext, - mockModel, - mockCostTrackingCallback, - () => mockSettings, - mockClineProvider as any, - mockTelemetry, - ) - }) - - afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - }) - - describe("provideInlineCompletionItems", () => { - it("should return empty array when no suggestions are set", async () => { - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result).toHaveLength(0) - }) - - it("should return empty array when suggestions have no FIM content", async () => { - provider.updateSuggestions({ - text: "", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should return inline completion item when FIM content is available and prefix/suffix match", async () => { - const fimContent = { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - } - provider.updateSuggestions(fimContent) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe(fimContent.text) - expect(result[0].range).toEqual(new vscode.Range(mockPosition, mockPosition)) - // Command is attached to track acceptance telemetry - expect(result[0].command).toEqual({ - command: "kilocode.autocomplete.inline-completion.accepted", - title: "Autocomplete Accepted", - }) - }) - - it("should truncate cached multi-line suggestions to first line when cursor is mid-line", async () => { - provider.updateSuggestions({ - text: "line1\nline2\nline3", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("line1") - }) - - it("should return empty array when prefix does not match", async () => { - const fimContent = { - text: "console.log('Hello, World!');", - prefix: "different prefix", - suffix: "\nconst y = 2", - } - provider.updateSuggestions(fimContent) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should return empty array when suffix does not match", async () => { - const fimContent = { - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "different suffix", - } - provider.updateSuggestions(fimContent) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should update suggestions when called multiple times", async () => { - provider.updateSuggestions({ - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - let result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("first suggestion") - - provider.updateSuggestions({ - text: "second suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("second suggestion") - }) - it("should maintain a rolling window of suggestions and match from most recent", async () => { - // Add first suggestion - provider.updateSuggestions({ - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add second suggestion with different context - provider.updateSuggestions({ - text: "second suggestion", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }) - - // Should match the first suggestion when context matches - let result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("first suggestion") - - // Should match the second suggestion when context matches - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - result = (await provideWithDebounce( - mockDocument2, - mockPosition2, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("second suggestion") - }) - - it("should prefer most recent matching suggestion when multiple match", async () => { - // Add first suggestion - provider.updateSuggestions({ - text: "first suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add second suggestion with same context - provider.updateSuggestions({ - text: "second suggestion", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Should return the most recent (second) suggestion - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("second suggestion") - }) - - it("should maintain only the last 20 suggestions (FIFO)", async () => { - // Add 25 suggestions - for (let i = 0; i < 25; i++) { - provider.updateSuggestions({ - text: `suggestion ${i}`, - prefix: `const x${i} = 1`, - suffix: `\nconst y${i} = 2`, - }) - } - - // The first 5 suggestions should be removed (0-4) - // Try to match suggestion 0 (should not be found, so LLM is called and returns empty) - const mockDocument0 = new MockTextDocument(vscode.Uri.file("/test0.ts"), "const x0 = 1\nconst y0 = 2") - const mockPosition0 = new vscode.Position(0, 12) - let result = (await provideWithDebounce( - mockDocument0, - mockPosition0, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result).toHaveLength(0) - - // Try to match suggestion 10 (should be found - it's in the middle of the window) - const mockDocument10 = new MockTextDocument(vscode.Uri.file("/test10.ts"), "const x10 = 1\nconst y10 = 2") - const mockPosition10 = new vscode.Position(0, 13) - result = (await provideWithDebounce( - mockDocument10, - mockPosition10, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - // Suggestion 10 should be found (it's in the cache window) - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("suggestion 10") - - // Try to match suggestion 24 (should be found - it's the most recent) - const mockDocument24 = new MockTextDocument(vscode.Uri.file("/test24.ts"), "const x24 = 1\nconst y24 = 2") - const mockPosition24 = new vscode.Position(0, 13) - result = (await provideWithDebounce( - mockDocument24, - mockPosition24, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result[0].insertText).toBe("suggestion 24") - }) - it("should not add duplicate suggestions", async () => { - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Try to add the same suggestion again - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add a different suggestion - provider.updateSuggestions({ - text: "console.log('different')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Should return the most recent non-duplicate suggestion - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should get the different suggestion (suggestions3), not the duplicate - expect(result[0].insertText).toBe("console.log('different')") - }) - - it("should allow same text with different prefix/suffix", async () => { - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Same text but different context - should be added - provider.updateSuggestions({ - text: "console.log('test')", - prefix: "const a = 1", - suffix: "\nconst b = 2", - }) - - // Should match the second suggestion when context matches - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const result = (await provideWithDebounce( - mockDocument2, - mockPosition2, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result[0].insertText).toBe("console.log('test')") - }) - - describe("partial typing support", () => { - it("should return remaining suggestion when user has partially typed the suggestion", async () => { - // Set up a suggestion - provider.updateSuggestions({ - text: "console.log('Hello, World!');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Simulate user typing "cons" after the prefix - const partialDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 2") - const partialPosition = new vscode.Position(0, 15) // After "const x = 1cons" - - const result = (await provideWithDebounce( - partialDocument, - partialPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - // Should return the remaining part after "cons" - expect(result[0].insertText).toBe("ole.log('Hello, World!');") - }) - - it("should return full suggestion when user has typed nothing after prefix", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User is at exact prefix position (no partial typing) - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("console.log('test');") - }) - - it("should return empty when partially typed content does not match suggestion", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed "xyz" which doesn't match the suggestion - const mismatchDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1xyz\nconst y = 2") - const mismatchPosition = new vscode.Position(0, 14) - - const result = (await provideWithDebounce( - mismatchDocument, - mismatchPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should return empty string when user has typed entire suggestion", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User has typed the entire suggestion - cursor is at the end of typed text - // Position 31 is right after the semicolon, before the newline - const completeDocument = new MockTextDocument( - vscode.Uri.file("/test.ts"), - "const x = 1console.log('test');\nconst y = 2", - ) - const completePosition = new vscode.Position(0, 31) // After the semicolon, before newline - - const result = (await provideWithDebounce( - completeDocument, - completePosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array since everything is typed (empty string match) - expect(result).toHaveLength(0) - }) - - it("should not match when suffix has changed", async () => { - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed partial content but suffix changed - const changedSuffixDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 3") - const changedSuffixPosition = new vscode.Position(0, 15) - - const result = (await provideWithDebounce( - changedSuffixDocument, - changedSuffixPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(0) - }) - - it("should prefer exact match over partial match", async () => { - // Add a suggestion that would match partially - provider.updateSuggestions({ - text: "console.log('partial');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Add a suggestion with exact match (more recent) - provider.updateSuggestions({ - text: "exact match", - prefix: "const x = 1cons", - suffix: "\nconst y = 2", - }) - - // User is at position that matches exact prefix of second suggestion - const document = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 2") - const position = new vscode.Position(0, 15) - - const result = (await provideWithDebounce( - document, - position, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - // Should return exact match (most recent), not partial - expect(result[0].insertText).toBe("exact match") - }) - - it("should handle multi-character partial typing", async () => { - provider.updateSuggestions({ - text: "function test() { return 42; }", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed "function te" - const partialDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1function te\nconst y = 2") - const partialPosition = new vscode.Position(0, 22) - - const result = (await provideWithDebounce( - partialDocument, - partialPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("st() { return 42; }") - }) - - it("should handle case-sensitive partial matching", async () => { - provider.updateSuggestions({ - text: "Console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // User typed "cons" (lowercase) but suggestion starts with "Console" (uppercase) - const partialDocument = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1cons\nconst y = 2") - const partialPosition = new vscode.Position(0, 15) - - const result = (await provideWithDebounce( - partialDocument, - partialPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should not match due to case difference, so LLM is called and returns empty - expect(result).toHaveLength(0) - }) - }) - - describe("dispose", () => { - it("should clear pending debounce timer when disposed", async () => { - // First call executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Second call should set a debounce timer - const promise = provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Verify timer is set for trailing edge - const timerCountBeforeDispose = vi.getTimerCount() - expect(timerCountBeforeDispose).toBeGreaterThan(0) - - // Dispose the provider before timer fires - provider.dispose() - - // Verify timer is cleared and the pending request settles - const timerCountAfterDispose = vi.getTimerCount() - expect(timerCountAfterDispose).toBeLessThan(timerCountBeforeDispose) - await expect(promise).resolves.toEqual([]) - }) - }) - }) - - describe("updateSuggestions", () => { - it("should accept new suggestions state", async () => { - provider.updateSuggestions({ - text: "new content", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("new content") - }) - }) - - describe("auto-trigger settings", () => { - it("should respect enableAutoTrigger setting when auto-triggered", async () => { - // Set auto-trigger to false - mockSettings = { enableAutoTrigger: false } - - // Change context to automatic trigger - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Should return empty array because auto-trigger is disabled - expect(result).toEqual([]) - // Model should not be called - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should block manual trigger when auto-trigger is disabled (defense in depth)", async () => { - // Set auto-trigger to false - mockSettings = { enableAutoTrigger: false } - - // Manual trigger (Invoke) - const manualContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Invoke, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - const result = await provideWithDebounce(mockDocument, mockPosition, manualContext, mockToken) - - // Should return empty array as defense in depth, even for manual triggers - // The provider should be deregistered at the manager level when disabled - expect(result).toEqual([]) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should read settings dynamically on each call", async () => { - // Start with auto-trigger enabled - mockSettings = { enableAutoTrigger: true } - - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - // First call with auto-trigger enabled - await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Change settings to disable auto-trigger - mockSettings = { enableAutoTrigger: false } - - // Second call should respect the new settings - const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Should not call model again because auto-trigger is now disabled - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - expect(result).toEqual([]) - }) - - it("should handle null settings gracefully", async () => { - // Set settings to null - mockSettings = null - - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Should default to false (disabled) when settings are null - expect(result).toEqual([]) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should allow auto-trigger when explicitly enabled", async () => { - // Set auto-trigger to true - mockSettings = { enableAutoTrigger: true } - - const autoContext = { - triggerKind: vscode.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } as vscode.InlineCompletionContext - - await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken) - - // Model should be called because auto-trigger is enabled - expect(mockModel.generateFimResponse).toHaveBeenCalled() - }) - }) - - describe("failed lookups cache", () => { - it("should cache failed LLM lookups and not call LLM again for same prefix/suffix", async () => { - // Mock the model to return empty suggestions - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - - // First call - should invoke LLM - const result1 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result1).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - expect(mockCostTrackingCallback).toHaveBeenCalledWith(0.01, 100, 50) - - // Second call with same prefix/suffix - should NOT invoke LLM - vi.mocked(mockModel.generateFimResponse).mockClear() - vi.mocked(mockCostTrackingCallback).mockClear() - - const result2 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result2).toHaveLength(0) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - expect(mockCostTrackingCallback).not.toHaveBeenCalled() - }) - - it("should not cache successful LLM lookups in failed cache", async () => { - // Mock the model to return a successful suggestion using proper COMPLETION format - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - // Simulate streaming FIM response - if (onChunk) { - onChunk("console.log('success');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - should invoke LLM and get a suggestion - const result1 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result1.length).toBeGreaterThan(0) - expect(callCount).toBe(1) - - // Second call with same prefix/suffix - should use suggestion cache, not failed cache - const result2 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result2.length).toBeGreaterThan(0) - // Should still be 1 - not called again - expect(callCount).toBe(1) - }) - - it("should cache different prefix/suffix combinations separately", async () => { - // Mock the model to return empty suggestions - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call with first prefix/suffix - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call with different prefix/suffix - should invoke LLM - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - - await provideWithDebounce(mockDocument2, mockPosition2, mockContext, mockToken) - expect(callCount).toBe(2) - - // Third call with first prefix/suffix again - should NOT invoke LLM (cached in failed cache) - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(2) - - // Fourth call with second prefix/suffix again - should NOT invoke LLM (cached in failed cache) - await provideWithDebounce(mockDocument2, mockPosition2, mockContext, mockToken) - expect(callCount).toBe(2) - }) - - it("should maintain only the last 50 failed lookups (FIFO)", async () => { - // Mock the model to return empty suggestions - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0, - inputTokens: 0, - outputTokens: 0, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // Add 55 failed lookups - for (let i = 0; i < 55; i++) { - const doc = new MockTextDocument(vscode.Uri.file(`/test${i}.ts`), `const x${i} = 1\nconst y${i} = 2`) - // Position is after "const x{i} = 1" which is 11 + length of i - const pos = new vscode.Position(0, 11 + i.toString().length) - await provideWithDebounce(doc, pos, mockContext, mockToken) - } - - expect(callCount).toBe(55) - - // The first 5 failed lookups should be removed (0-4) - // Try lookup 0 again - should invoke LLM (not cached anymore) - const doc0 = new MockTextDocument(vscode.Uri.file("/test0.ts"), "const x0 = 1\nconst y0 = 2") - const pos0 = new vscode.Position(0, 12) // After "const x0 = 1" - await provideWithDebounce(doc0, pos0, mockContext, mockToken) - expect(callCount).toBe(56) // Should have been called again - - // Try lookup 5 - should NOT invoke LLM (still cached) - const doc5 = new MockTextDocument(vscode.Uri.file("/test5.ts"), "const x5 = 1\nconst y5 = 2") - const pos5 = new vscode.Position(0, 12) // After "const x5 = 1" - await provideWithDebounce(doc5, pos5, mockContext, mockToken) - // Note: This actually gets called because the exact prefix/suffix combination is slightly different - // due to how positions are calculated, but that's okay - the important thing is that - // entries 0-4 were evicted and entry 5 is still in the cache (even if recalculated) - expect(callCount).toBe(57) - - // Try lookup 54 (most recent) - should NOT invoke LLM (still cached) - const doc54 = new MockTextDocument(vscode.Uri.file("/test54.ts"), "const x54 = 1\nconst y54 = 2") - const pos54 = new vscode.Position(0, 13) // After "const x54 = 1" - await provideWithDebounce(doc54, pos54, mockContext, mockToken) - expect(callCount).toBe(57) // Should not have been called (but gets called due to position mismatch) - }) - - it("should not add duplicate failed lookups", async () => { - // Mock the model to return empty suggestions - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0, - inputTokens: 0, - outputTokens: 0, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - - // First call - adds to failed cache - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Second call - should use cache, not add duplicate - vi.mocked(mockModel.generateFimResponse).mockClear() - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - - // Third call - should still use cache - vi.mocked(mockModel.generateFimResponse).mockClear() - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should return empty result with zero cost when using failed cache", async () => { - // Mock the model to return empty suggestions - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 10, - cacheReadTokens: 20, - }) - - // First call - should invoke LLM - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockCostTrackingCallback).toHaveBeenCalledWith(0.01, 100, 50) - - // Second call - should use failed cache with zero cost - vi.mocked(mockCostTrackingCallback).mockClear() - await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken) - expect(mockCostTrackingCallback).not.toHaveBeenCalled() - }) - }) - - describe("useless suggestion filtering", () => { - it("should refuse suggestions that match the end of prefix", async () => { - // Mock the model to return a suggestion that matches the end of prefix - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("= 1") // This matches the end of "const x = 1" - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array because the suggestion is useless - expect(result).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should refuse suggestions that match the start of suffix", async () => { - // Mock the model to return a suggestion that matches the start of suffix - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("\nconst") // This matches the start of "\nconst y = 2" - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array because the suggestion is useless - expect(result).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should accept useful suggestions that don't match prefix end or suffix start", async () => { - // Mock the model to return a useful suggestion - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("\nconsole.log('useful');") // Useful suggestion - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return the suggestion because it's useful - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("\nconsole.log('useful');") - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should cache refused suggestions as empty to avoid repeated LLM calls", async () => { - // Mock the model to return a useless suggestion - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - if (onChunk) { - onChunk("= 1") // Matches end of prefix - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - should invoke LLM and refuse the suggestion - const result1 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result1).toHaveLength(0) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Second call with same prefix/suffix - should use cache, not call LLM - vi.mocked(mockModel.generateFimResponse).mockClear() - const result2 = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - expect(result2).toHaveLength(0) - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - }) - - describe("credentials validation", () => { - it("should return empty array when model has no valid credentials", async () => { - // Set hasValidCredentials to return false - vi.mocked(mockModel.hasValidCredentials).mockReturnValue(false) - - // Set up a suggestion that would normally be returned - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return empty array because credentials are not valid - expect(result).toHaveLength(0) - // Model should not be called - expect(mockModel.generateFimResponse).not.toHaveBeenCalled() - }) - - it("should not attempt an LLM call if credentials become invalid before the debounced fetch executes", async () => { - // First call executes immediately (leading edge) - just to move provider into debounced mode - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - - // Second call will be debounced. Simulate a model reload happening before the timer fires. - vi.mocked(mockModel.hasValidCredentials).mockReturnValue(false) - - const promise = provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Let the trailing-edge debounce fire - await vi.advanceTimersByTimeAsync(300) - await promise - - // If fetch-time validation is working, we do not call generateFimResponse again. - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should return suggestions when model has valid credentials", async () => { - // Ensure hasValidCredentials returns true - vi.mocked(mockModel.hasValidCredentials).mockReturnValue(true) - - // Set up a suggestion - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - mockDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return the suggestion because credentials are valid - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("console.log('test');") - }) - }) - - describe("untitled document handling", () => { - it("should provide completions for untitled documents", async () => { - // Create an untitled document using MockTextDocument - const untitledDocument = new MockTextDocument(vscode.Uri.parse("untitled:Untitled-1"), "const x = 1\nconst y = 2") - // Override isUntitled property - Object.defineProperty(untitledDocument, "isUntitled", { - value: true, - writable: false, - }) - - // Set up a suggestion - provider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - const result = (await provideWithDebounce( - untitledDocument, - mockPosition, - mockContext, - mockToken, - )) as vscode.InlineCompletionItem[] - - // Should return the completion because untitled documents are always allowed - expect(result).toHaveLength(1) - expect(result[0].insertText).toBe("console.log('test');") - }) - }) - - describe("pending request reuse", () => { - it("should reuse pending request when user types forward (prefix extends, suffix unchanged)", async () => { - // Mock the model to track call count - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: user at "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) // After "const x = 1" - - // Start first request but don't await it yet - const promise1 = provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - - // Advance time partially (not past debounce) - await vi.advanceTimersByTimeAsync(100) - - // Second request: user typed "c" - prefix extended, suffix unchanged - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1c\nconst y = 2") - const pos2 = new vscode.Position(0, 12) // After "const x = 1c" - - // Start second request - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // Advance time past debounce to let requests complete - await vi.advanceTimersByTimeAsync(500) - - // Wait for both promises - await promise1 - await promise2 - - // The model should only have been called once because the second request - // should have reused the pending request from the first - expect(callCount).toBe(1) - }) - - it("should reuse slow leading-edge request when user types forward before it completes", async () => { - // Mock the model with a slow response that takes 500ms - let callCount = 0 - let resolvers: Array<() => void> = [] - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - // Simulate a slow FIM response — wait for manual resolution - await new Promise((resolve) => { - resolvers.push(resolve) - }) - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: leading edge fires immediately for "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) - const promise1 = provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - - // Leading edge should have started the request immediately - expect(callCount).toBe(1) - - // User types "c" while the leading-edge request is still in-flight - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1c\nconst y = 2") - const pos2 = new vscode.Position(0, 12) - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // The second request should NOT have triggered a new FIM call — - // it should reuse the leading-edge pending request since the prefix - // extends and the suffix is unchanged - expect(callCount).toBe(1) - - // Now resolve the FIM response - resolvers[0]() - await vi.advanceTimersByTimeAsync(500) - - await promise1 - await promise2 - - // Only one FIM request should have been made total - expect(callCount).toBe(1) - }) - - it("should NOT reuse pending request when suffix changes", async () => { - // Mock the model to track call count - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: user at "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) - - // Start first request - with leading edge, this executes immediately - await provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - expect(callCount).toBe(1) // First call executed immediately (leading edge) - - // Second request: suffix changed (different text after cursor) - // This cannot reuse the first request because suffix changed - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst z = 3") - const pos2 = new vscode.Position(0, 11) - - // Start second request - this will be debounced (not leading edge anymore) - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance time past debounce to let the second request complete - await vi.advanceTimersByTimeAsync(300) - await promise2 - - // The model should have been called twice: - // 1. First request executed immediately (leading edge) - // 2. Second request executed after debounce (different suffix, couldn't reuse) - // The key point is that the second request was NOT reused from the first - // because the suffix changed - it started a new debounce cycle - expect(callCount).toBe(2) - }) - - it("should settle canceled debounced requests when the timer is reset", async () => { - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst z = 3") - const promise2 = provider.provideInlineCompletionItems(doc2, mockPosition, mockContext, mockToken) - - await vi.advanceTimersByTimeAsync(100) - - const doc3 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst q = 4") - const promise3 = provider.provideInlineCompletionItems(doc3, mockPosition, mockContext, mockToken) - - await expect(promise2).resolves.toEqual([]) - - await vi.advanceTimersByTimeAsync(500) - await promise3 - }) - - it("should NOT reuse pending request when user backspaces (prefix shrinks)", async () => { - // Mock the model to track call count - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - callCount++ - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First request: user at "const x = 1" - const doc1 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = 1\nconst y = 2") - const pos1 = new vscode.Position(0, 11) - - // Start first request (don't await - it will be cancelled by the second request) - provider.provideInlineCompletionItems(doc1, pos1, mockContext, mockToken) - - // Advance time partially (not past debounce) - await vi.advanceTimersByTimeAsync(100) - - // Second request: user backspaced - prefix is now shorter - // This will cancel the first request's debounce timer and start a new one - const doc2 = new MockTextDocument(vscode.Uri.file("/test.ts"), "const x = \nconst y = 2") - const pos2 = new vscode.Position(0, 10) // After "const x = " - - // Start second request - const promise2 = provider.provideInlineCompletionItems(doc2, pos2, mockContext, mockToken) - - // Advance time past debounce to let the second request complete - await vi.advanceTimersByTimeAsync(500) - - await promise2 - - // The model should have been called once (only the second request fires, - // the first was cancelled by the debounce) - // But the key point is that the second request was NOT reused from the first - // because the prefix shrunk - it started a new debounce cycle - expect(callCount).toBe(1) - }) - }) - - describe("debounce with leading edge behavior", () => { - it("should execute immediately on first call (leading edge)", async () => { - vi.mocked(mockModel.generateFimResponse).mockResolvedValue({ - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - }) - - // First call should execute immediately without waiting - const promise = provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Model should be called immediately (no timer needed) - await promise - expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1) - }) - - it("should debounce subsequent calls (wait for 300ms of inactivity)", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call immediately after - should be debounced - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance time past debounce delay - await vi.advanceTimersByTimeAsync(300) - await promise2 - - // Now it should have been called - expect(callCount).toBe(2) - }) - - it("should reset debounce timer on each call (only execute after 300ms of inactivity)", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Multiple rapid calls - each resets the debounce timer - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - - const mockDocument3 = new MockTextDocument(vscode.Uri.file("/test3.ts"), "const c = 1\nconst d = 2") - const mockPosition3 = new vscode.Position(0, 11) - - // First debounced call - provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - expect(callCount).toBe(1) - - // Advance 150ms (half the debounce time) - await vi.advanceTimersByTimeAsync(150) - expect(callCount).toBe(1) - - // Second debounced call - resets the timer - const promise3 = provider.provideInlineCompletionItems(mockDocument3, mockPosition3, mockContext, mockToken) - expect(callCount).toBe(1) - - // Advance another 150ms (total 300ms from first debounced call, but only 150ms from second) - await vi.advanceTimersByTimeAsync(150) - expect(callCount).toBe(1) // Still not called because timer was reset - - // Advance remaining 150ms to complete the debounce from the last call - await vi.advanceTimersByTimeAsync(150) - await promise3 - expect(callCount).toBe(2) // Now it should be called - }) - - it("should allow immediate execution after debounce completes (new leading edge)", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call - debounced - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Wait for debounce to complete - await vi.advanceTimersByTimeAsync(300) - await promise2 - expect(callCount).toBe(2) - - // Third call after debounce completed - should execute immediately (new leading edge) - const mockDocument3 = new MockTextDocument(vscode.Uri.file("/test3.ts"), "const c = 1\nconst d = 2") - const mockPosition3 = new vscode.Position(0, 11) - await provider.provideInlineCompletionItems(mockDocument3, mockPosition3, mockContext, mockToken) - - // Should have executed immediately without waiting - expect(callCount).toBe(3) - }) - }) - - describe("adaptive debounce delay", () => { - it("should start with initial debounce delay of 300ms", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call - should be debounced with initial 300ms delay - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance 200ms - should still be waiting - await vi.advanceTimersByTimeAsync(200) - expect(callCount).toBe(1) - - // Advance remaining 100ms to complete the 300ms debounce - await vi.advanceTimersByTimeAsync(100) - await promise2 - expect(callCount).toBe(2) - }) - - it("should record latency and not update debounce delay until 10 samples collected", () => { - // Record 9 latencies - should not update debounce delay yet - for (let i = 0; i < 9; i++) { - provider.recordLatency(100 + i * 10) // 100, 110, 120, ..., 180 - } - - // Access private field via any cast for testing - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(9) - expect(providerAny.debounceDelayMs).toBe(300) // Still initial value - }) - - it("should update debounce delay to average after exceeding 10 samples", () => { - // Record 10 latencies of 200ms each - debounce delay not updated yet - for (let i = 0; i < 10; i++) { - provider.recordLatency(200) - } - - // Access private field via any cast for testing - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(10) - expect(providerAny.debounceDelayMs).toBe(300) // Still initial value (not updated until > 10) - - // Record 11th latency - now debounce delay is updated - provider.recordLatency(200) - expect(providerAny.latencyHistory.length).toBe(10) // Still 10 (oldest removed) - expect(providerAny.debounceDelayMs).toBe(200) // Now updated to average - }) - - it("should maintain rolling window of 10 latencies", () => { - // Record 15 latencies - for (let i = 0; i < 15; i++) { - provider.recordLatency(100 + i * 10) // 100, 110, 120, ..., 240 - } - - // Access private field via any cast for testing - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(10) // Only last 10 kept - - // Last 10 values should be 150, 160, 170, 180, 190, 200, 210, 220, 230, 240 - // Average = (150+160+170+180+190+200+210+220+230+240) / 10 = 195 - expect(providerAny.debounceDelayMs).toBe(195) - }) - - it("should update debounce delay on each new latency after exceeding 10 samples", () => { - // Record 11 latencies of 200ms each (need > 10 to trigger update) - for (let i = 0; i < 11; i++) { - provider.recordLatency(200) - } - - const providerAny = provider as any - expect(providerAny.debounceDelayMs).toBe(200) - - // Add one more latency of 300ms - // New average = (200*9 + 300) / 10 = 210 - provider.recordLatency(300) - expect(providerAny.debounceDelayMs).toBe(210) - - // Add another latency of 400ms - // New average = (200*8 + 300 + 400) / 10 = 230 - provider.recordLatency(400) - expect(providerAny.debounceDelayMs).toBe(230) - }) - - it("should use adaptive debounce delay after collecting enough samples", async () => { - let callCount = 0 - vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => { - callCount++ - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - // Record 11 latencies of 150ms each to set debounce delay to 150ms - // (need > 10 to trigger update) - for (let i = 0; i < 11; i++) { - provider.recordLatency(150) - } - - const providerAny = provider as any - expect(providerAny.debounceDelayMs).toBe(150) - - // First call - executes immediately (leading edge) - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - expect(callCount).toBe(1) - - // Second call - should be debounced with adaptive 150ms delay - const mockDocument2 = new MockTextDocument(vscode.Uri.file("/test2.ts"), "const a = 1\nconst b = 2") - const mockPosition2 = new vscode.Position(0, 11) - const promise2 = provider.provideInlineCompletionItems(mockDocument2, mockPosition2, mockContext, mockToken) - - // Should not have called yet (debounced) - expect(callCount).toBe(1) - - // Advance 100ms - should still be waiting (150ms debounce) - await vi.advanceTimersByTimeAsync(100) - expect(callCount).toBe(1) - - // Advance remaining 50ms to complete the 150ms debounce - await vi.advanceTimersByTimeAsync(50) - await promise2 - expect(callCount).toBe(2) - }) - - it("should record latency from LLM requests", async () => { - // Mock the model to simulate a delay - vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => { - // Simulate some processing time - if (onChunk) { - onChunk("console.log('test');") - } - return { - cost: 0.01, - inputTokens: 100, - outputTokens: 50, - cacheWriteTokens: 0, - cacheReadTokens: 0, - } - }) - - const providerAny = provider as any - expect(providerAny.latencyHistory.length).toBe(0) - - // Make a request that will record latency - await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - - // Latency should have been recorded - expect(providerAny.latencyHistory.length).toBe(1) - }) - }) - - describe("telemetry tracking", () => { - it("should track acceptance when suggestion is accepted via command", async () => { - // Capture the registered command callback by setting up mock before provider creation - let acceptCallback: (() => void) | undefined - const originalMock = vi.mocked(vscode.commands.registerCommand) - originalMock.mockImplementation((cmd, callback) => { - if (cmd === "kilocode.autocomplete.inline-completion.accepted") { - acceptCallback = callback as () => void - } - return { dispose: vi.fn() } - }) - - // Create new telemetry instance for this test - const testTelemetry = new AutocompleteTelemetry() - vi.spyOn(testTelemetry, "captureAcceptSuggestion") - - // Create new provider to capture the command - const testProvider = new AutocompleteInlineCompletionProvider( - mockExtensionContext, - mockModel, - mockCostTrackingCallback, - () => mockSettings, - mockClineProvider as any, - testTelemetry, - ) - - // Verify callback was captured - expect(acceptCallback).toBeDefined() - - // Set up and show a suggestion - testProvider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Call provideInlineCompletionItems to trigger trackSuggestionShown - const promise = testProvider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - await vi.advanceTimersByTimeAsync(300) - const result = await promise - - // Verify we got a suggestion (which means trackSuggestionShown was called) - expect(Array.isArray(result) ? result.length : 0).toBeGreaterThan(0) - - // Simulate accepting the suggestion - acceptCallback!() - - expect(testTelemetry.captureAcceptSuggestion).toHaveBeenCalled() - - // Cleanup - testProvider.dispose() - }) - - it("should work without telemetry when null is passed", async () => { - // Create provider without telemetry - const testProvider = new AutocompleteInlineCompletionProvider( - mockExtensionContext, - mockModel, - mockCostTrackingCallback, - () => mockSettings, - mockClineProvider as any, - null, - ) - - // Set up a suggestion - testProvider.updateSuggestions({ - text: "console.log('test');", - prefix: "const x = 1", - suffix: "\nconst y = 2", - }) - - // Should work without errors - const promise = testProvider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken) - await vi.advanceTimersByTimeAsync(300) - const result = await promise - - // Should still return suggestions - expect(Array.isArray(result) ? result.length : 0).toBeGreaterThan(0) - - // Cleanup - testProvider.dispose() - }) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts index b82d06d4abd..68b874a0b17 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts @@ -7,7 +7,6 @@ import { getAllSnippetsWithoutRace } from "../continuedev/core/autocomplete/snip import { getDefinitionsFromLsp } from "../continuedev/core/vscode-test-harness/src/autocomplete/lsp" import { DEFAULT_AUTOCOMPLETE_OPTS } from "../continuedev/core/util/parameters" import { getSnippets } from "../continuedev/core/autocomplete/templating/filtering" -import { AutocompleteModel } from "../AutocompleteModel" import { FileIgnoreController } from "../shims/FileIgnoreController" import { AutocompleteSnippet, AutocompleteSnippetType } from "../continuedev/core/autocomplete/types" @@ -68,7 +67,7 @@ export async function getProcessedSnippets( autocompleteInput: AutocompleteInput, filepath: string, contextService: ContextRetrievalService, - model: AutocompleteModel, + modelId: string, ide: VsCodeIde, ignoreController?: Promise, ): Promise<{ @@ -92,7 +91,7 @@ export async function getProcessedSnippets( filepath: filepathUri, } - const modelName = model.getModelName() ?? "codestral" + const modelName = modelId || "codestral" const helper = await HelperVars.create(helperInput as any, DEFAULT_AUTOCOMPLETE_OPTS, modelName, ide) const snippetPayload = await getAllSnippetsWithoutRace({ diff --git a/packages/kilo-vscode/src/services/autocomplete/fim.ts b/packages/kilo-vscode/src/services/autocomplete/fim.ts new file mode 100644 index 00000000000..8e5fcb429c6 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/fim.ts @@ -0,0 +1,77 @@ +import { ResponseMetaData } from "./types" +import type { KiloConnectionService } from "../cli-backend" +import { getAutocompleteModel } from "../../shared/autocomplete-models" + +/** + * Generate a FIM (Fill-in-the-Middle) completion via the CLI backend. + * Uses the SDK's kilo.fim() SSE endpoint which handles auth and streaming. + * + * @param signal - Optional AbortSignal to cancel the SSE stream early (e.g. when the user types again) + */ +export async function generateFim( + connectionService: KiloConnectionService, + modelId: string, + prefix: string, + suffix: string, + onChunk: (text: string) => void, + signal?: AbortSignal, +): Promise { + const client = await connectionService.getClientAsync() + + let cost = 0 + let inputTokens = 0 + let outputTokens = 0 + + // Capture SSE-level errors so they propagate to the caller. The SDK's SSE + // client catches HTTP errors (402, 401, 429, 5xx) internally and silently + // ends the stream. Without this, errors never reach ErrorBackoff. + let sseError: Error | undefined + + const temp = getAutocompleteModel(modelId).temperature + + const { stream } = await client.kilo.fim( + { + prefix, + suffix, + model: modelId, + maxTokens: 256, + temperature: temp, + }, + { + signal, + sseMaxRetryAttempts: 1, + onSseError: (error) => { + sseError = error instanceof Error ? error : new Error(String(error)) + }, + }, + ) + + for await (const chunk of stream) { + const choice = chunk.choices?.[0] + const content = choice?.delta?.content ?? choice?.text + if (content) onChunk(content) + if (chunk.usage) { + inputTokens = chunk.usage.prompt_tokens ?? 0 + outputTokens = chunk.usage.completion_tokens ?? 0 + } + if (chunk.cost !== undefined) cost = chunk.cost + } + + if (sseError) throw sseError + + return { + cost, + inputTokens, + outputTokens, + cacheWriteTokens: 0, + cacheReadTokens: 0, + } +} + +/** + * Check if the CLI backend is connected. The CLI manages credentials internally, + * so a connected state means we can issue FIM requests. + */ +export function hasValidCredentials(connectionService: KiloConnectionService): boolean { + return connectionService.getConnectionState() === "connected" +} diff --git a/packages/kilo-vscode/src/services/autocomplete/settings.ts b/packages/kilo-vscode/src/services/autocomplete/settings.ts index 029d4921291..9ec341406dc 100644 --- a/packages/kilo-vscode/src/services/autocomplete/settings.ts +++ b/packages/kilo-vscode/src/services/autocomplete/settings.ts @@ -1,12 +1,8 @@ import * as vscode from "vscode" import { AUTOCOMPLETE_MODELS, getAutocompleteModel } from "../../shared/autocomplete-models" -const keys = new Set(["enableAutoTrigger", "enableSmartInlineTaskKeybinding", "enableChatAutocomplete", "model"]) - type Message = { type: string - key?: unknown - value?: unknown } type Post = (msg: unknown) => void @@ -17,13 +13,6 @@ export async function routeAutocompleteMessage(message: Message, post: Post): Pr return true } - if (message.type === "updateAutocompleteSetting") { - if (await update(message.key, message.value)) { - post(buildAutocompleteSettingsMessage()) - } - return true - } - return false } @@ -49,23 +38,15 @@ export function watchAutocompleteConfig(post: Post): vscode.Disposable { }) } -async function update(key: unknown, value: unknown) { - if (typeof key !== "string") return false - if (!keys.has(key)) return false - if (!valid(key, value)) return false - - await vscode.workspace - .getConfiguration("kilo-code.new.autocomplete") - .update(key, value, vscode.ConfigurationTarget.Global) - - return true -} - -function valid(key: string, value: unknown) { +export function validAutocompleteSetting(key: string, value: unknown) { if (key === "model") { if (typeof value !== "string") return false return AUTOCOMPLETE_MODELS.some((m) => m.id === value) } - return typeof value === "boolean" + if (key === "enableAutoTrigger") return typeof value === "boolean" + if (key === "enableSmartInlineTaskKeybinding") return typeof value === "boolean" + if (key === "enableChatAutocomplete") return typeof value === "boolean" + + return false } diff --git a/packages/kilo-vscode/src/services/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/types.ts index 1fddf8b01e8..765f1d6f4b9 100644 --- a/packages/kilo-vscode/src/services/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/types.ts @@ -4,7 +4,6 @@ import type { Position, Range, RangeInFile } from "./continuedev/core" import type { FileIgnoreController } from "./shims/FileIgnoreController" import type { ContextRetrievalService } from "./continuedev/core/autocomplete/context/ContextRetrievalService" import type { VsCodeIde } from "./continuedev/core/vscode-test-harness/src/VSCodeIde" -import type { AutocompleteModel } from "./AutocompleteModel" export interface ResponseMetaData { cost: number @@ -81,7 +80,6 @@ export interface AutocompleteStatusBarStateProps { snoozed?: boolean model?: string provider?: string - profileName?: string | null hasNoUsableProvider?: boolean totalSessionCost: number completionCount: number @@ -212,6 +210,6 @@ export function contextToAutocompleteInput(context: AutocompleteSuggestionContex export interface AutocompleteContextProvider { contextService: ContextRetrievalService ide: VsCodeIde - model: AutocompleteModel + modelId: string ignoreController?: Promise } diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 17b4137d7fd..18c176c389a 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -104,6 +104,12 @@ export class ServerManager { ...(extraCaCerts && { NODE_EXTRA_CA_CERTS: extraCaCerts }), ...(!proxyStrictSSL && { NODE_TLS_REJECT_UNAUTHORIZED: "0" }), ...process.env, + // VS Code's http.proxy / http.noProxy settings are not reflected in + // process.env, so spawned children bypass the user's configured proxy + // and fail behind corporate firewalls. Forward them as the standard + // HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars that Bun's fetch and + // most HTTP clients already respect. + ...buildProxyEnv(), // Force mimalloc (the allocator Bun ships with) to return freed pages // to the OS immediately instead of retaining them in its arenas. // Without this, Bun.spawn's piped stdio accumulates ~2 MB of native @@ -257,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/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index bdd397327b4..ecb8cd2e4a8 100644 --- a/packages/kilo-vscode/src/services/commit-message/index.ts +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -65,6 +65,8 @@ export function registerCommitMessageService( const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined + let userCancelled = false + let timedOut = false const controller = new AbortController() await vscode.window @@ -76,13 +78,19 @@ export function registerCommitMessageService( }, async (_progress, token) => { // Wire VS Code cancellation to abort the HTTP request - token.onCancellationRequested(() => controller.abort()) + token.onCancellationRequested(() => { + userCancelled = true + controller.abort() + }) // Client-side safety timeout (35s) — slightly longer than the // server-side 30s timeout so the server can respond with a proper // error first, but still ensures the spinner never hangs forever. const timeout = 35_000 - const timer = setTimeout(() => controller.abort(), timeout) + const timer = setTimeout(() => { + timedOut = true + controller.abort() + }, timeout) try { const { data } = await client.commitMessage.generate( @@ -100,8 +108,13 @@ export function registerCommitMessageService( }, ) .then(undefined, (error: unknown) => { - if (controller.signal.aborted) { - console.log("[Kilo New] Commit message generation was cancelled or timed out") + if (userCancelled) { + console.log("[Kilo New] Commit message generation was cancelled by user") + return + } + if (timedOut) { + console.log("[Kilo New] Commit message generation timed out") + vscode.window.showErrorMessage("Commit message generation timed out. Please try again.") return } const msg = getErrorMessage(error) diff --git a/packages/kilo-vscode/src/services/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/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 9ab5ff42032..25a02adca2c 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -71,6 +71,16 @@ const mockVscode = { showTextDocument: async () => {}, showWarningMessage: async () => undefined, createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }), + createOutputChannel: () => ({ + name: "", + append: noop, + appendLine: noop, + replace: noop, + clear: noop, + show: noop, + hide: noop, + dispose: noop, + }), createStatusBarItem: () => ({ text: "", tooltip: "", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index cd51acefe52..6a4fc016b39 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -33,6 +33,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), + path.join(ROOT, "webview-ui/agent-manager/CurrentTabsMenu.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/SortableTerminalTab.tsx"), @@ -262,6 +263,15 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("syncOnSessionSwitch") }) + it("terminal context keeps the current active terminal when present", () => { + const text = body("onSessionMessage") + const check = text.indexOf("!this.terminalManager.hasActiveTerminal()") + const show = text.indexOf("this.terminalManager.showExisting(m.sessionID)") + expect(check).toBeGreaterThan(-1) + expect(show).toBeGreaterThan(-1) + expect(check, "active terminal check must guard session terminal reveal").toBeLessThan(show) + }) + it("session routing handles clearSession for SSE re-registration", () => { const text = body("onSessionMessage") expect(text).toContain("clearSession") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts new file mode 100644 index 00000000000..cba84607892 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test" +import { initialMessage, initialVariant, seedInitialVariant } from "../../webview-ui/agent-manager/initial-message" + +describe("Agent Manager initial message", () => { + it("forwards the selected variant to sendMessage", () => { + const msg = initialMessage({ + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + text: "Fix it", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + variant: "high", + }) + + expect(msg).toEqual({ + type: "sendMessage", + text: "Fix it", + sessionID: "session-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + variant: "high", + files: undefined, + }) + }) + + it("does not create an empty sendMessage payload", () => { + expect( + initialMessage({ + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + }), + ).toBeUndefined() + }) + + it("builds the initial session variant state", () => { + const state = initialVariant( + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + variant: "medium", + }, + "code", + ) + + expect(state).toEqual({ + sessionID: "session-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + value: "medium", + }) + }) + + it("does not build variant state without a complete model variant", () => { + expect( + initialVariant( + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + }, + "code", + ), + ).toBeUndefined() + }) + + it("seeds initial variant state into the session store", () => { + const calls: unknown[] = [] + + seedInitialVariant( + { + getSessionAgent: () => "code", + setSessionVariant: (...args) => calls.push(args), + }, + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + variant: "medium", + }, + ) + + expect(calls).toEqual([["session-a", "anthropic", "claude-sonnet-4", "medium", "code"]]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts new file mode 100644 index 00000000000..1996041e917 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-model-selector.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { + AUTOCOMPLETE_PROVIDER_ID, + AUTOCOMPLETE_PROVIDER_NAME, + AUTOCOMPLETE_SELECTOR_MODELS, +} from "../../webview-ui/src/components/settings/autocomplete-model-selector" +import { AUTOCOMPLETE_MODELS } from "../../src/shared/autocomplete-models" + +describe("autocomplete model selector", () => { + it("shows only Kilo Gateway autocomplete models", () => { + expect(AUTOCOMPLETE_SELECTOR_MODELS).toEqual( + AUTOCOMPLETE_MODELS.map((m) => ({ + id: m.id, + name: m.label, + providerID: AUTOCOMPLETE_PROVIDER_ID, + providerName: AUTOCOMPLETE_PROVIDER_NAME, + })), + ) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts index 8c353eb2b1a..16b433009b5 100644 --- a/packages/kilo-vscode/tests/unit/config-scope.test.ts +++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts @@ -13,4 +13,23 @@ describe("splitConfigByScope", () => { expect(split.global).toEqual({ indexing: { provider: "ollama" } }) expect(split.project).toEqual({ indexing: { enabled: true } }) }) + + it("writes indexing provider settings to global config", () => { + const split = splitConfigByScope({ + indexing: { + provider: "ollama", + }, + }) + + expect(split.global).toEqual({ indexing: { provider: "ollama" } }) + expect(split.project).toEqual({}) + }) + + it("can write indexing enablement to global config through a global draft", () => { + const split = splitConfigByScope({ username: "marius" }) + const draft = { indexing: { enabled: true } } + + expect({ ...split.global, ...draft }).toEqual({ username: "marius", indexing: { enabled: true } }) + expect(split.project).toEqual({}) + }) }) diff --git a/packages/kilo-vscode/tests/unit/diff-hash.test.ts b/packages/kilo-vscode/tests/unit/diff-hash.test.ts new file mode 100644 index 00000000000..a05416fadb7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-hash.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test" +import { hashFileDiffs } from "../../src/diff/shared/hash" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" + +type Diff = SnapshotFileDiff & { + tracked?: boolean + generatedLike?: boolean + summarized?: boolean + stamp?: string +} + +function diff(overrides: Partial = {}): Diff { + return { + file: "src/app.ts", + patch: "patch", + additions: 1, + deletions: 0, + status: "modified", + tracked: true, + generatedLike: false, + summarized: false, + stamp: "1:1", + ...overrides, + } as Diff +} + +describe("hashFileDiffs", () => { + it("returns empty string for an empty array", () => { + expect(hashFileDiffs([])).toBe("") + }) + + it("is stable for identical input", () => { + expect(hashFileDiffs([diff()])).toBe(hashFileDiffs([diff()])) + }) + + it("changes when any metadata field changes", () => { + const base = hashFileDiffs([diff()]) + expect(hashFileDiffs([diff({ file: "other.ts" })])).not.toBe(base) + expect(hashFileDiffs([diff({ status: "added" })])).not.toBe(base) + expect(hashFileDiffs([diff({ additions: 2 })])).not.toBe(base) + expect(hashFileDiffs([diff({ deletions: 5 })])).not.toBe(base) + expect(hashFileDiffs([diff({ tracked: false })])).not.toBe(base) + expect(hashFileDiffs([diff({ generatedLike: true })])).not.toBe(base) + expect(hashFileDiffs([diff({ stamp: "2:2" })])).not.toBe(base) + }) + + it("ignores patch content when summarized=true", () => { + // Regression guard: summary entries have unstable `patch` values from git + // (can even be ""). If this hashed the patch, the poller would spam the + // webview with "new" diffs every tick. + const a = hashFileDiffs([diff({ summarized: true, patch: "one" })]) + const b = hashFileDiffs([diff({ summarized: true, patch: "two" })]) + expect(a).toBe(b) + }) + + it("includes patch content when summarized=false", () => { + const a = hashFileDiffs([diff({ summarized: false, patch: "one" })]) + const b = hashFileDiffs([diff({ summarized: false, patch: "two" })]) + expect(a).not.toBe(b) + }) + + it("distinguishes summarized vs detail for the same metadata", () => { + // Flipping `summarized` must change the hash even when stamp/counts match — + // otherwise a summary-only re-fetch would silently keep stale detail. + const summary = hashFileDiffs([diff({ summarized: true })]) + const detail = hashFileDiffs([diff({ summarized: false })]) + expect(summary).not.toBe(detail) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts new file mode 100644 index 00000000000..b1c2a377147 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "bun:test" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import { + createSessionDiffSource, + sessionDescriptor, + type SessionDiffFetch, + type SnapshotEnabledCheck, +} from "../../src/diff/sources/session" + +type FetchCall = { sessionID: string; directory?: string } + +function recording(result: SnapshotFileDiff[] | Error): { fetch: SessionDiffFetch; calls: FetchCall[] } { + const calls: FetchCall[] = [] + const fetch: SessionDiffFetch = async (params) => { + calls.push(params) + if (result instanceof Error) throw result + return result + } + return { fetch, calls } +} + +const modifiedPatch = [ + "diff --git a/foo.ts b/foo.ts", + "--- a/foo.ts", + "+++ b/foo.ts", + "@@ -1,2 +1,2 @@", + " keep", + "-old", + "+new", +].join("\n") + +describe("createSessionDiffSource.fetch", () => { + it("returns empty diffs for an empty session", async () => { + const { fetch, calls } = recording([]) + const source = createSessionDiffSource("s1", fetch, "/repo") + + const result = await source.fetch() + + expect(calls).toEqual([{ sessionID: "s1", directory: "/repo" }]) + expect(result).toEqual({ diffs: [] }) + }) + + it("converts patches into before/after diffs", async () => { + const raw: SnapshotFileDiff[] = [ + { + file: "foo.ts", + patch: modifiedPatch, + additions: 1, + deletions: 1, + status: "modified", + }, + { + file: "big.bin", + patch: "", + additions: 0, + deletions: 0, + status: "modified", + }, + ] + const { fetch } = recording(raw) + const source = createSessionDiffSource("s2", fetch, "/repo") + + const result = await source.fetch() + + expect(result.diffs).toHaveLength(2) + + const foo = result.diffs[0]! + expect(foo.file).toBe("foo.ts") + expect(foo.before).toBe("keep\nold\n") + expect(foo.after).toBe("keep\nnew\n") + expect(foo.additions).toBe(1) + expect(foo.deletions).toBe(1) + expect(foo.status).toBe("modified") + expect(foo.tracked).toBe(true) + expect(foo.generatedLike).toBe(false) + expect(foo.summarized).toBe(false) + + const big = result.diffs[1]! + expect(big.summarized).toBe(true) + expect(big.before).toBe("") + expect(big.after).toBe("") + }) + + it("propagates errors from the underlying fetch", async () => { + const { fetch } = recording(new Error("network down")) + const source = createSessionDiffSource("s3", fetch) + + await expect(source.fetch()).rejects.toThrow("network down") + }) + + it("calls fetch without directory when workspaceRoot is not given", async () => { + const { fetch, calls } = recording([]) + const source = createSessionDiffSource("s4", fetch) + + await source.fetch() + + expect(calls).toEqual([{ sessionID: "s4", directory: undefined }]) + }) +}) + +describe("createSessionDiffSource descriptor", () => { + it("encodes the session id in the descriptor", () => { + const source = createSessionDiffSource("abc", recording([]).fetch) + expect(source.descriptor.id).toBe("session:abc") + expect(source.descriptor.group).toBe("Session") + expect(source.descriptor.capabilities).toEqual({ revert: false, comments: true }) + }) + + it("exposes a stable descriptor helper", () => { + expect(sessionDescriptor("xyz").id).toBe("session:xyz") + }) +}) + +describe("createSessionDiffSource snapshot check", () => { + it("returns the snapshots-disabled notice and skips fetch when the check returns false", async () => { + const { fetch, calls } = recording([ + { file: "foo.ts", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + ]) + const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => false + const source = createSessionDiffSource("s-disabled", fetch, "/repo", checkSnapshotsEnabled) + + const result = await source.fetch() + + expect(calls).toEqual([]) + expect(result).toEqual({ diffs: [], notice: "snapshots-disabled", stopPolling: true }) + }) + + it("caches the disabled state so subsequent fetches skip the config lookup", async () => { + const { fetch } = recording([]) + let checks = 0 + const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => { + checks++ + return false + } + const source = createSessionDiffSource("s-cache", fetch, "/repo", checkSnapshotsEnabled) + + await source.fetch() + await source.fetch() + + expect(checks).toBe(1) + }) + + it("fetches normally when snapshots are enabled", async () => { + const { fetch, calls } = recording([]) + const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => true + const source = createSessionDiffSource("s-enabled", fetch, "/repo", checkSnapshotsEnabled) + + const result = await source.fetch() + + expect(calls).toEqual([{ sessionID: "s-enabled", directory: "/repo" }]) + expect(result.notice).toBeUndefined() + expect(result.stopPolling).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts b/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts new file mode 100644 index 00000000000..49b3cc28ddd --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "bun:test" +import type { KiloConnectionService } from "../../src/services/cli-backend" +import { DiffSourceCatalog } from "../../src/diff/sources/catalog" +import { sessionDescriptor } from "../../src/diff/sources/session" +import { WORKSPACE_DESCRIPTOR } from "../../src/diff/sources/worktree" + +// Minimal stand-in for the connection service — the catalog only holds a +// reference and passes it to the source factories, so we never exercise any +// of its methods in these tests. +const connection = {} as unknown as KiloConnectionService + +function makeCatalog(): DiffSourceCatalog { + return new DiffSourceCatalog(connection) +} + +describe("DiffSourceCatalog.listAvailable", () => { + it("returns workspace + session when both are available", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: "/repo", sessionId: "s1" }) + expect(out.map((d) => d.id)).toEqual(["workspace", "session:s1"]) + }) + + it("returns only workspace when sessionId is missing", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: "/repo" }) + expect(out.map((d) => d.id)).toEqual(["workspace"]) + }) + + it("returns only session when workspaceRoot is missing", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: undefined, sessionId: "s1" }) + expect(out.map((d) => d.id)).toEqual(["session:s1"]) + }) + + it("returns [] when the context is empty", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: undefined }) + expect(out).toEqual([]) + }) + + it("returns [] when hidePicker is set, regardless of workspace/session", () => { + const out = makeCatalog().listAvailable({ workspaceRoot: "/repo", sessionId: "s1", hidePicker: true }) + expect(out).toEqual([]) + }) +}) + +describe("DiffSourceCatalog.defaultSourceId", () => { + it("prefers explicit initialSourceId", () => { + const id = makeCatalog().defaultSourceId({ + workspaceRoot: "/repo", + sessionId: "s1", + initialSourceId: "workspace", + }) + expect(id).toBe("workspace") + }) + + it("prefers workspace over session when both are present", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: "/repo", sessionId: "s1" }) + expect(id).toBe("workspace") + }) + + it("falls back to workspace when only workspaceRoot is present", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: "/repo" }) + expect(id).toBe("workspace") + }) + + it("falls back to session when only sessionId is present", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: undefined, sessionId: "s1" }) + expect(id).toBe("session:s1") + }) + + it("returns undefined when nothing can be inferred", () => { + const id = makeCatalog().defaultSourceId({ workspaceRoot: undefined }) + expect(id).toBeUndefined() + }) +}) + +describe("DiffSourceCatalog.build", () => { + it("builds a workspace source for 'workspace'", () => { + const src = makeCatalog().build("workspace", { workspaceRoot: "/repo" }) + expect(src.descriptor.id).toBe("workspace") + expect(src.descriptor.type).toBe("workspace") + expect(src.revert).toBeDefined() + expect(src.fetchFile).toBeDefined() + src.dispose?.() + }) + + it("builds a session source for 'session:'", () => { + const src = makeCatalog().build("session:s1", { workspaceRoot: "/repo", sessionId: "s1" }) + expect(src.descriptor.id).toBe("session:s1") + expect(src.descriptor.type).toBe("session") + expect(src.revert).toBeUndefined() + src.dispose?.() + }) + + it("builds a turn source for 'turn::'", () => { + const src = makeCatalog().build("turn:sess:msg", { workspaceRoot: "/repo" }) + expect(src.descriptor.id).toBe("turn:sess:msg") + expect(src.descriptor.type).toBe("turn") + expect(src.revert).toBeUndefined() + src.dispose?.() + }) + + it("throws on a malformed turn id", () => { + expect(() => makeCatalog().build("turn:sess", { workspaceRoot: "/repo" })).toThrow(/malformed turn id/) + expect(() => makeCatalog().build("turn:", { workspaceRoot: "/repo" })).toThrow(/malformed turn id/) + }) + + it("throws on an empty session id", () => { + expect(() => makeCatalog().build("session:", { workspaceRoot: "/repo" })).toThrow(/empty session id/) + }) + + it("throws on an unknown source id", () => { + expect(() => makeCatalog().build("bogus", { workspaceRoot: "/repo" })).toThrow(/unknown source id/) + }) +}) + +// The webview composes i18n keys from `type`. Keep the type values stable +// so a rename here doesn't silently break existing translation dicts. +describe("descriptor types", () => { + it("workspace descriptor has type 'workspace'", () => { + expect(WORKSPACE_DESCRIPTOR.type).toBe("workspace") + }) + + it("session descriptor has type 'session'", () => { + expect(sessionDescriptor("s1").type).toBe("session") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts b/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts new file mode 100644 index 00000000000..55e5055d07e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "bun:test" +import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" +import { + createTurnDiffSource, + turnDescriptor, + turnSourceId, + TURN_PREFIX, + type TurnDiffFetch, +} from "../../src/diff/sources/turn" + +type FetchCall = { sessionID: string; messageID: string; directory?: string } + +function recording(result: SnapshotFileDiff[] | Error): { fetch: TurnDiffFetch; calls: FetchCall[] } { + const calls: FetchCall[] = [] + const fetch: TurnDiffFetch = async (params) => { + calls.push(params) + if (result instanceof Error) throw result + return result + } + return { fetch, calls } +} + +const samplePatch = [ + "diff --git a/foo.ts b/foo.ts", + "--- a/foo.ts", + "+++ b/foo.ts", + "@@ -1,1 +1,1 @@", + "-old", + "+new", +].join("\n") + +describe("createTurnDiffSource.fetch", () => { + it("calls the fetch with sessionID + messageID + directory", async () => { + const { fetch, calls } = recording([]) + const source = createTurnDiffSource("sess", "msg", fetch, "/repo") + + await source.fetch() + + expect(calls).toEqual([{ sessionID: "sess", messageID: "msg", directory: "/repo" }]) + }) + + it("returns diffs with stopPolling=true so the controller skips polling", async () => { + const { fetch } = recording([ + { file: "foo.ts", patch: samplePatch, additions: 1, deletions: 1, status: "modified" }, + ]) + const source = createTurnDiffSource("sess", "msg", fetch) + + const result = await source.fetch() + + expect(result.stopPolling).toBe(true) + expect(result.notice).toBeUndefined() + expect(result.diffs).toHaveLength(1) + expect(result.diffs[0]!.file).toBe("foo.ts") + expect(result.diffs[0]!.before).toBe("old\n") + expect(result.diffs[0]!.after).toBe("new\n") + }) + + it("propagates underlying fetch errors", async () => { + const { fetch } = recording(new Error("backend unavailable")) + const source = createTurnDiffSource("sess", "msg", fetch) + + await expect(source.fetch()).rejects.toThrow("backend unavailable") + }) + + it("calls fetch without directory when workspaceRoot is not given", async () => { + const { fetch, calls } = recording([]) + const source = createTurnDiffSource("sess", "msg", fetch) + + await source.fetch() + + expect(calls).toEqual([{ sessionID: "sess", messageID: "msg", directory: undefined }]) + }) +}) + +describe("turn source descriptor + id helpers", () => { + it("encodes sessionId + messageId in the source id", () => { + expect(turnSourceId("abc", "42")).toBe(`${TURN_PREFIX}abc:42`) + }) + + it("produces a descriptor with type='turn' and no revert capability", () => { + const desc = turnDescriptor("abc", "42") + expect(desc.id).toBe("turn:abc:42") + expect(desc.type).toBe("turn") + expect(desc.capabilities).toEqual({ revert: false, comments: true }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/feedback-payload.test.ts b/packages/kilo-vscode/tests/unit/feedback-payload.test.ts new file mode 100644 index 00000000000..67d18caadb5 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/feedback-payload.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test" +import { buildFeedbackProperties } from "../../webview-ui/src/context/feedback-payload" + +const baseInput = { + messageID: "msg_abc", + sessionID: "ses_xyz", + parentMessageID: "msg_parent", + modelID: "claude-sonnet-4-5", + variant: undefined as string | undefined, +} + +describe("buildFeedbackProperties — non-Kilo providers", () => { + it("includes only provider/model/rating (no session or message IDs)", () => { + const props = buildFeedbackProperties({ ...baseInput, providerID: "anthropic", next: "up" }) + expect(props).toEqual({ + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + rating: "up", + }) + expect(props).not.toHaveProperty("sessionID") + expect(props).not.toHaveProperty("messageID") + expect(props).not.toHaveProperty("parentMessageID") + }) + + it("includes variant when set", () => { + const props = buildFeedbackProperties({ ...baseInput, providerID: "openai", variant: "preview", next: "down" }) + expect(props.variant).toBe("preview") + }) + + it("includes previousRating when provided", () => { + const props = buildFeedbackProperties({ ...baseInput, providerID: "anthropic", next: "down" }, "up") + expect(props.previousRating).toBe("up") + }) + + it("uses 'cleared' when next is null", () => { + const props = buildFeedbackProperties({ ...baseInput, providerID: "anthropic", next: null }, "up") + expect(props.rating).toBe("cleared") + expect(props.previousRating).toBe("up") + }) +}) + +describe("buildFeedbackProperties — Kilo Gateway", () => { + it("includes sessionID, messageID, parentMessageID", () => { + const props = buildFeedbackProperties({ ...baseInput, providerID: "kilo", next: "up" }) + expect(props).toEqual({ + providerID: "kilo", + modelID: "claude-sonnet-4-5", + rating: "up", + sessionID: "ses_xyz", + messageID: "msg_abc", + parentMessageID: "msg_parent", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts index 1a0172d0ab2..5a96ef7eb28 100644 --- a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts @@ -25,7 +25,7 @@ const TARGETS = [ const WATCHED_PROVIDERS = [ path.join(ROOT, "src/KiloProvider.ts"), - path.join(ROOT, "src/DiffViewerProvider.ts"), + path.join(ROOT, "src/diff/DiffViewerProvider.ts"), path.join(ROOT, "src/DiffVirtualProvider.ts"), path.join(ROOT, "src/kiloclaw/KiloClawProvider.ts"), ] diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index c1f8386e2bc..a8b1981daa4 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -23,11 +23,13 @@ function createConnection() { const client = { global: { config: { + get: async () => ({ data: {} }), update: async () => ({ data: {} }), }, }, config: { get: async () => ({ data: {} }), + update: async () => ({ data: {} }), }, } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils-enhance-error.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils-enhance-error.test.ts new file mode 100644 index 00000000000..00965b8e2be --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils-enhance-error.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "bun:test" +import { normalizeEnhancePromptErrorMessage } from "../../src/enhance-prompt-error" + +describe("normalizeEnhancePromptErrorMessage", () => { + it("returns guidance for exceeded current quota errors", () => { + const msg = normalizeEnhancePromptErrorMessage( + "AI_RetryError: Failed after 4 attempts. Last error: You exceeded your current quota, please check your plan and billing details.", + ) + + expect(msg).toContain("provider quota/billing limits") + expect(msg).toContain("Check your provider account billing/quota and API access, then retry.") + expect(msg).toContain("Provider response:") + }) + + it("returns guidance for insufficient_quota style errors", () => { + const msg = normalizeEnhancePromptErrorMessage("OpenAI API error: insufficient_quota") + + expect(msg).toContain("provider quota/billing limits") + expect(msg).toContain("Provider response: OpenAI API error: insufficient_quota") + }) + + it("returns original error for non-quota failures", () => { + const raw = "Request timed out while connecting to provider" + expect(normalizeEnhancePromptErrorMessage(raw)).toBe(raw) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 2db786d39d6..251138a59fc 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -137,8 +137,29 @@ describe("Edit tool diff-first click contract (source)", () => { const editBlock = editBlockMatch?.[0] ?? "" it("edit tool derives before/after content from filediff or input", () => { - expect(editBlock).toMatch(/filediff\?\.before\s*\?\?.*oldString/) - expect(editBlock).toMatch(/filediff\?\.after\s*\?\?.*newString/) + expect(editBlock).toContain("contents(diff)") + expect(editBlock).toMatch(/view\(\)\?\.before\s*\?\?.*filediff\?\.before\s*\?\?.*oldString/) + expect(editBlock).toMatch(/view\(\)\?\.after\s*\?\?.*filediff\?\.after\s*\?\?.*newString/) + }) +}) + +describe("Write and apply_patch patch rendering contracts (source)", () => { + const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + const writeBlock = + src.match(/ToolRegistry\.register\(\{\s*name:\s*"write"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? "" + const patchBlock = + src.match(/ToolRegistry\.register\(\{\s*name:\s*"apply_patch"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? "" + + it("write tool can render from filediff.patch when input.content is stripped", () => { + expect(writeBlock).toContain("contents(diff)") + expect(writeBlock).toContain("props.input.content || view()") + expect(writeBlock).toContain('mode="diff"') + }) + + it("apply_patch tool can render from patch metadata without before/after", () => { + expect(patchBlock).toContain("file.patch") + expect(patchBlock).toContain("contents({ file: file.relativePath, patch: file.patch") + expect(patchBlock).toContain('mode="diff"') }) }) diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 8e657182163..3d6c6892706 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -4,7 +4,7 @@ import * as os from "os" import * as path from "path" import { diffSummary, diffFile, generatedLike, resolveBase, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff" import { GitOps } from "../../src/agent-manager/GitOps" -import { resolveLocalDiffTarget } from "../../src/review-utils" +import { resolveLocalDiffTarget } from "../../src/diff/shared/target" function git(): GitOps { return new GitOps({ log: () => undefined }) diff --git a/packages/kilo-vscode/tests/unit/native-tab-title.test.ts b/packages/kilo-vscode/tests/unit/native-tab-title.test.ts new file mode 100644 index 00000000000..f949f88e3ec --- /dev/null +++ b/packages/kilo-vscode/tests/unit/native-tab-title.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test" +import type { Session } from "@kilocode/sdk/v2/client" +import { nativeTitle } from "../../src/kilo-provider/native-tab-title" + +const session = (title: string | null) => ({ title }) as Session + +describe("nativeTitle", () => { + it("uses the default title without a useful session title", () => { + expect(nativeTitle(null)).toBe("Kilo Code") + expect(nativeTitle(session(""))).toBe("Kilo Code") + expect(nativeTitle(session("New session - 2026-05-06T10:39:00.000Z"))).toBe("Kilo Code") + }) + + it("keeps short session titles", () => { + expect(nativeTitle(session("Greeting"))).toBe("Greeting") + }) + + it("truncates long session titles", () => { + expect(nativeTitle(session("Dynamic VS Code tab titles for Kilo sessions"))).toBe("Dynamic VS Code tab...") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index 2e04caa3adf..375a44efdcf 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it } from "bun:test" -import { disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" +import { connectProvider, disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" type ExistingGlobal = { disabled_providers?: string[]; provider?: Record } -function createCtx(existing: ExistingGlobal = { disabled_providers: [] }) { +function createCtx(existing: ExistingGlobal = { disabled_providers: [] }, merged: ExistingGlobal = existing) { const calls = { - set: [] as Array<{ providerID: string; auth: { type: string; key: string } }>, + set: [] as Array<{ providerID: string; auth: { type: string; key: string; metadata?: Record } }>, remove: [] as Array<{ providerID: string }>, posts: [] as unknown[], config: [] as Array<{ config: Record }>, + project: [] as Array<{ config: Record }>, cached: [] as unknown[], refresh: 0, dispose: 0, @@ -17,7 +18,10 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }) { const ctx = { client: { auth: { - set: async (input: { providerID: string; auth: { type: string; key: string } }) => { + set: async (input: { + providerID: string + auth: { type: string; key: string; metadata?: Record } + }) => { calls.set.push(input) return { data: true } }, @@ -53,6 +57,13 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }) { }, }, }, + config: { + get: async () => ({ data: merged }), + update: async (input: { config: Record }) => { + calls.project.push(input) + return { data: input } + }, + }, }, postMessage: (message: unknown) => calls.posts.push(message), getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), @@ -82,6 +93,13 @@ function createProvider() { } } +function createSavedProvider() { + return { + npm: "@ai-sdk/openai-compatible", + ...createProvider(), + } +} + describe("disconnectProvider", () => { it("keeps configured provider enabled after disconnecting oauth override", async () => { const existing = { @@ -102,6 +120,54 @@ describe("disconnectProvider", () => { }) }) +describe("connectProvider", () => { + it("stores api auth metadata from provider prompts", async () => { + const { ctx, calls } = createCtx() + + await connectProvider(ctx, "req", "azure", "sk-test", { + resourceName: " my-resource ", + empty: " ", + }) + + expect(calls.set).toEqual([ + { + providerID: "azure", + auth: { + type: "api", + key: "sk-test", + metadata: { resourceName: "my-resource" }, + }, + }, + ]) + expect(calls.refresh).toBe(1) + expect(calls.posts).toContainEqual({ type: "providerConnected", requestId: "req", providerID: "azure" }) + }) + + it("stores azure endpoint URL metadata from provider prompts", async () => { + const { ctx, calls } = createCtx() + + await connectProvider(ctx, "req", "azure", "sk-test", { + endpointType: "baseURL", + baseURL: " https://my-resource.openai.azure.com/openai ", + resourceName: " ", + }) + + expect(calls.set).toEqual([ + { + providerID: "azure", + auth: { + type: "api", + key: "sk-test", + metadata: { + endpointType: "baseURL", + baseURL: "https://my-resource.openai.azure.com/openai", + }, + }, + }, + ]) + }) +}) + describe("saveCustomProvider", () => { it("preserves auth when the api key field is unchanged", async () => { const { ctx, calls, setCachedConfig } = createCtx() @@ -273,6 +339,76 @@ describe("disconnectProvider", () => { expect(calls.config).toHaveLength(0) expect(calls.refresh).toBe(1) }) + + it("deletes saved custom provider config when disconnecting", async () => { + const existing = { + disabled_providers: ["myprovider", "openai"], + provider: { + myprovider: createSavedProvider(), + }, + } + const { ctx, calls, setCachedConfig } = createCtx(existing) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toHaveLength(1) + expect(calls.config[0].config).toEqual({ + provider: { myprovider: null }, + disabled_providers: ["openai"], + }) + expect(calls.project).toEqual([{ config: { provider: { myprovider: null } }, directory: "/tmp" }]) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + }) + + it("deletes project custom provider config when it is not in global config", async () => { + const merged = { + provider: { + myprovider: createSavedProvider(), + }, + } + const { ctx, calls, setCachedConfig } = createCtx({ disabled_providers: [] }, merged) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toHaveLength(0) + expect(calls.project).toEqual([{ config: { provider: { myprovider: null } }, directory: "/tmp" }]) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + }) + + it("deletes both global and project custom provider config when project overrides global", async () => { + const global = { + disabled_providers: ["myprovider", "openai"], + provider: { + myprovider: createSavedProvider(), + }, + } + const merged = { + ...global, + provider: { + myprovider: { + ...createSavedProvider(), + name: "Project Provider", + }, + }, + } + const { ctx, calls, setCachedConfig } = createCtx(global, merged) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toEqual([ + { + config: { + provider: { myprovider: null }, + disabled_providers: ["openai"], + }, + }, + ]) + expect(calls.project).toEqual([{ config: { provider: { myprovider: null } }, directory: "/tmp" }]) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + }) }) describe("fetchProviderData", () => { diff --git a/packages/kilo-vscode/tests/unit/server-manager-proxy-env.test.ts b/packages/kilo-vscode/tests/unit/server-manager-proxy-env.test.ts new file mode 100644 index 00000000000..6aac3ae2bcb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/server-manager-proxy-env.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, afterEach } from "bun:test" +import * as vscode from "vscode" +import { buildProxyEnv } from "../../src/services/cli-backend/server-manager" + +type Info = { globalValue?: unknown; workspaceValue?: unknown; workspaceFolderValue?: unknown } +type WorkspaceStub = { + getConfiguration: (section?: string) => { get: (key: string) => unknown; inspect: (key: string) => Info } +} + +const workspace = vscode.workspace as unknown as WorkspaceStub +const originalGetConfiguration = workspace.getConfiguration + +function stubHttpConfig(values: { proxy?: unknown; noProxy?: unknown; proxySupport?: unknown }): void { + workspace.getConfiguration = (section?: string) => { + if (section === "http") { + return { + get: (key: string) => { + if (key === "proxy") return values.proxy + if (key === "noProxy") return values.noProxy + if (key === "proxySupport") return values.proxySupport + return undefined + }, + inspect: (key: string) => { + if (key === "proxy" && values.proxy !== undefined) return { workspaceValue: values.proxy } + if (key === "noProxy" && values.noProxy !== undefined) return { workspaceValue: values.noProxy } + if (key === "proxySupport" && values.proxySupport !== undefined) + return { workspaceValue: values.proxySupport } + return {} + }, + } + } + return { get: () => undefined, inspect: () => ({}) } + } +} + +afterEach(() => { + workspace.getConfiguration = originalGetConfiguration +}) + +describe("buildProxyEnv", () => { + it("returns an empty object when neither proxy nor noProxy is configured", () => { + stubHttpConfig({ proxy: undefined, noProxy: undefined }) + + expect(buildProxyEnv()).toEqual({}) + }) + + it("forwards http.proxy as HTTP_PROXY and HTTPS_PROXY", () => { + stubHttpConfig({ proxy: "http://proxy.corp.example:8080" }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "http://proxy.corp.example:8080", + HTTPS_PROXY: "http://proxy.corp.example:8080", + http_proxy: "http://proxy.corp.example:8080", + https_proxy: "http://proxy.corp.example:8080", + }) + }) + + it("joins http.noProxy into a comma-separated NO_PROXY value", () => { + stubHttpConfig({ noProxy: ["localhost", "127.0.0.1", "*.internal"] }) + + expect(buildProxyEnv()).toEqual({ + NO_PROXY: "localhost,127.0.0.1,*.internal", + no_proxy: "localhost,127.0.0.1,*.internal", + }) + }) + + it("forwards both proxy and noProxy when both are configured", () => { + stubHttpConfig({ + proxy: "http://proxy.corp.example:8080", + noProxy: ["localhost", "*.internal"], + }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "http://proxy.corp.example:8080", + HTTPS_PROXY: "http://proxy.corp.example:8080", + NO_PROXY: "localhost,*.internal", + http_proxy: "http://proxy.corp.example:8080", + https_proxy: "http://proxy.corp.example:8080", + no_proxy: "localhost,*.internal", + }) + }) + + it("clears env vars when http.proxy is only whitespace", () => { + stubHttpConfig({ proxy: " " }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + }) + }) + + it("clears env var when http.noProxy is an empty array", () => { + stubHttpConfig({ noProxy: [] }) + + expect(buildProxyEnv()).toEqual({ + NO_PROXY: "", + no_proxy: "", + }) + }) + + it("ignores a non-array http.noProxy value", () => { + stubHttpConfig({ noProxy: "localhost" }) + + expect(buildProxyEnv()).toEqual({}) + }) + + it("explicitly clears env vars when http.proxySupport is off", () => { + stubHttpConfig({ proxySupport: "off" }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + NO_PROXY: "", + http_proxy: "", + https_proxy: "", + no_proxy: "", + }) + }) + + it("http.proxySupport=off wins over a configured http.proxy/http.noProxy", () => { + stubHttpConfig({ + proxy: "http://proxy.corp.example:8080", + noProxy: ["localhost"], + proxySupport: "off", + }) + + expect(buildProxyEnv()).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + NO_PROXY: "", + http_proxy: "", + https_proxy: "", + no_proxy: "", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index 22fd2b4871f..66409719064 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -47,7 +47,7 @@ const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonne const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } describe("per-session model selection", () => { - it("selecting a model in session A writes per-mode globally", () => { + it("selecting a model in session A does not write per-mode globally", () => { const store = emptyStore() const e = env() @@ -58,10 +58,9 @@ describe("per-session model selection", () => { // Session A should see claude (via session override) expect(getSessionModel(updated, e, "session-a", "code")).toEqual(claude) - // Session B (no override) inherits the per-mode global selection. - // This matches CLI behavior: per-mode model is global, not per-session. + // Session B (no override) keeps the default model. const sessionB = getSessionModel(updated, e, "session-b", "code") - expect(sessionB).toEqual(claude) + expect(sessionB).toEqual(KILO_AUTO) }) it("each session preserves its own model independently", () => { @@ -150,15 +149,15 @@ describe("per-session model selection", () => { }) describe("per-mode model memory", () => { - it("applyModel in a session writes to both sessionOverrides and modelSelections", () => { + it("applyModel in a session writes only to sessionOverrides", () => { const store = emptyStore() const result = applyModel(store, "code", claude, "session-a") expect(result.sessionOverrides["session-a"]).toEqual(claude) - expect(result.modelSelections["code"]).toEqual(claude) + expect(result.modelSelections["code"]).toBeUndefined() }) - it("switching modes restores per-mode model after session override is cleared", () => { + it("switching modes falls back to default after session override is cleared", () => { let store = emptyStore() const e = env() @@ -169,20 +168,19 @@ describe("per-mode model memory", () => { // Simulate mode switch: clear session override (like selectAgent does) const cleared = { ...store, sessionOverrides: {} } - // The global modelSelections["code"] still has claude - expect(getSelected(cleared, e, "session-a", "code")).toEqual(claude) + expect(getSelected(cleared, e, "session-a", "code")).toEqual(KILO_AUTO) }) it("different modes remember their own model independently", () => { let store = emptyStore() const e = env() - // User picks claude for "code" in session A - let result = applyModel(store, "code", claude, "session-a") + // User picks claude for "code" globally + let result = applyModel(store, "code", claude, undefined) store = { ...store, ...result } - // User switches to "ask" mode and picks gpt - result = applyModel(store, "ask", gpt, "session-a") + // User switches to "ask" mode and picks gpt globally + result = applyModel(store, "ask", gpt, undefined) store = { ...store, ...result } // Clear session overrides (simulating mode switch) @@ -207,8 +205,8 @@ describe("per-mode model memory", () => { // Session A sees gpt (its override), not the global claude expect(getSelected(store, e, "session-a", "code")).toEqual(gpt) - // Global modelSelections was updated to gpt (last write wins) - expect(store.modelSelections["code"]).toEqual(gpt) + // Global modelSelections stays at the sidebar/default choice. + expect(store.modelSelections["code"]).toEqual(claude) }) it("applyModel without session only writes to modelSelections, not sessionOverrides", () => { diff --git a/packages/kilo-vscode/tests/unit/session-preferences.test.ts b/packages/kilo-vscode/tests/unit/session-preferences.test.ts new file mode 100644 index 00000000000..9da16148386 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-preferences.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { resolveMessagePrefs } from "../../webview-ui/src/context/session-preferences" +import type { Message } from "../../webview-ui/src/types/messages" + +function msg(input: Partial): Message { + return { + id: input.id ?? "msg", + sessionID: input.sessionID ?? "session-a", + role: input.role ?? "user", + createdAt: input.createdAt ?? "2026-01-01T00:00:00.000Z", + ...input, + } +} + +const agents = new Set(["code", "ask"]) + +describe("session preference recovery", () => { + it("recovers model, variant, and agent from the latest user message", () => { + const prefs = resolveMessagePrefs( + [ + msg({ + id: "old", + agent: "ask", + model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "low" }, + }), + msg({ + id: "new", + agent: "code", + model: { providerID: "openai", modelID: "gpt-5.5", variant: "medium" }, + }), + ], + agents, + ) + + expect(prefs).toEqual({ + agent: "code", + model: { providerID: "openai", modelID: "gpt-5.5" }, + variant: "medium", + }) + }) + + it("ignores assistant-only model data and invalid agents", () => { + const prefs = resolveMessagePrefs( + [ + msg({ + role: "assistant", + agent: "task", + model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" }, + }), + ], + agents, + ) + + expect(prefs).toEqual({}) + }) + + it("can recover the latest valid agent separately from the latest user model", () => { + const prefs = resolveMessagePrefs( + [ + msg({ agent: "ask", model: { providerID: "anthropic", modelID: "claude-sonnet-4" } }), + msg({ role: "assistant", agent: "code" }), + ], + agents, + ) + + expect(prefs).toEqual({ + agent: "code", + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: undefined, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts index 13ccd532f90..bb08e44b828 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -87,4 +87,9 @@ describe("SessionTerminalManager structure", () => { expect(text).toContain("if (!this.panelOpen)") expect(text).toContain("this.showExistingLocal()") }) + + it("exposes active terminal state for terminal context routing", () => { + const text = body("hasActiveTerminal") + expect(text).toContain("this.host.activeTerminal()") + }) }) diff --git a/packages/kilo-vscode/tests/unit/session-utils.test.ts b/packages/kilo-vscode/tests/unit/session-utils.test.ts index 8c53fa88212..64454c9e51e 100644 --- a/packages/kilo-vscode/tests/unit/session-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/session-utils.test.ts @@ -184,27 +184,106 @@ describe("childID", () => { describe("buildFamilyCosts", () => { it("returns empty map for empty family", () => { - expect(buildFamilyCosts(new Set(), {}).size).toBe(0) + expect(buildFamilyCosts(new Set(), {}, {}).size).toBe(0) }) - it("sums costs per session, skipping zero-cost sessions", () => { + it("returns own-cost per session when there are no parent links", () => { const family = new Set(["s1", "s2", "s3"]) const messages = { s1: [msg("m1", "assistant", 0.05), msg("m2", "assistant", 0.03)], s2: [msg("m3", "user", 999), msg("m4", "assistant", 0)], s3: [msg("m5", "assistant", 0.1)], } - const costs = buildFamilyCosts(family, messages) + const sessions = { s1: {}, s2: {}, s3: {} } + const costs = buildFamilyCosts(family, messages, sessions) expect(costs.size).toBe(2) expect(costs.get("s1")).toBeCloseTo(0.08) expect(costs.has("s2")).toBe(false) expect(costs.get("s3")).toBeCloseTo(0.1) }) + it("subtracts each subagent's propagated total from its parent (single child)", () => { + // Backend contract: parent's message.info.cost already includes the + // child's total. Parent total $0.15 = parent own $0.05 + child $0.10. + const family = new Set(["root", "child"]) + const messages = { + root: [msg("m1", "assistant", 0.15)], + child: [msg("m2", "assistant", 0.1)], + } + const sessions = { root: {}, child: { parentID: "root" } } + const costs = buildFamilyCosts(family, messages, sessions) + expect(costs.get("root")).toBeCloseTo(0.05) + expect(costs.get("child")).toBeCloseTo(0.1) + // Sum of own-costs equals root's propagated total. + const sum = [...costs.values()].reduce((s, c) => s + c, 0) + expect(sum).toBeCloseTo(0.15) + }) + + it("subtracts every direct child from a parent with multiple subagents", () => { + // Parent total $0.18 = parent own $0.05 + childA $0.10 + childB $0.03. + const family = new Set(["root", "a", "b"]) + const messages = { + root: [msg("m1", "assistant", 0.18)], + a: [msg("m2", "assistant", 0.1)], + b: [msg("m3", "assistant", 0.03)], + } + const sessions = { root: {}, a: { parentID: "root" }, b: { parentID: "root" } } + const costs = buildFamilyCosts(family, messages, sessions) + expect(costs.get("root")).toBeCloseTo(0.05) + expect(costs.get("a")).toBeCloseTo(0.1) + expect(costs.get("b")).toBeCloseTo(0.03) + const sum = [...costs.values()].reduce((s, c) => s + c, 0) + expect(sum).toBeCloseTo(0.18) + }) + + it("handles nested subagents (grandchildren) correctly", () => { + // root.total = root_own + child.total; child.total = child_own + grandchild.total. + // root.total = $0.05 + ($0.06 + $0.04) = $0.15. + const family = new Set(["root", "child", "grand"]) + const messages = { + root: [msg("m1", "assistant", 0.15)], + child: [msg("m2", "assistant", 0.1)], + grand: [msg("m3", "assistant", 0.04)], + } + const sessions = { + root: {}, + child: { parentID: "root" }, + grand: { parentID: "child" }, + } + const costs = buildFamilyCosts(family, messages, sessions) + expect(costs.get("root")).toBeCloseTo(0.05) + expect(costs.get("child")).toBeCloseTo(0.06) + expect(costs.get("grand")).toBeCloseTo(0.04) + const sum = [...costs.values()].reduce((s, c) => s + c, 0) + expect(sum).toBeCloseTo(0.15) + }) + + it("drops sessions whose own-cost rounds to zero (pure dispatcher)", () => { + // Wrapper session that only spawned a subagent with no LLM calls of its own. + const family = new Set(["root", "child"]) + const messages = { + root: [msg("m1", "assistant", 0.1)], + child: [msg("m2", "assistant", 0.1)], + } + const sessions = { root: {}, child: { parentID: "root" } } + const costs = buildFamilyCosts(family, messages, sessions) + expect(costs.has("root")).toBe(false) + expect(costs.get("child")).toBeCloseTo(0.1) + }) + + it("ignores parent links that point outside the family", () => { + const family = new Set(["s1"]) + const messages = { s1: [msg("m1", "assistant", 0.07)] } + const sessions = { s1: { parentID: "not-in-family" } } + const costs = buildFamilyCosts(family, messages, sessions) + expect(costs.get("s1")).toBeCloseTo(0.07) + }) + it("handles missing messages for a family member", () => { const family = new Set(["s1", "s2"]) const messages = { s1: [msg("m1", "assistant", 0.01)] } - const costs = buildFamilyCosts(family, messages) + const sessions = { s1: {}, s2: { parentID: "s1" } } + const costs = buildFamilyCosts(family, messages, sessions) expect(costs.size).toBe(1) expect(costs.get("s1")).toBeCloseTo(0.01) }) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts new file mode 100644 index 00000000000..11901449db8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test" +import { + getVariant, + sessionVariantKeys, + sessionVariants, + transferVariants, + variantKey, +} from "../../webview-ui/src/context/session-variant-store" +import type { ModelSelection } from "../../webview-ui/src/types/messages" + +const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const variants = ["low", "medium", "high"] + +describe("per-session variant selection", () => { + it("keeps reasoning effort independent for each Agent Manager session", () => { + const store: Record = {} + + store[variantKey(model, "code", "session-a")] = "low" + store[variantKey(model, "code", "session-b")] = "high" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("low") + expect(getVariant(store, model, variants, "code", "session-b")).toBe("high") + }) + + it("keeps reasoning effort independent for each pending local tab", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + store[variantKey(model, "code", "pending-local-2")] = "high" + + expect(getVariant(store, model, variants, "code", "pending-local-1")).toBe("medium") + expect(getVariant(store, model, variants, "code", "pending-local-2")).toBe("high") + }) + + it("keeps no-session reasoning effort independent per agent", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + store[variantKey(model, "ask")] = "high" + + expect(getVariant(store, model, variants, "code")).toBe("medium") + expect(getVariant(store, model, variants, "ask")).toBe("high") + }) + + it("carries the pre-submit agent variant into a newly created session", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("prefers a session variant over the pre-submit agent variant", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + store[variantKey(model, "code", "session-a")] = "high" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("high") + }) + + it("falls back to the legacy provider/model variant key", () => { + const store: Record = { "anthropic/claude-sonnet-4": "medium" } + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("transfers a pending local tab variant to the created session", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + Object.assign(store, transferVariants(store, "pending-local-1", "session-a")) + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("extracts persisted session variant preferences", () => { + const store: Record = {} + + store[variantKey(model, "code", "session-a")] = "medium" + store[variantKey(model, "code", "session-b")] = "high" + + expect(sessionVariants(store, "session-a")).toEqual({ "anthropic/claude-sonnet-4": "medium" }) + }) + + it("finds only variant keys for the requested session", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + store[variantKey(model, "code", "pending-local-2")] = "high" + + expect(sessionVariantKeys(store, "pending-local-1")).toEqual(["session/pending-local-1/anthropic/claude-sonnet-4"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts index 7435dddb2e0..0dce0fe5dac 100644 --- a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts +++ b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts @@ -13,12 +13,18 @@ function bytes(obj: unknown): number { return JSON.stringify(obj).length } +function bigPatch() { + return `Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-${"x".repeat(70_000)}\n+${"y".repeat(70_000)}\n` +} + /** * Hard ceiling per slimmed tool state (JSON bytes). Real slimmed parts * should be well under this. If a slimmer leaks even one file-content * field (~50-500 KB each) the test blows past this immediately. */ const MAX_SLIM_BYTES = 10_000 +const PATCH = + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n" const BIG = "x".repeat(200_000) // 200 KB — typical file content size const DIAG = [ @@ -50,7 +56,7 @@ describe("slimPart", () => { output: "Edit applied successfully.", metadata: { diff: BIG, - filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 3, deletions: 1 }, + filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 3, deletions: 1 }, diagnostics: { "/a.ts": DIAG }, }, }) @@ -63,6 +69,7 @@ describe("slimPart", () => { const slim = slimPart(heavy) as Record const meta = slim.state.metadata expect(meta.filediff.file).toBe("/a.ts") + expect(meta.filediff.patch).toBe(PATCH) expect(meta.filediff.additions).toBe(3) expect(meta.filediff.deletions).toBe(1) expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) @@ -81,6 +88,19 @@ describe("slimPart", () => { }) expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) }) + + it("drops oversized filediff patches", () => { + const wide = part("edit", { + ...heavy.state, + metadata: { + ...(heavy.state.metadata as object), + filediff: { file: "/a.ts", patch: bigPatch(), before: BIG, after: BIG, additions: 1, deletions: 1 }, + }, + }) + const slim = slimPart(wide) as Record + expect(slim.state.metadata.filediff.patch).toBeUndefined() + expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES) + }) }) // ----------------------------------------------------------------------- @@ -100,6 +120,7 @@ describe("slimPart", () => { type: "update", before: BIG, after: BIG, + patch: PATCH, diff: BIG, additions: 5, deletions: 2, @@ -130,6 +151,7 @@ describe("slimPart", () => { expect(meta.files[0].filePath).toBe("/a.ts") expect(meta.files[0].relativePath).toBe("a.ts") expect(meta.files[0].type).toBe("update") + expect(meta.files[0].patch).toBe(PATCH) expect(meta.files[0].additions).toBe(5) expect(meta.files[1].type).toBe("add") expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) @@ -163,6 +185,28 @@ describe("slimPart", () => { }) expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) }) + + it("drops oversized per-file patches", () => { + const wide = part("apply_patch", { + ...heavy.state, + metadata: { + ...(heavy.state.metadata as Record), + files: [ + { + filePath: "/a.ts", + relativePath: "a.ts", + type: "update", + patch: bigPatch(), + additions: 1, + deletions: 1, + }, + ], + }, + }) + const slim = slimPart(wide) as Record + expect(slim.state.metadata.files[0].patch).toBeUndefined() + expect(bytes(slim)).toBeLessThan(MAX_SLIM_BYTES) + }) }) // ----------------------------------------------------------------------- @@ -178,7 +222,7 @@ describe("slimPart", () => { diagnostics: { "/a.ts": DIAG }, results: [ { - filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 1, deletions: 1 }, + filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 1, deletions: 1 }, diagnostics: { "/a.ts": DIAG }, diff: BIG, }, @@ -195,6 +239,7 @@ describe("slimPart", () => { const slim = slimPart(heavy) as Record const meta = slim.state.metadata expect(meta.results[0].filediff.file).toBe("/a.ts") + expect(meta.results[0].filediff.patch).toBe(PATCH) expect(meta.results[0].filediff.additions).toBe(1) expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG }) expect(meta.results[1].filediff.file).toBe("/b.ts") @@ -233,7 +278,7 @@ describe("slimPart", () => { filepath: "/a.ts", exists: true, diff: BIG, - filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 100, deletions: 0 }, + filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 100, deletions: 0 }, diagnostics: { "/a.ts": DIAG }, }, }) @@ -248,6 +293,7 @@ describe("slimPart", () => { expect(meta.filepath).toBe("/a.ts") expect(meta.exists).toBe(true) expect(meta.filediff.file).toBe("/a.ts") + expect(meta.filediff.patch).toBe(PATCH) expect(meta.filediff.additions).toBe(100) expect(meta.filediff.deletions).toBe(0) expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts new file mode 100644 index 00000000000..e217bf927cc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect } from "bun:test" +import { SourceController } from "../../src/diff/SourceController" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../../src/diff/sources/types" + +const WORKSPACE_DESC: DiffSourceDescriptor = { + id: "workspace", + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, +} + +const SESSION_DESC: DiffSourceDescriptor = { + id: "session:s1", + type: "session", + group: "Session", + capabilities: { revert: false, comments: true }, +} + +function make(sources: Record, descriptors?: DiffSourceDescriptor[]) { + const posted: unknown[] = [] + const controller = new SourceController( + (id) => { + const src = sources[id] + if (!src) throw new Error(`no source: ${id}`) + return src + }, + () => descriptors ?? Object.values(sources).map((s) => s.descriptor), + (m) => posted.push(m), + ) + return { controller, posted } +} + +const byType = (posted: unknown[], type: string) => + posted.filter((m): m is Record => { + return typeof m === "object" && m !== null && (m as { type: string }).type === type + }) + +describe("SourceController.activate", () => { + it("builds, fetches, and posts available sources + capabilities + diffs", async () => { + let fetches = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch(): Promise { + fetches++ + return { diffs: [] } + }, + } + const { controller, posted } = make({ "session:s1": source }, [WORKSPACE_DESC, SESSION_DESC]) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + + expect(fetches).toBe(1) + expect(controller.currentId).toBe("session:s1") + + const available = byType(posted, "setAvailableSources") + expect(available).toHaveLength(1) + expect(available[0]!.currentId).toBe("session:s1") + expect(available[0]!.descriptors).toEqual([WORKSPACE_DESC, SESSION_DESC]) + + const caps = byType(posted, "diffViewer.capabilities") + expect(caps).toHaveLength(1) + expect(caps[0]!.capabilities).toEqual({ revert: false, comments: true }) + + const diffs = byType(posted, "diffViewer.diffs") + expect(diffs).toHaveLength(1) + expect(diffs[0]!.diffs).toEqual([]) + + const loading = byType(posted, "diffViewer.loading") + expect(loading.map((m) => m.loading)).toEqual([true, false]) + + // Clean up the polling interval scheduled by activate(). + controller.stop() + }) + + it("forwards notices from the source to the webview", async () => { + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } + }, + } + const { controller, posted } = make({ "session:s1": source }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + + const notices = byType(posted, "diffViewer.notice") + expect(notices).toHaveLength(1) + expect(notices[0]!.notice).toBe("snapshots-disabled") + }) + + it("disposes the previous source when activating a new one", async () => { + let workspaceDisposed = 0 + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + return { diffs: [] } + }, + dispose() { + workspaceDisposed++ + }, + } + let sessionFetches = 0 + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + sessionFetches++ + return { diffs: [] } + }, + } + const { controller } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("workspace") + await controller.activate("session:s1") + + expect(workspaceDisposed).toBe(1) + expect(sessionFetches).toBe(1) + expect(controller.currentId).toBe("session:s1") + + controller.stop() + }) + + it("drops a fetch result and disposes the source when stopped mid-fetch", async () => { + let release: () => void = () => {} + let fetches = 0 + let disposed = 0 + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + fetches++ + await new Promise((r) => (release = r)) + return { diffs: [] } + }, + dispose() { + disposed++ + }, + } + const { controller, posted } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const activation = controller.activate("session:s1") + controller.stop() + release() + await activation + + expect(fetches).toBe(1) + expect(disposed).toBe(1) + // Fetch resolved after stop — its diffs must not leak to the webview. + expect(byType(posted, "diffViewer.diffs")).toEqual([]) + }) + + it("drops a fetch result from a source that has been swapped out", async () => { + let release: () => void = () => {} + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + await new Promise((r) => (release = r)) + return { diffs: [{ file: "stale.ts" } as never] } + }, + } + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + } + const { controller, posted } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const first = controller.activate("workspace") + await controller.activate("session:s1") + release() + await first + + const diffs = byType(posted, "diffViewer.diffs") + // Only the session source's empty diffs should have been posted. + expect(diffs).toHaveLength(1) + expect(diffs[0]!.diffs).toEqual([]) + + controller.stop() + }) +}) + +describe("SourceController.stop", () => { + it("disposes the active source", async () => { + let disposed = 0 + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + dispose() { + disposed++ + }, + } + const { controller } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + + controller.stop() + + expect(disposed).toBe(1) + expect(controller.currentId).toBeUndefined() + }) + + it("is a no-op when no source is active", () => { + const { controller } = make({}) + controller.stop() + expect(controller.currentId).toBeUndefined() + }) +}) + +describe("SourceController.revertFile", () => { + it("posts error when the active source does not support revert", async () => { + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + } + const { controller, posted } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + posted.length = 0 + await controller.revertFile("foo.ts") + + const results = byType(posted, "diffViewer.revertFileResult") + expect(results).toHaveLength(1) + expect(results[0]!.status).toBe("error") + expect(results[0]!.file).toBe("foo.ts") + + controller.stop() + }) + + it("posts success from a successful revert and triggers a fresh fetch", async () => { + const reverts: string[] = [] + let fetches = 0 + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + fetches++ + return { diffs: [] } + }, + async revert(file) { + reverts.push(file) + return { ok: true, message: "Reverted" } + }, + } + const { controller, posted } = make({ workspace }) + + controller.setContext({ workspaceRoot: "/repo" }) + await controller.activate("workspace") + const fetchesAfterActivate = fetches + posted.length = 0 + await controller.revertFile("foo.ts") + + expect(reverts).toEqual(["foo.ts"]) + const results = byType(posted, "diffViewer.revertFileResult") + expect(results[0]!.status).toBe("success") + expect(results[0]!.message).toBe("Reverted") + // Successful revert triggers an immediate re-fetch to push updated diffs. + expect(fetches).toBe(fetchesAfterActivate + 1) + + controller.stop() + }) + + it("posts error when the revert implementation throws", async () => { + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + return { diffs: [] } + }, + async revert() { + throw new Error("boom") + }, + } + const { controller, posted } = make({ workspace }) + + controller.setContext({ workspaceRoot: "/repo" }) + await controller.activate("workspace") + posted.length = 0 + await controller.revertFile("foo.ts") + + const results = byType(posted, "diffViewer.revertFileResult") + expect(results[0]!.status).toBe("error") + expect(results[0]!.message).toBe("boom") + + controller.stop() + }) +}) + +describe("SourceController.requestFile", () => { + it("posts null when the source does not support per-file detail", async () => { + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + } + const { controller, posted } = make({ "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1") + posted.length = 0 + await controller.requestFile("foo.ts") + + const files = byType(posted, "diffViewer.diffFile") + expect(files).toHaveLength(1) + expect(files[0]!.file).toBe("foo.ts") + expect(files[0]!.diff).toBeNull() + + controller.stop() + }) + + it("forwards the source's fetchFile result", async () => { + const detail = { + file: "foo.ts", + before: "a", + after: "b", + additions: 1, + deletions: 1, + } + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + return { diffs: [] } + }, + async fetchFile(file) { + return file === "foo.ts" ? detail : null + }, + } + const { controller, posted } = make({ workspace }) + + controller.setContext({ workspaceRoot: "/repo" }) + await controller.activate("workspace") + posted.length = 0 + await controller.requestFile("foo.ts") + + const files = byType(posted, "diffViewer.diffFile") + expect(files[0]!.diff).toEqual(detail) + + controller.stop() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 96d07371828..76f831fa87e 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test" import os from "node:os" import path from "node:path" import fs from "node:fs/promises" +import { existsSync } from "node:fs" import { WorktreeManager } from "../../src/agent-manager/WorktreeManager" import { generateBranchName, sanitizeBranchName, versionedName } from "../../src/agent-manager/branch-name" import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" @@ -37,6 +38,12 @@ function createManager(root: string): WorktreeManager { return new WorktreeManager(root, (msg) => logs.push(msg)) } +// Test-only helper to verify metadata writes keep the temp worktree checkout clean. +async function changedFiles(cwd: string): Promise { + const raw = await simpleGit(cwd).raw(["status", "--porcelain", "--untracked-files=all", "--"]) + return raw.trim().split("\n").filter(Boolean) +} + /** Create a temp repo with a bare origin remote so origin/ refs exist. */ async function createTempRepoWithOrigin(): Promise<{ bare: string; clone: string }> { // Use a non-bare seed repo to control the initial branch name, then clone bare @@ -551,6 +558,18 @@ describe("WorktreeManager metadata", () => { expect(meta?.remote).toBe("origin") }) + it("writes metadata outside the worktree checkout", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + const result = await mgr.createWorktree({ prompt: "session-status" }) + + await mgr.writeMetadata(result.path, "sess-clean-123", "feature-branch", "origin") + + expect(existsSync(path.join(result.path, ".kilo", "session-id"))).toBe(false) + expect(existsSync(path.join(result.path, ".kilo", "metadata.json"))).toBe(false) + expect(await changedFiles(result.path)).toEqual([]) + }) + it("returns undefined when no metadata exists", async () => { const root = await createTempRepo() const mgr = createManager(root) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index a1c104582ff..71c84b1a786 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -77,6 +77,27 @@ describe("WorktreeStateManager", () => { expect(s.worktreeId).toBeNull() }) + it("drops obsolete session prefs while loading state", async () => { + const file = path.join(root, ".kilo", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ + worktrees: {}, + sessions: { + "local-1": { + worktreeId: null, + createdAt: new Date().toISOString(), + prefs: { agent: "code" }, + }, + }, + }), + ) + + await manager.load() + + expect(manager.getSession("local-1")).not.toHaveProperty("prefs") + }) + it("filters sessions by worktreeId", () => { const wt1 = manager.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) const wt2 = manager.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index df117cb4778..977bf78868b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -9,8 +9,8 @@ import { on, onMount, onCleanup, - type Accessor, type Component, + type JSX, } from "solid-js" import type { ExtensionMessage, @@ -77,12 +77,13 @@ import { ProviderProvider } from "../src/context/provider" import { ConfigProvider } from "../src/context/config" import { DisplayProvider } from "../src/context/display" import { NotificationsProvider } from "../src/context/notifications" +import { FeedbackProvider } from "../src/context/feedback" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" -import { LanguageBridge, DataBridge } from "../src/App" +import { LanguageBridge, DataBridge, MermaidDownloadBridge } from "../src/App" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" import { nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, reconcileLocalSessions, LOCAL } from "./navigate" @@ -91,12 +92,14 @@ import { createTabOrderSync } from "./tab-order-sync" import { ConstrainDragYAxis } from "./sortable-tab" import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal" import { renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" +import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "./FullScreenDiffView" import { ApplyDialog } from "./ApplyDialog" import { groupApplyConflicts } from "./apply-conflicts" import type { ReviewComment } from "./review-comments" +import { CurrentTabsMenu, createCurrentTabItems, focusCurrentTab } from "./CurrentTabsMenu" import { BranchSelect } from "./BranchSelect" import { WorktreeItem } from "./WorktreeItem" import SectionHeader from "./SectionHeader" @@ -114,10 +117,11 @@ import { import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { mergeWorktreeDiffs } from "./diff-state" +import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" +import { setTabWidths } from "./tab-widths" import "./agent-manager.css" import "./agent-manager-review.css" - const REVIEW_TAB_ID = "review" interface SetupState { @@ -171,70 +175,6 @@ const defaultBindings: Record = { ), } -/** Manages horizontal scroll for the tab list: hides the scrollbar, converts - * vertical wheel events to horizontal scroll, tracks overflow to show/hide - * fade indicators, and auto-scrolls the active tab into view. */ -function useTabScroll(activeTabs: Accessor, activeId: Accessor) { - const [ref, setRef] = createSignal() - const [showLeft, setShowLeft] = createSignal(false) - const [showRight, setShowRight] = createSignal(false) - let scrollFrame: number | undefined - const update = () => { - if (scrollFrame !== undefined) return - scrollFrame = requestAnimationFrame(() => { - scrollFrame = undefined - const el = ref() - if (!el) return - setShowLeft(el.scrollLeft > 2) - setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2) - }) - } - // Wheel → horizontal scroll conversion - const onWheel = (e: WheelEvent) => { - const el = ref() - if (!el) return - if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return - e.preventDefault() - el.scrollLeft += e.deltaY > 0 ? 60 : -60 - } - // Recalculate on scroll, resize, or tab changes - createEffect(() => { - const el = ref() - if (!el) return - el.addEventListener("scroll", update, { passive: true }) - el.addEventListener("wheel", onWheel, { passive: false }) - const ro = new ResizeObserver(update) - ro.observe(el) - const mo = new MutationObserver(update) - mo.observe(el, { childList: true, subtree: true }) - onCleanup(() => { - el.removeEventListener("scroll", update) - el.removeEventListener("wheel", onWheel) - ro.disconnect() - mo.disconnect() - }) - }) - createEffect(() => { - const id = activeId() - const el = ref() - // depend on tabs length to trigger on tab add/remove - activeTabs() - if (!id || !el) return - requestAnimationFrame(() => { - const tab = el.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null - if (!tab) return - const left = tab.offsetLeft - const right = left + tab.offsetWidth - if (left < el.scrollLeft) { - el.scrollTo({ left: left - 8, behavior: "smooth" }) - } else if (right > el.scrollLeft + el.clientWidth) { - el.scrollTo({ left: right - el.clientWidth + 8, behavior: "smooth" }) - } - }) - }) - return { setRef, showLeft, showRight } -} - /** Shortcut category definition for the keyboard shortcuts dialog */ interface ShortcutEntry { label: string @@ -653,6 +593,13 @@ const AgentManagerContent: Component = () => { // Drag-and-drop state for tab reordering const [draggingTab, setDraggingTab] = createSignal() + + const freezeTabs = () => { + const bar = document.querySelector(".am-tab-bar") + if (bar instanceof HTMLElement && bar.matches(":hover")) setTabWidths(true) + } + + const releaseTabs = () => setTabWidths(false) // Tab ordering: context key → ordered session ID array (recovered from extension state) const [worktreeTabOrder, setWorktreeTabOrder] = createSignal>({}) // Sidebar worktree order (persisted to extension state) @@ -896,6 +843,8 @@ const AgentManagerContent: Component = () => { /** True when a local session is actively working. */ const isLocalBusy = (): boolean => isAnySessionBusy(localSessionIDs()) + const isSessionBusy = (id: string): boolean => isAnySessionBusy([id]) + /** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */ const sortedWorktrees = createMemo(() => { const ordered = applyTabOrder(worktrees(), sidebarWorktreeOrder()) @@ -998,24 +947,7 @@ const AgentManagerContent: Component = () => { if (idx === -1) return const next = direction === "left" ? idx - 1 : idx + 1 if (next < 0 || next >= ids.length) return - const targetId = ids[next]! - if (isTerminalTabId(targetId)) return termHandlers.activate(targetId) - terms.setActiveId(undefined) - if (targetId === REVIEW_TAB_ID) { - if (!reviewOpen()) setReviewOpenForSelection(true) - setReviewActive(true) - return - } - const target = tabLookup().get(targetId) - if (!target) return - setReviewActive(false) - if (isPending(target.id)) { - setActivePendingId(target.id) - session.clearCurrentSession() - return - } - setActivePendingId(undefined) - session.selectSession(target.id) + focusTab(ids[next]!) } const selectLocal = () => { @@ -1120,6 +1052,8 @@ const AgentManagerContent: Component = () => { // Prevent Cmd/Ctrl shortcuts from triggering native browser actions const preventDefaults = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey)) return + const target = e.target as HTMLElement | null + if (target?.closest("[data-agent-manager-native-text-shortcuts]")) return // Arrow navigation requires Alt modifier (Cmd+Alt+Arrow for tabs/sessions) if (e.altKey && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { e.preventDefault() @@ -1407,18 +1341,12 @@ const AgentManagerContent: Component = () => { if (ev.providerID && ev.modelID) { session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } + seedInitialVariant(session, ev) // Only send a message if there's text — otherwise just clear busy state - if (ev.text) { - vscode.postMessage({ - type: "sendMessage", - text: ev.text, - sessionID: ev.sessionId, - providerID: ev.providerID, - modelID: ev.modelID, - agent: ev.agent, - files: ev.files, - }) + const init = initialMessage(ev) + if (init) { + vscode.postMessage(init) } // Clear busy state — use worktreeId from the message directly // to avoid race condition where managedSessions() hasn't updated yet @@ -1608,6 +1536,7 @@ const AgentManagerContent: Component = () => { // the unmount triggers heavy FileDiff cleanup but the tab bar // and chat view are already visible before that work runs. const closeReviewTab = () => { + freezeTabs() setReviewActive(false) setReviewOpenForSelection(false) } @@ -1973,6 +1902,7 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ ...msg, worktreeId: sel }) } const handleCloseTab = (sessionId: string) => { + freezeTabs() const pending = isPending(sessionId) const isActive = pending ? sessionId === activePendingId() : session.currentSessionID() === sessionId if (isActive) { @@ -2001,6 +1931,7 @@ const AgentManagerContent: Component = () => { const handleTabMouseDown = (sessionId: string, e: MouseEvent) => { if (e.button === 1) { e.preventDefault() + e.stopPropagation() handleCloseTab(sessionId) } } @@ -2029,6 +1960,7 @@ const AgentManagerContent: Component = () => { isPendingId: isPending, findTab: (id) => tabLookup().get(id), postMessage: (msg) => vscode.postMessage(msg as never), + onRemove: freezeTabs, getSelection: selection, LOCAL, REVIEW_TAB_ID, @@ -2059,7 +1991,6 @@ const AgentManagerContent: Component = () => { worktreeTabOrder()[key], ).map((item) => item.id) }) - const handleDragStart = (event: DragEvent) => { const id = event.draggable?.id if (typeof id === "string") setDraggingTab(id) @@ -2111,6 +2042,37 @@ const AgentManagerContent: Component = () => { return activeTabs().find((s) => s.id === id) }) + const focusTab = (id: string) => + focusCurrentTab({ + id, + terms, + isTerminal: isTerminalTabId, + isPending, + reviewId: REVIEW_TAB_ID, + reviewOpen, + setReviewOpen: setReviewOpenForSelection, + setReviewActive, + tabLookup, + setActivePendingId, + clearSession: session.clearCurrentSession, + selectSession: session.selectSession, + activateTerminal: termHandlers.activate, + }) + + const tabMenuItems = createCurrentTabItems({ + tabIds, + tabLookup, + statusMap: session.allStatusMap, + permissions: session.permissions, + questions: session.questions, + visibleTabId, + terms, + reviewId: REVIEW_TAB_ID, + isTerminal: isTerminalTabId, + isPending, + t, + }) + // Close the currently active tab via keyboard shortcut. // If no tabs remain, fall through to close the selected worktree. const closeActiveTab = () => { @@ -2701,56 +2663,71 @@ const AgentManagerContent: Component = () => { > -
+
-
- - - {(id) => - renderTab(id, { - terms, - REVIEW_TAB_ID, - tabIds, - kb, - reviewActive, - currentSessionID: () => session.currentSessionID(), - activePendingId, - visibleTabId, - isPending, - tabLookup, - adjacentHint, - activateTerminal: termHandlers.activate, - deactivateTerminal: termHandlers.deactivate, - closeTerminal: termHandlers.closeTerminal, - terminalMiddleClick: termHandlers.middleClick, - closeReview: closeReviewTab, - reviewMiddleClick: handleReviewTabMouseDown, - selectReviewTab: () => setReviewActive(true), - selectSessionTab, - sessionMiddleClick: handleTabMouseDown, - sessionClose: handleCloseTab, - sessionFork: handleForkSession, - reviewLabel: t("session.tab.review"), - reviewTooltip: t("command.review.toggle"), - }) - } - - +
+
+ + + {(id) => + renderTab(id, { + terms, + REVIEW_TAB_ID, + tabIds, + kb, + reviewActive, + currentSessionID: () => session.currentSessionID(), + activePendingId, + visibleTabId, + isPending, + isBusy: isSessionBusy, + tabLookup, + adjacentHint, + activateTerminal: termHandlers.activate, + deactivateTerminal: termHandlers.deactivate, + closeTerminal: termHandlers.closeTerminal, + terminalMiddleClick: termHandlers.middleClick, + closeReview: closeReviewTab, + reviewMiddleClick: handleReviewTabMouseDown, + selectReviewTab: () => setReviewActive(true), + selectSessionTab, + sessionMiddleClick: handleTabMouseDown, + sessionClose: handleCloseTab, + sessionFork: handleForkSession, + reviewLabel: t("session.tab.review"), + reviewTooltip: t("command.review.toggle"), + }) + } + + +
+ {renderNewTabButton({ + contextSelected: () => selection() !== null, + kb, + newSessionLabel: t("agentManager.session.new"), + newTerminalLabel: t("agentManager.terminal.new"), + newSessionMenuLabel: t("agentManager.session.newSession"), + moreOptionsLabel: t("agentManager.tab.newOptions"), + onNewSession: handleAddSession, + onNewTerminal: () => termHandlers.requestNew(), + })}
- {renderNewTabButton({ - contextSelected: () => selection() !== null, - kb, - newSessionLabel: t("agentManager.session.new"), - newTerminalLabel: t("agentManager.terminal.new"), - newSessionMenuLabel: t("agentManager.session.newSession"), - moreOptionsLabel: t("agentManager.tab.newOptions"), - onNewSession: handleAddSession, - onNewTerminal: () => termHandlers.requestNew(), - })}
+ {(() => { const sel = () => selection() const isWorktree = () => typeof sel() === "string" && sel() !== LOCAL @@ -3163,6 +3140,7 @@ export const AgentManagerApp: Component = () => { + @@ -3174,13 +3152,15 @@ export const AgentManagerApp: Component = () => { - - - - - - - + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx b/packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx index c4580234a74..672101c98e1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx @@ -1,10 +1,11 @@ // Reusable branch selector: search input + scrollable list with keyboard navigation -import { type Component, For, Show } from "solid-js" +import { type Component, For, Show, type JSXElement, type ParentProps } from "solid-js" import type { BranchInfo } from "../src/types/messages" import { Icon } from "@kilocode/kilo-ui/icon" import { Spinner } from "@kilocode/kilo-ui/spinner" import { formatRelativeDate } from "../src/utils/date" +import { DeferredPopover } from "../src/components/shared/DeferredPopover" interface AutoOption { label: string @@ -33,6 +34,28 @@ interface BranchSelectProps { autoOption?: AutoOption } +interface BranchSelectPopoverProps extends ParentProps { + open: boolean + onOpenChange: (open: boolean) => void + trigger: JSXElement +} + +export const BranchSelectPopover: Component = (props) => ( + + {props.children} + +) + export const BranchSelect: Component = (props) => { const isDefault = (branch: BranchInfo) => { if (props.defaultName) return branch.name === props.defaultName diff --git a/packages/kilo-vscode/webview-ui/agent-manager/CurrentTabsMenu.tsx b/packages/kilo-vscode/webview-ui/agent-manager/CurrentTabsMenu.tsx new file mode 100644 index 00000000000..7dd919fb367 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/CurrentTabsMenu.tsx @@ -0,0 +1,289 @@ +/** @jsxImportSource solid-js */ + +import { For, Show, createEffect, createMemo, createSignal } from "solid-js" +import type { Accessor, Component } from "solid-js" +import { Popover } from "@kilocode/kilo-ui/popover" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import type { PermissionRequest, QuestionRequest, SessionInfo, SessionStatusInfo } from "../src/types/messages" +import type { TerminalStateControls } from "./terminal" + +interface CurrentTabItem { + id: string + title: string + status?: string + working: boolean + tone: "active" | "busy" | "waiting" | "idle" +} + +interface CurrentTabItemsDeps { + tabIds: Accessor + tabLookup: Accessor> + statusMap: Accessor> + permissions: Accessor + questions: Accessor + visibleTabId: Accessor + terms: TerminalStateControls + reviewId: string + isTerminal: (id: string) => boolean + isPending: (id: string) => boolean + t: (key: string) => string +} + +interface FocusTabDeps { + id: string + terms: TerminalStateControls + isTerminal: (id: string) => boolean + isPending: (id: string) => boolean + reviewId: string + reviewOpen: Accessor + setReviewOpen: (open: boolean) => void + setReviewActive: (active: boolean) => void + tabLookup: Accessor> + setActivePendingId: (id: string | undefined) => void + clearSession: () => void + selectSession: (id: string) => void + activateTerminal: (id: string) => void +} + +export function focusCurrentTab(deps: FocusTabDeps) { + if (deps.isTerminal(deps.id)) { + deps.activateTerminal(deps.id) + return + } + deps.terms.setActiveId(undefined) + if (deps.id === deps.reviewId) { + if (!deps.reviewOpen()) deps.setReviewOpen(true) + deps.setReviewActive(true) + return + } + const target = deps.tabLookup().get(deps.id) + if (!target) return + deps.setReviewActive(false) + if (deps.isPending(target.id)) { + deps.setActivePendingId(target.id) + deps.clearSession() + return + } + deps.setActivePendingId(undefined) + deps.selectSession(target.id) +} + +export const createCurrentTabItems = (deps: CurrentTabItemsDeps): Accessor => + createMemo(() => { + const statuses = deps.statusMap() + const perms = deps.permissions() + const qs = deps.questions() + return deps + .tabIds() + .map((id) => buildItem(id, statuses, perms, qs, deps)) + .filter((item): item is CurrentTabItem => item !== undefined) + }) + +const basicItem = (id: string, title: string, deps: CurrentTabItemsDeps): CurrentTabItem => ({ + id, + title, + working: false, + tone: id === deps.visibleTabId() ? "active" : "idle", +}) + +function buildReviewItem(id: string, deps: CurrentTabItemsDeps) { + return basicItem(id, deps.t("session.tab.review"), deps) +} + +function buildTerminalItem(id: string, deps: CurrentTabItemsDeps) { + const term = deps.terms.lookup().get(id) + if (!term) return undefined + return basicItem(id, term.title, deps) +} + +function buildPendingItem(tab: SessionInfo, deps: CurrentTabItemsDeps) { + return basicItem(tab.id, tab.title || deps.t("agentManager.session.newSession"), deps) +} + +function buildSessionItem( + tab: SessionInfo, + status: SessionStatusInfo | undefined, + blocked: boolean, + deps: CurrentTabItemsDeps, +) { + const working = !blocked && (status?.type === "busy" || status?.type === "retry") + return { + id: tab.id, + title: tab.title || deps.t("agentManager.session.untitled"), + status: statusLabel(tab.id, blocked, status, deps), + working, + tone: statusTone(tab.id, blocked, working, deps), + } satisfies CurrentTabItem +} + +function buildItem( + id: string, + statuses: Record, + perms: PermissionRequest[], + qs: QuestionRequest[], + deps: CurrentTabItemsDeps, +): CurrentTabItem | undefined { + if (id === deps.reviewId) return buildReviewItem(id, deps) + if (deps.isTerminal(id)) return buildTerminalItem(id, deps) + const tab = deps.tabLookup().get(id) + if (!tab) return undefined + if (deps.isPending(id)) return buildPendingItem(tab, deps) + const blocked = perms.some((p) => p.sessionID === id) || qs.some((q) => q.sessionID === id) + return buildSessionItem(tab, statuses[id], blocked, deps) +} + +function statusLabel(id: string, blocked: boolean, status: SessionStatusInfo | undefined, deps: CurrentTabItemsDeps) { + if (blocked) return deps.t("agentManager.tabsMenu.status.waiting") + if (status?.type === "busy") return deps.t("agentManager.tabsMenu.status.working") + if (status?.type === "retry") return deps.t("agentManager.tabsMenu.status.retry") + return undefined +} + +function statusTone(id: string, blocked: boolean, working: boolean, deps: CurrentTabItemsDeps): CurrentTabItem["tone"] { + if (id === deps.visibleTabId()) return "active" + if (blocked) return "waiting" + if (working) return "busy" + return "idle" +} + +interface CurrentTabsMenuProps { + items: Accessor + label: string + searchLabel: string + emptyLabel: string + activeId: Accessor + onSelect: (id: string) => void +} + +function SearchIcon() { + return ( + + ) +} + +export const CurrentTabsMenu: Component = (props) => { + const [search, setSearch] = createSignal("") + const [open, setOpen] = createSignal(false) + const [mark, setMark] = createSignal(0) + let input: HTMLInputElement | undefined + + const focus = () => { + input?.focus({ preventScroll: true }) + } + + createEffect(() => { + if (!open()) return + setSearch("") + queueMicrotask(focus) + }) + + const rows = createMemo(() => { + const q = search().trim().toLowerCase() + if (!q) return props.items() + return props.items().filter((item) => item.title.toLowerCase().includes(q)) + }) + + createEffect(() => { + if (!open()) return + search() + setMark(0) + }) + + const select = (id: string) => { + props.onSelect(id) + setOpen(false) + } + + const move = (dir: 1 | -1) => { + const len = rows().length + if (len === 0) return + setMark((prev) => (prev + dir + len) % len) + } + + const onKeyDown = (e: KeyboardEvent) => { + e.stopPropagation() + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + if (e.key === "ArrowDown") { + e.preventDefault() + move(1) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + move(-1) + return + } + if (e.key === "Enter") { + e.preventDefault() + const item = rows()[mark()] + if (item) select(item.id) + return + } + if (e.key === "Escape") { + e.preventDefault() + setOpen(false) + } + } + + return ( + + + + } + > + +
+ 0} fallback={
{props.emptyLabel}
}> + + {(item) => ( + + )} + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index c1d433b8916..f722b7aa68d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -52,6 +52,10 @@ interface FullScreenDiffViewProps { onOpenFile?: (relativePath: string, line?: number) => void onRevertFile?: (file: string) => void revertingFiles?: Set + /** Defaults to true. Hides the per-file Revert action when false. */ + canRevert?: boolean + /** Defaults to true. Disables comment creation and "Send all" when false. */ + canComment?: boolean onClose: () => void } @@ -288,6 +292,7 @@ export const FullScreenDiffView: Component = (props) => } const handleGutterClick = (file: string, range: SelectedLineRange) => { + if (props.canComment === false) return if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { @@ -310,6 +315,7 @@ export const FullScreenDiffView: Component = (props) => if (!(e.metaKey || e.ctrlKey)) return const target = e.target if (keepNativeFocus(target)) return + if (props.canComment === false) return if (comments().length === 0) return e.preventDefault() e.stopPropagation() @@ -445,7 +451,7 @@ export const FullScreenDiffView: Component = (props) => {open().length === props.diffs.length ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")} - 0}> + 0 && props.canComment !== false}> = (props) => activeFile={activeFile()} onFileSelect={handleFileSelect} comments={comments()} - onRevertFile={props.onRevertFile} + onRevertFile={props.canRevert !== false ? props.onRevertFile : undefined} revertingFiles={props.revertingFiles} />
@@ -567,7 +573,7 @@ export const FullScreenDiffView: Component = (props) => /> - + = (props) => diffStyle={props.diffStyle} annotations={annotationsForFile(diff.file)} renderAnnotation={buildAnnotation} - enableGutterUtility={true} + enableGutterUtility={props.canComment !== false} onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)} onLineNumberClick={(event) => { if (event.annotationSide === "deletions") return diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 3e54451f4e1..959b1333205 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -9,7 +9,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { Spinner } from "@kilocode/kilo-ui/spinner" -import { Popover } from "@kilocode/kilo-ui/popover" +import { DeferredPopover } from "../src/components/shared/DeferredPopover" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useVSCode } from "../src/context/vscode" import { useServer } from "../src/context/server" @@ -28,7 +28,7 @@ import { import { useLanguage } from "../src/context/language" import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments" import { convertToMentionPath } from "../src/utils/path-mentions" -import { BranchSelect } from "./BranchSelect" +import { BranchSelect, BranchSelectPopover } from "./BranchSelect" type VersionCount = 1 | 2 | 3 | 4 const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] @@ -388,7 +388,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
1}> - + void; defaultBaseBran if (pid && mid) setModel({ providerID: pid, modelID: mid }) }} placement="top-start" + deferDismiss + /> + -
@@ -596,12 +597,13 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
- @@ -627,7 +629,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran } > - +
@@ -693,14 +695,9 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
{t("agentManager.import.branches")}
- @@ -727,7 +724,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran defaultLabel={t("agentManager.dialog.branchBadge.default")} remoteLabel={t("agentManager.dialog.branchBadge.remote")} /> - +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 99f91f7f957..721c9bde312 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1033,8 +1033,11 @@ button.am-section-toggle:hover .am-section-label { } .am-tab-list { + --am-tab-max-width: 240px; + --am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width)); display: flex; align-items: stretch; + flex: 0 1 calc(var(--tab-count, 1) * var(--am-tab-max-width)); min-width: 0; overflow-x: auto; height: 100%; @@ -1044,6 +1047,16 @@ button.am-section-toggle:hover .am-section-label { -ms-overflow-style: none; } +.am-tab-list-wrap { + display: flex; + align-items: stretch; + gap: 4px; + min-width: 0; + flex: 0 1 auto; + max-width: 100%; + height: 100%; +} + .am-tab-list::-webkit-scrollbar { display: none; } @@ -1053,7 +1066,7 @@ button.am-section-toggle:hover .am-section-label { position: relative; display: flex; min-width: 0; - flex-shrink: 1; + flex: 1 1 auto; align-items: stretch; height: 100%; overflow: hidden; @@ -1086,10 +1099,11 @@ button.am-section-toggle:hover .am-section-label { } .am-tab { + position: relative; display: flex; align-items: center; - gap: 6px; - padding: 0 12px; + gap: 5px; + padding: 0 8px; background: none; color: var(--text-weak); font-size: var(--kilo-font-size-12); @@ -1098,7 +1112,12 @@ button.am-section-toggle:hover .am-section-label { border: none; border-bottom: 2px solid transparent; min-width: 0; + width: 100%; height: 100%; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; } .am-tab:hover { @@ -1108,32 +1127,59 @@ button.am-section-toggle:hover .am-section-label { .am-tab-active { color: var(--text-base); border-bottom-color: var(--surface-interactive-base); + background: color-mix(in srgb, var(--surface-interactive-base) 10%, transparent); +} + +.am-tab-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.am-tab-icon [data-component="icon"] { + color: currentColor; } .am-tab-label { + flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.am-tab-close { +.am-tab-close-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; flex-shrink: 0; -} - -.am-tab .am-tab-close { opacity: 0; + pointer-events: none; + transition: opacity 100ms ease; } -.am-tab:hover .am-tab-close { - opacity: 0.6; -} - -.am-tab-active .am-tab-close { - opacity: 0.6; -} - -.am-tab-close:hover { +.am-tab:hover .am-tab-close-wrap, +.am-tab-active .am-tab-close-wrap { opacity: 1; + pointer-events: auto; +} + +.am-tab-close-wrap:hover { + opacity: 1; +} + +.am-tab-close[data-component="icon-button"] { + width: 20px; + height: 20px; +} + +.am-tab-close[data-component="icon-button"] [data-slot="icon-svg"] { + color: var(--text-base); } /* Drag-and-drop sortable tab wrapper */ @@ -1141,12 +1187,28 @@ button.am-section-toggle:hover .am-section-label { .am-tab-sortable { display: flex; height: 100%; + width: var(--am-tab-width); + min-width: var(--am-tab-width); + max-width: var(--am-tab-width); + flex: 0 0 var(--am-tab-width); touch-action: none; + transition: + flex-basis 140ms ease, + max-width 140ms ease, + min-width 140ms ease, + width 140ms ease; +} + +.am-tab-list[data-tab-widths-frozen] .am-tab-sortable, +.am-tab-dragging { + transition: none; } .am-tab-sortable > [data-component="tooltip-trigger"], .am-tab-sortable > [data-slot="context-menu-trigger"] > [data-component="tooltip-trigger"] { height: 100%; + min-width: 0; + width: 100%; } .am-tab-dragging { @@ -1211,6 +1273,7 @@ body.am-wt-dragging-active * { .am-tab-add-split { flex-shrink: 0; align-self: center; + margin-right: 2px; } .am-tab-actions { @@ -1222,6 +1285,166 @@ body.am-wt-dragging-active * { padding-right: 4px; } +.am-tabs-menu-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-weak); + cursor: pointer; + flex-shrink: 0; +} + +.am-tabs-menu-trigger:hover, +[data-slot="popover-trigger"][data-expanded] .am-tabs-menu-trigger { + background: var(--surface-inset-base-hover); + color: var(--text-base); +} + +.am-tabs-search-icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.35; + stroke-linecap: round; + stroke-linejoin: round; +} + +.am-tabs-menu[data-component="popover-content"] { + width: 300px; + max-height: min(360px, calc(100vh - 64px)); + padding: 0; + overflow: hidden; +} + +.am-tabs-menu [data-slot="popover-body"] { + padding: 0; +} + +.am-tabs-menu-search { + display: flex; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border-base); + color: var(--text-weaker); +} + +.am-tabs-menu-search-input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--text-base); + font-family: inherit; + font-size: var(--kilo-font-size-12); + outline: none; + user-select: text; +} + +.am-tabs-menu-search-input::placeholder { + color: var(--text-weaker); +} + +.am-tabs-menu-list { + max-height: 300px; + overflow-y: auto; + padding: 4px; +} + +.am-tabs-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-base); + cursor: pointer; + font-family: inherit; + font-size: var(--kilo-font-size-12); + text-align: left; +} + +.am-tabs-menu-item:hover { + background: var(--surface-inset-base-hover); +} + +.am-tabs-menu-item-marked:not(.am-tabs-menu-item-active) { + background: var(--surface-inset-base-hover); +} + +.am-tabs-menu-item-active, +.am-tabs-menu-item-active:hover { + background: var(--surface-interactive-base); + color: var(--text-on-interactive-base); +} + +.am-tabs-menu-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.am-tabs-menu-spinner { + width: 14px; + height: 14px; +} + +.am-tabs-menu-status { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + margin-left: auto; + color: var(--text-weaker); + font-size: var(--kilo-font-size-10); + line-height: 1; + text-transform: uppercase; +} + +.am-tabs-menu-status[data-tone="active"] { + color: var(--text-weak); +} + +.am-tabs-menu-status[data-tone="busy"] { + color: var(--vscode-testing-iconPassed, #34d399); +} + +.am-tabs-menu-status[data-tone="waiting"] { + color: #fbbf24; +} + +.am-tabs-menu-item-active .am-tabs-menu-status { + color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent); +} + +.am-tabs-menu-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-tabs-menu-empty { + padding: 14px 10px; + color: var(--text-weaker); + font-size: var(--kilo-font-size-12); + text-align: center; +} + /* Empty worktree state */ .am-empty-state { @@ -2868,7 +3091,7 @@ body.am-wt-dragging-active * { padding: 0 16px 16px; min-width: 460px; max-height: 350px; - overflow-y: auto; + overflow-y: visible; } .am-import-section { @@ -3042,7 +3265,7 @@ body.am-wt-dragging-active * { } .am-dropdown-list { - max-height: 200px; + max-height: min(320px, var(--kb-popper-content-available-height, 320px) - 44px); overflow-y: auto; overflow-x: hidden; padding: 4px; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 8fe7cc66eff..449dcb4e950 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "الطرفية", "agentManager.tab.openTerminal": "فتح الطرفية", "agentManager.tab.newOptions": "المزيد من خيارات علامات التبويب الجديدة", + "agentManager.tabsMenu.label": "البحث في علامات التبويب المفتوحة", + "agentManager.tabsMenu.search": "البحث في علامات التبويب...", + "agentManager.tabsMenu.empty": "لا توجد علامات تبويب مطابقة", + "agentManager.tabsMenu.status.waiting": "انتظار", + "agentManager.tabsMenu.status.working": "يعمل", + "agentManager.tabsMenu.status.retry": "إعادة", "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 0403f4757c5..0e0e36a5ccc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Abrir Terminal", "agentManager.tab.newOptions": "Mais opções de nova aba", + "agentManager.tabsMenu.label": "Pesquisar abas abertas", + "agentManager.tabsMenu.search": "Pesquisar abas...", + "agentManager.tabsMenu.empty": "Nenhuma aba correspondente", + "agentManager.tabsMenu.status.waiting": "Espera", + "agentManager.tabsMenu.status.working": "Execução", + "agentManager.tabsMenu.status.retry": "Repetir", "agentManager.terminal.new": "Nova aba de terminal", "agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 83d07a4300f..098aeb80604 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Otvori Terminal", "agentManager.tab.newOptions": "Više opcija nove kartice", + "agentManager.tabsMenu.label": "Pretraži otvorene kartice", + "agentManager.tabsMenu.search": "Pretraži kartice...", + "agentManager.tabsMenu.empty": "Nema podudarnih kartica", + "agentManager.tabsMenu.status.waiting": "Čeka", + "agentManager.tabsMenu.status.working": "Radi", + "agentManager.tabsMenu.status.retry": "Pokušaj", "agentManager.terminal.new": "Nova kartica terminala", "agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index b922b7ad487..9a53d313ab2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Åbn Terminal", "agentManager.tab.newOptions": "Flere nye faneindstillinger", + "agentManager.tabsMenu.label": "Søg i åbne faner", + "agentManager.tabsMenu.search": "Søg faner...", + "agentManager.tabsMenu.empty": "Ingen matchende faner", + "agentManager.tabsMenu.status.waiting": "Venter", + "agentManager.tabsMenu.status.working": "Arbejder", + "agentManager.tabsMenu.status.retry": "Igen", "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 056b3365ea9..658958fc0fc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminal öffnen", "agentManager.tab.newOptions": "Weitere Tab-Optionen", + "agentManager.tabsMenu.label": "Offene Tabs suchen", + "agentManager.tabsMenu.search": "Tabs suchen...", + "agentManager.tabsMenu.empty": "Keine passenden Tabs", + "agentManager.tabsMenu.status.waiting": "Warten", + "agentManager.tabsMenu.status.working": "Läuft", + "agentManager.tabsMenu.status.retry": "Erneut", "agentManager.terminal.new": "Neuer Terminal-Tab", "agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index e564072f3a3..6144f668548 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -53,6 +53,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Open Terminal", "agentManager.tab.newOptions": "More new-tab options", + "agentManager.tabsMenu.label": "Search open tabs", + "agentManager.tabsMenu.search": "Search tabs...", + "agentManager.tabsMenu.empty": "No matching tabs", + "agentManager.tabsMenu.status.waiting": "Wait", + "agentManager.tabsMenu.status.working": "Work", + "agentManager.tabsMenu.status.retry": "Retry", "agentManager.terminal.new": "New Terminal Tab", "agentManager.terminal.ended": "terminal ended — close tab to dismiss", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 55fe7198a8f..5ee42b117b8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Abrir Terminal", "agentManager.tab.newOptions": "Más opciones de nueva pestaña", + "agentManager.tabsMenu.label": "Buscar pestañas abiertas", + "agentManager.tabsMenu.search": "Buscar pestañas...", + "agentManager.tabsMenu.empty": "No hay pestañas coincidentes", + "agentManager.tabsMenu.status.waiting": "Espera", + "agentManager.tabsMenu.status.working": "En curso", + "agentManager.tabsMenu.status.retry": "Reintento", "agentManager.terminal.new": "Nueva pestaña de terminal", "agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index d61da449af9..66e3e2a7bcf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Ouvrir le Terminal", "agentManager.tab.newOptions": "Plus d'options de nouvel onglet", + "agentManager.tabsMenu.label": "Rechercher les onglets ouverts", + "agentManager.tabsMenu.search": "Rechercher des onglets...", + "agentManager.tabsMenu.empty": "Aucun onglet correspondant", + "agentManager.tabsMenu.status.waiting": "Attente", + "agentManager.tabsMenu.status.working": "En cours", + "agentManager.tabsMenu.status.retry": "Réessai", "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 4035c4ac951..4b1843043b9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "ターミナル", "agentManager.tab.openTerminal": "ターミナルを開く", "agentManager.tab.newOptions": "新しいタブのその他のオプション", + "agentManager.tabsMenu.label": "開いているタブを検索", + "agentManager.tabsMenu.search": "タブを検索...", + "agentManager.tabsMenu.empty": "一致するタブがありません", + "agentManager.tabsMenu.status.waiting": "待機", + "agentManager.tabsMenu.status.working": "実行中", + "agentManager.tabsMenu.status.retry": "再試行", "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 3fa6207bd92..e2cc019eb19 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "터미널", "agentManager.tab.openTerminal": "터미널 열기", "agentManager.tab.newOptions": "더 많은 새 탭 옵션", + "agentManager.tabsMenu.label": "열린 탭 검색", + "agentManager.tabsMenu.search": "탭 검색...", + "agentManager.tabsMenu.empty": "일치하는 탭 없음", + "agentManager.tabsMenu.status.waiting": "대기", + "agentManager.tabsMenu.status.working": "작업 중", + "agentManager.tabsMenu.status.retry": "재시도", "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 6d71eb72fb9..09f47b76e5f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -53,6 +53,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminal openen", "agentManager.tab.newOptions": "Meer opties voor nieuwe tabblad", + "agentManager.tabsMenu.label": "Open tabbladen zoeken", + "agentManager.tabsMenu.search": "Tabbladen zoeken...", + "agentManager.tabsMenu.empty": "Geen overeenkomende tabbladen", + "agentManager.tabsMenu.status.waiting": "Wacht", + "agentManager.tabsMenu.status.working": "Bezig", + "agentManager.tabsMenu.status.retry": "Opnieuw", "agentManager.terminal.new": "Nieuw terminaltabblad", "agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index bd0fe919656..9966510f9a7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Åpne Terminal", "agentManager.tab.newOptions": "Flere alternativer for ny fane", + "agentManager.tabsMenu.label": "Søk i åpne faner", + "agentManager.tabsMenu.search": "Søk i faner...", + "agentManager.tabsMenu.empty": "Ingen treffende faner", + "agentManager.tabsMenu.status.waiting": "Venter", + "agentManager.tabsMenu.status.working": "Jobber", + "agentManager.tabsMenu.status.retry": "Igjen", "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index d27b43d4c3a..14511d94582 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Otwórz Terminal", "agentManager.tab.newOptions": "Więcej opcji nowej karty", + "agentManager.tabsMenu.label": "Szukaj otwartych kart", + "agentManager.tabsMenu.search": "Szukaj kart...", + "agentManager.tabsMenu.empty": "Brak pasujących kart", + "agentManager.tabsMenu.status.waiting": "Czeka", + "agentManager.tabsMenu.status.working": "Pracuje", + "agentManager.tabsMenu.status.retry": "Ponów", "agentManager.terminal.new": "Nowa karta terminala", "agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index c963c944c68..d6f8d9a8082 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "Терминал", "agentManager.tab.openTerminal": "Открыть терминал", "agentManager.tab.newOptions": "Другие параметры новой вкладки", + "agentManager.tabsMenu.label": "Искать открытые вкладки", + "agentManager.tabsMenu.search": "Поиск вкладок...", + "agentManager.tabsMenu.empty": "Нет подходящих вкладок", + "agentManager.tabsMenu.status.waiting": "Ожидание", + "agentManager.tabsMenu.status.working": "Работа", + "agentManager.tabsMenu.status.retry": "Повтор", "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 506f6ba0fa7..43260a7affb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "เทอร์มินัล", "agentManager.tab.openTerminal": "เปิดเทอร์มินัล", "agentManager.tab.newOptions": "ตัวเลือกแท็บใหม่เพิ่มเติม", + "agentManager.tabsMenu.label": "ค้นหาแท็บที่เปิดอยู่", + "agentManager.tabsMenu.search": "ค้นหาแท็บ...", + "agentManager.tabsMenu.empty": "ไม่มีแท็บที่ตรงกัน", + "agentManager.tabsMenu.status.waiting": "รอ", + "agentManager.tabsMenu.status.working": "ทำงาน", + "agentManager.tabsMenu.status.retry": "ลองใหม่", "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 1dac84b5990..e0863aa3f57 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -53,6 +53,12 @@ export const dict = { "agentManager.tab.terminal": "Terminal", "agentManager.tab.openTerminal": "Terminali Aç", "agentManager.tab.newOptions": "Daha fazla yeni sekme seçeneği", + "agentManager.tabsMenu.label": "Açık sekmeleri ara", + "agentManager.tabsMenu.search": "Sekmelerde ara...", + "agentManager.tabsMenu.empty": "Eşleşen sekme yok", + "agentManager.tabsMenu.status.waiting": "Bekliyor", + "agentManager.tabsMenu.status.working": "Çalışıyor", + "agentManager.tabsMenu.status.retry": "Yeniden", "agentManager.terminal.new": "Yeni Terminal Sekmesi", "agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index d2d196c6604..e6e338e4258 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -53,6 +53,12 @@ export const dict = { "agentManager.tab.terminal": "Термінал", "agentManager.tab.openTerminal": "Відкрити термінал", "agentManager.tab.newOptions": "Інші параметри нової вкладки", + "agentManager.tabsMenu.label": "Шукати відкриті вкладки", + "agentManager.tabsMenu.search": "Шукати вкладки...", + "agentManager.tabsMenu.empty": "Немає відповідних вкладок", + "agentManager.tabsMenu.status.waiting": "Очікує", + "agentManager.tabsMenu.status.working": "Працює", + "agentManager.tabsMenu.status.retry": "Повтор", "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index c125c2e385a..26b4c666f8b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "终端", "agentManager.tab.openTerminal": "打开终端", "agentManager.tab.newOptions": "更多新建标签页选项", + "agentManager.tabsMenu.label": "搜索打开的标签页", + "agentManager.tabsMenu.search": "搜索标签页...", + "agentManager.tabsMenu.empty": "无匹配标签页", + "agentManager.tabsMenu.status.waiting": "等待", + "agentManager.tabsMenu.status.working": "工作中", + "agentManager.tabsMenu.status.retry": "重试", "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 4fcdc1f355e..e1b9335685d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -49,6 +49,12 @@ export const dict = { "agentManager.tab.terminal": "終端機", "agentManager.tab.openTerminal": "開啟終端機", "agentManager.tab.newOptions": "更多新增分頁選項", + "agentManager.tabsMenu.label": "搜尋開啟的分頁", + "agentManager.tabsMenu.search": "搜尋分頁...", + "agentManager.tabsMenu.empty": "沒有相符的分頁", + "agentManager.tabsMenu.status.waiting": "等待", + "agentManager.tabsMenu.status.working": "工作中", + "agentManager.tabsMenu.status.retry": "重試", "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts new file mode 100644 index 00000000000..15049eec698 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts @@ -0,0 +1,37 @@ +import type { AgentManagerSendInitialMessage, SendMessageRequest } from "../src/types/messages" + +interface VariantSession { + getSessionAgent: (sessionID: string) => string + setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void +} + +export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageRequest | undefined { + if (!ev.text) return undefined + return { + type: "sendMessage", + text: ev.text, + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent, + variant: ev.variant, + files: ev.files, + } +} + +export function initialVariant(ev: AgentManagerSendInitialMessage, agent: string) { + if (!ev.providerID || !ev.modelID || !ev.variant) return undefined + return { + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent ?? agent, + value: ev.variant, + } +} + +export function seedInitialVariant(session: VariantSession, ev: AgentManagerSendInitialMessage) { + const state = initialVariant(ev, session.getSessionAgent(ev.sessionId)) + if (!state) return + session.setSessionVariant(state.sessionID, state.providerID, state.modelID, state.value, state.agent) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx index 7238060c76f..e8a5ea775a0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx @@ -17,6 +17,7 @@ import { createRoot } from "solid-js" import type { SessionInfo } from "../src/types/messages" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Icon } from "@kilocode/kilo-ui/icon" +import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { useLanguage } from "../src/context/language" @@ -45,6 +46,7 @@ export const ConstrainDragYAxis: Component = () => { export const SortableTab: Component<{ tab: SessionInfo active: boolean + busy: boolean keybind?: string closeKeybind?: string onSelect: () => void @@ -75,8 +77,18 @@ export const SortableTab: Component<{ onClick={props.onSelect} onMouseDown={props.onMiddleClick} > + + + + + {props.tab.title || t("agentManager.session.untitled")} - + - + + + {props.label} - + string | undefined isPending: (id: string) => boolean + isBusy: (id: string) => boolean tabLookup: () => Map adjacentHint: (id: string, activeId: string, ids: string[], prev: string, next: string) => string // Handlers @@ -131,6 +132,7 @@ function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element { { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-scroll.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-scroll.ts new file mode 100644 index 00000000000..eae74ec9355 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-scroll.ts @@ -0,0 +1,83 @@ +import { createEffect, createSignal, onCleanup } from "solid-js" +import type { Accessor } from "solid-js" +import type { SessionInfo } from "../src/types/messages" + +/** + * Keeps the Agent Manager tab strip usable when tabs overflow. + * + * - Converts vertical wheel movement over the tab strip into horizontal scroll. + * - Tracks whether the left/right fade indicators should be visible. + * - Scrolls the active tab into view after tab selection or tab list changes. + */ +export function useTabScroll(activeTabs: Accessor, activeId: Accessor) { + const [ref, setRef] = createSignal() + const [showLeft, setShowLeft] = createSignal(false) + const [showRight, setShowRight] = createSignal(false) + let scrollFrame: number | undefined + let activeFrame: number | undefined + + const update = () => { + if (scrollFrame !== undefined) return + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + const el = ref() + if (!el) return + setShowLeft(el.scrollLeft > 2) + setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2) + }) + } + + const wheel = (e: WheelEvent) => { + const el = ref() + if (!el) return + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return + e.preventDefault() + el.scrollLeft += e.deltaY > 0 ? 60 : -60 + } + + createEffect(() => { + const el = ref() + if (!el) return + el.addEventListener("scroll", update, { passive: true }) + el.addEventListener("wheel", wheel, { passive: false }) + const ro = new ResizeObserver(update) + ro.observe(el) + const mo = new MutationObserver(update) + mo.observe(el, { childList: true, subtree: true }) + onCleanup(() => { + el.removeEventListener("scroll", update) + el.removeEventListener("wheel", wheel) + ro.disconnect() + mo.disconnect() + }) + }) + + createEffect(() => { + const id = activeId() + const el = ref() + activeTabs() + if (!id || !el) return + if (activeFrame !== undefined) cancelAnimationFrame(activeFrame) + activeFrame = requestAnimationFrame(() => { + activeFrame = undefined + const tab = el.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null + if (!tab) return + const left = tab.offsetLeft + const right = left + tab.offsetWidth + if (left < el.scrollLeft) { + el.scrollTo({ left: left - 8, behavior: "smooth" }) + return + } + if (right > el.scrollLeft + el.clientWidth) { + el.scrollTo({ left: right - el.clientWidth + 8, behavior: "smooth" }) + } + }) + }) + + onCleanup(() => { + if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame) + if (activeFrame !== undefined) cancelAnimationFrame(activeFrame) + }) + + return { setRef, showLeft, showRight } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-widths.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-widths.ts new file mode 100644 index 00000000000..95089055581 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-widths.ts @@ -0,0 +1,21 @@ +export function setTabWidths(frozen: boolean, root: ParentNode = document) { + const list = root.querySelector(".am-tab-list") + if (!(list instanceof HTMLElement)) return + list.toggleAttribute("data-tab-widths-frozen", frozen) + + const tabs = Array.from(list.children).filter((child): child is HTMLElement => child instanceof HTMLElement) + for (const tab of tabs) { + if (frozen) { + const width = tab.getBoundingClientRect().width + tab.style.width = `${width}px` + tab.style.minWidth = `${width}px` + tab.style.flex = `0 0 ${width}px` + tab.style.maxWidth = `${width}px` + continue + } + tab.style.width = "" + tab.style.minWidth = "" + tab.style.flex = "" + tab.style.maxWidth = "" + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx index 8cfbb167354..0fdd48796f3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx @@ -56,9 +56,16 @@ export const SortableTerminalTab: Component<{ onClick={props.onSelect} onMouseDown={props.onMiddleClick} > - + + + {props.label} - + { id: string } | undefined postMessage: (msg: unknown) => void + onRemove?: () => void /** Resolve the current sidebar selection for the new-terminal helper. */ getSelection: () => string | null /** Sentinel value for the LOCAL sidebar selection. */ @@ -258,6 +259,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } const closeTerminal = (terminalId: string) => { + deps.onRemove?.() const ids = deps.tabIds() const idx = ids.indexOf(terminalId) // Pick the tab to focus after closing: prefer the next tab, fall diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx new file mode 100644 index 00000000000..82d438f7b12 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx @@ -0,0 +1,81 @@ +import type { Component } from "solid-js" +import { createSignal, Show } from "solid-js" +import { Select } from "@kilocode/kilo-ui/select" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../src/context/language" +import type { DiffSourceDescriptor } from "../../src/diff/sources/types" + +interface DiffPickerHeaderProps { + descriptors: DiffSourceDescriptor[] + currentId: string | undefined + onSelect: (id: string) => void +} + +const GROUP_KEYS: Record = { + Session: "diffViewer.group.session", + Git: "diffViewer.group.git", +} + +const TOOLTIP_OPEN_DELAY_MS = 500 + +export const DiffPickerHeader: Component = (props) => { + const { t } = useLanguage() + const current = () => props.descriptors.find((d) => d.id === props.currentId) + const [highlight, setHighlight] = createSignal(undefined) + + const label = (desc: DiffSourceDescriptor): string => t(`diffViewer.source.${desc.type}.label`) + + const tooltip = (desc: DiffSourceDescriptor): string => t(`diffViewer.source.${desc.type}.tooltip`) + + const group = (desc: DiffSourceDescriptor): string => t(GROUP_KEYS[desc.group]) + + const onHighlight = (desc: DiffSourceDescriptor | undefined) => { + if (!desc) { + setHighlight(undefined) + return + } + const timer = setTimeout(() => setHighlight(desc.id), TOOLTIP_OPEN_DELAY_MS) + return () => { + clearTimeout(timer) + setHighlight(undefined) + } + } + + return ( +
+ 1} + fallback={ + + + {(d) => label(d())} + + + } + > + + options={props.descriptors} + current={current()} + value={(d) => d.id} + label={label} + groupBy={group} + variant="secondary" + size="small" + onSelect={(d) => { + if (d) props.onSelect(d.id) + }} + onHighlight={onHighlight} + > + {(desc) => { + if (!desc) return "" + return ( + + {label(desc)} + + ) + }} + + +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index 87093996ec9..dedc564dbbe 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -1,4 +1,4 @@ -import { createSignal, onCleanup } from "solid-js" +import { createEffect, createSignal, on, onCleanup, Show } from "solid-js" import type { Component } from "solid-js" import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" @@ -8,13 +8,22 @@ import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import { Code } from "@kilocode/kilo-ui/code" import { Diff } from "@kilocode/kilo-ui/diff" import { File } from "@kilocode/kilo-ui/file" +import { Icon } from "@kilocode/kilo-ui/icon" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { Toast } from "@kilocode/kilo-ui/toast" import { FullScreenDiffView } from "../agent-manager/FullScreenDiffView" -import { LanguageProvider } from "../src/context/language" +import { mergeWorktreeDiffs } from "../agent-manager/diff-state" +import { LanguageProvider, useLanguage } from "../src/context/language" import { ServerProvider, useServer } from "../src/context/server" import { getVSCodeAPI, VSCodeProvider, useVSCode } from "../src/context/vscode" import type { ReviewComment, WebviewMessage, WorktreeFileDiff } from "../src/types/messages" +import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/diff/sources/types" +import type { DiffViewerNotice } from "../src/types/messages/extension-messages" +import { DiffPickerHeader } from "./DiffPickerHeader" + +const NOTICE_KEYS: Record = { + "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", +} type DiffStyle = "unified" | "split" @@ -22,12 +31,24 @@ const post = (message: WebviewMessage) => getVSCodeAPI().postMessage(message) const DiffViewerContent: Component = () => { const vscode = useVSCode() + const { t } = useLanguage() const [diffs, setDiffs] = createSignal([]) const [loading, setLoading] = createSignal(true) const [comments, setComments] = createSignal([]) const [diffStyle, setDiffStyle] = createSignal("unified") const [markdown, setMarkdown] = createSignal(false) const [reverting, setReverting] = createSignal>(new Set()) + const [loadingFiles, setLoadingFiles] = createSignal>(new Set()) + const [availableSources, setAvailableSources] = createSignal([]) + const [currentSourceId, setCurrentSourceId] = createSignal(undefined) + const [capabilities, setCapabilities] = createSignal(undefined) + const [notice, setNotice] = createSignal(undefined) + + const noticeText = () => { + const n = notice() + if (!n) return "" + return t(NOTICE_KEYS[n]) + } const markReverting = (file: string, active: boolean) => { setReverting((prev) => { @@ -38,9 +59,47 @@ const DiffViewerContent: Component = () => { }) } + const markLoadingFile = (file: string, active: boolean) => { + setLoadingFiles((prev) => { + if (active && prev.has(file)) return prev + if (!active && !prev.has(file)) return prev + const next = new Set(prev) + if (active) next.add(file) + else next.delete(file) + return next + }) + } + + const requestDiffFile = (file: string) => { + if (loadingFiles().has(file)) return + markLoadingFile(file, true) + post({ type: "diffViewer.requestFile", file }) + } + + const refreshStaleDiffs = (files: Set) => { + for (const file of files) { + if (loadingFiles().has(file)) continue + markLoadingFile(file, true) + post({ type: "diffViewer.requestFile", file }) + } + } + const unsubscribe = vscode.onMessage((msg) => { if (msg.type === "diffViewer.diffs") { - setDiffs(msg.diffs) + // Preserve cached `before`/`after` across polls so summarized polling + // updates don't clobber loaded detail. Mirrors the agent manager's + // worktree diff merge — see worktree-diff-controller.ts. + const merged = mergeWorktreeDiffs(diffs(), msg.diffs) + setDiffs(merged.diffs) + if (merged.stale.size > 0) refreshStaleDiffs(merged.stale) + return + } + + if (msg.type === "diffViewer.diffFile") { + markLoadingFile(msg.file, false) + const fresh = msg.diff + if (!fresh) return + setDiffs((prev) => prev.map((entry) => (entry.file === fresh.file ? fresh : entry))) return } @@ -58,8 +117,43 @@ const DiffViewerContent: Component = () => { setMarkdown(msg.render) return } + if (msg.type === "setAvailableSources") { + setAvailableSources(msg.descriptors) + setCurrentSourceId(msg.currentId) + return + } + + if (msg.type === "diffViewer.capabilities") { + setCapabilities(msg.capabilities) + return + } + + if (msg.type === "diffViewer.notice") { + setNotice(msg.notice) + return + } }) + const selectSource = (id: string) => { + if (id === currentSourceId()) return + post({ type: "selectSource", id }) + } + + // Reset transient UI state when the active source changes. Comments are + // discarded without confirmation; diff style goes back to + // unified; in-flight revert indicators are cleared. The diffs list itself + // is reset by the extension sending `diffs: []` before the new fetch. + createEffect( + on(currentSourceId, (id, prev) => { + if (prev === undefined || id === prev) return + setComments([]) + setDiffStyle("unified") + setReverting(new Set()) + setLoadingFiles(new Set()) + setNotice(undefined) + }), + ) + const handler = (event: MessageEvent) => { const msg = event.data if (msg?.type !== "appendReviewComments" || !Array.isArray(msg.comments)) return @@ -73,35 +167,52 @@ const DiffViewerContent: Component = () => { }) return ( - {}} - diffStyle={diffStyle()} - onDiffStyleChange={(style) => { - setDiffStyle(style) - post({ type: "diffViewer.setDiffStyle", style }) - }} - markdownRender={markdown()} - onMarkdownRenderChange={(render) => { - setMarkdown(render) - post({ type: "diffViewer.setMarkdownRender", render }) - }} - onOpenFile={(relativePath) => { - post({ type: "openFile", filePath: relativePath }) - }} - onRevertFile={(file) => { - markReverting(file, true) - post({ type: "diffViewer.revertFile", file }) - }} - revertingFiles={reverting()} - onClose={() => { - post({ type: "diffViewer.close" }) - }} - /> + <> + 0}> + + + +
+ + + + {noticeText()} +
+
+ {}} + diffStyle={diffStyle()} + onDiffStyleChange={(style) => { + setDiffStyle(style) + post({ type: "diffViewer.setDiffStyle", style }) + }} + markdownRender={markdown()} + onMarkdownRenderChange={(render) => { + setMarkdown(render) + post({ type: "diffViewer.setMarkdownRender", render }) + }} + onOpenFile={(relativePath) => { + post({ type: "openFile", filePath: relativePath }) + }} + onRevertFile={(file) => { + markReverting(file, true) + post({ type: "diffViewer.revertFile", file }) + }} + revertingFiles={reverting()} + canRevert={capabilities()?.revert ?? true} + canComment={capabilities()?.comments ?? true} + onClose={() => { + post({ type: "diffViewer.close" }) + }} + /> + ) } diff --git a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx index af5f6d77bd7..80b31504d68 100644 --- a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx @@ -12,7 +12,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { RadioGroup } from "@kilocode/kilo-ui/radio-group" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import { normalize, text } from "@kilocode/kilo-ui/session-diff" +import { contents } from "@kilocode/kilo-ui/session-diff" import { LanguageProvider, useLanguage } from "../src/context/language" import { ServerProvider, useServer } from "../src/context/server" import { getVSCodeAPI, VSCodeProvider } from "../src/context/vscode" @@ -68,8 +68,8 @@ const DiffVirtualContent: Component = () => { if (!d) return { before: "", after: "" } if (d.before !== undefined || d.after !== undefined) return { before: d.before ?? "", after: d.after ?? "" } if (d.patch) { - const view = normalize(d as { file: string; patch: string; additions: number; deletions: number }) - return { before: text(view, "deletions"), after: text(view, "additions") } + const view = contents(d as { file: string; patch: string; additions: number; deletions: number }) + return { before: view.before, after: view.after } } return { before: "", after: "" } }) diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx index 161ed4ebff3..aa4cff2f128 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/KiloClawApp.tsx @@ -8,7 +8,8 @@ import { Spinner } from "@kilocode/kilo-ui/spinner" import { Toast } from "@kilocode/kilo-ui/toast" import { ClawProvider, useClaw } from "./context/claw" import { KiloClawLanguageProvider, useKiloClawLanguage } from "./context/language" -import { ChatPanel } from "./components/ChatPanel" +import { ConversationList } from "./components/ConversationList" +import { MessageArea } from "./components/MessageArea" import { StatusSidebar } from "./components/StatusSidebar" import { SetupView } from "./components/SetupView" import { UpgradeView } from "./components/UpgradeView" @@ -46,7 +47,8 @@ function Content() {
- + +
diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx deleted file mode 100644 index 42b52c085f6..00000000000 --- a/packages/kilo-vscode/webview-ui/kiloclaw/components/ChatPanel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -// KiloClaw chat panel — message list + input - -import { createSignal, createEffect, For, Show, createMemo, onMount } from "solid-js" -import { Button } from "@kilocode/kilo-ui/button" -import { useClaw } from "../context/claw" -import { useKiloClawLanguage } from "../context/language" -import { MessageBubble } from "./MessageBubble" - -export function ChatPanel() { - const claw = useClaw() - const { t } = useKiloClawLanguage() - const [text, setText] = createSignal("") - let list!: HTMLDivElement - let input!: HTMLTextAreaElement - - const disabled = createMemo(() => { - const s = claw.status() - return !s || s.status !== "running" || !claw.connected() - }) - - const placeholder = createMemo(() => { - if (!claw.connected()) return t("kiloClaw.chat.connecting") - const s = claw.status() - if (!s || s.status !== "running") return t("kiloClaw.chat.notRunning") - return t("kiloClaw.chat.placeholder") - }) - - // Auto-scroll to bottom when messages change - createEffect(() => { - claw.messages() - if (list) { - requestAnimationFrame(() => { - list.scrollTop = list.scrollHeight - }) - } - }) - - // Focus input on mount - onMount(() => { - if (input && !disabled()) input.focus() - }) - - const submit = () => { - const val = text().trim() - if (!val || disabled()) return - claw.send(val) - setText("") - if (input) { - input.style.height = "auto" - } - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - submit() - } - } - - const onInput = (e: InputEvent) => { - const target = e.target as HTMLTextAreaElement - setText(target.value) - // Auto-resize - target.style.height = "auto" - target.style.height = Math.min(target.scrollHeight, 120) + "px" - } - - return ( -
- {/* Header */} -
-
- - - KiloClaw {claw.online() ? t("kiloClaw.chat.online") : t("kiloClaw.chat.offline")} - -
-
- - {/* Messages */} -
- -
{t("kiloClaw.chat.empty")}
-
- {(msg) => } -
- - {/* Input */} -
-