diff --git a/.changeset/compact-thinking-level.md b/.changeset/compact-thinking-level.md deleted file mode 100644 index a00bd74f63..0000000000 --- a/.changeset/compact-thinking-level.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Preserve the selected thinking level after compacting a session. diff --git a/.changeset/kiloclaw-kilo-chat-migration.md b/.changeset/kiloclaw-kilo-chat-migration.md deleted file mode 100644 index 5945405333..0000000000 --- a/.changeset/kiloclaw-kilo-chat-migration.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"kilo-code": minor -"@kilocode/cli": minor -"@kilocode/kilo-gateway": minor -"@kilocode/sdk": minor ---- - -Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation. diff --git a/.changeset/native-tab-titles.md b/.changeset/native-tab-titles.md deleted file mode 100644 index 2b583182b8..0000000000 --- a/.changeset/native-tab-titles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Update regular Kilo editor tab titles to follow the current session title. diff --git a/.changeset/remove-custom-providers.md b/.changeset/remove-custom-providers.md deleted file mode 100644 index b3ffa35773..0000000000 --- a/.changeset/remove-custom-providers.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch -"@kilocode/sdk": patch ---- - -Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled. diff --git a/.changeset/steady-agents-think.md b/.changeset/steady-agents-think.md deleted file mode 100644 index c3278a9d44..0000000000 --- a/.changeset/steady-agents-think.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. diff --git a/.changeset/unified-diff-panel.md b/.changeset/unified-diff-panel.md deleted file mode 100644 index c7c42d5ede..0000000000 --- a/.changeset/unified-diff-panel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Unify the Changes panel into a single tab with a source dropdown to switch between workspace and current-session diffs. Show the session's additions and deletions on the sidebar changes badge, and warn in the panel when snapshots are disabled for the repository. diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 674235483a..b0604eb8eb 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -101,8 +101,11 @@ jobs: # curl|bash + apt install nodejs before the CLI even downloads, and on # the Blacksmith runners that occasionally pushes past 6 min when an apt # mirror or NodeSource cdn is slow, killing the run with - # AgentSetupTimeoutError. 600s gives enough headroom for transient - # mirror/cdn slowness while still finishing well under timeout-minutes. + # 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 }} @@ -112,7 +115,7 @@ jobs: -m kilo/anthropic/claude-sonnet-4.6 \ -d hello-world \ --job-name smoke-test-hello-world \ - --agent-setup-timeout 600 + --timeout-multiplier 2 - name: Run smoke test — log-summary-date-ranges env: @@ -124,7 +127,7 @@ jobs: -d terminal-bench-sample \ -t "log-summary-date-ranges" \ --job-name smoke-test-log-summary \ - --agent-setup-timeout 600 + --timeout-multiplier 2 - name: Validate results run: python3 scripts/validate_smoke_test.py jobs/smoke-test-*/ 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 cebc404535..d8aff1fd6c 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 0000000000..9a5bfefdf3 --- /dev/null +++ b/.kilo/skills/gh-issues/SKILL.md @@ -0,0 +1,68 @@ +--- +name: gh-issues +description: Use when creating, triaging, or commenting on GitHub issues for the Kilo VS Code extension or JetBrains plugin via `gh`. Covers issue templates, project board assignment, title conventions, and required `gh` scopes. +--- + +# GitHub Issues + +Use this skill whenever you create or manage a GitHub issue with `gh` for either the VS Code extension or the JetBrains plugin. + +## Templates + +The repo defines issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template instead of opening a blank issue: + +| Template | When to use | +|---|---| +| `Bug report` (`bug-report.yml`) | Reproducible defects with steps, expected, and actual behavior | +| `Feature Request` (`feature-request.yml`) | New capabilities, enhancements, or behavior changes | +| `Question` (`question.yml`) | Usage or design questions that aren't obviously bugs or feature requests | + +Pass the template title to `gh issue create --template`. + +## Title Conventions + +- Use a plain, descriptive title that reads cleanly as a standalone sentence. +- Do not add platform-specific prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Routing happens through project boards, not the title. + +## Project Boards + +Every new issue must land on the correct project board: + +| Surface | Project | URL | +|---|---|---| +| VS Code extension | `VS Code Extension` | https://github.com/orgs/Kilo-Org/projects/25 | +| JetBrains plugin | `Jetbrains Plugin` | https://github.com/orgs/Kilo-Org/projects/39 | + +Pass the project title to `gh issue create --project`. + +## Recipes + +Create a VS Code extension bug report and add it to the board: + +```bash +gh issue create \ + --template "Bug report" \ + --project "VS Code Extension" \ + --title "Sidebar chat fails to render after reload" \ + --body "..." +``` + +Create a JetBrains feature request: + +```bash +gh issue create \ + --template "Feature Request" \ + --project "Jetbrains Plugin" \ + --title "Support Kotlin Multiplatform target detection" \ + --body "..." +``` + +## Scope Errors + +If `gh` reports a missing scope when assigning a project, refresh the auth token and retry: + +```bash +gh auth refresh -s project +``` + +After the refresh succeeds, re-run the original `gh issue create` command. Do not fall back to creating the issue without the project — the board assignment is required. diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json index 7a0e27b435..32676c5624 100644 --- a/.kilocode/package-lock.json +++ b/.kilocode/package-lock.json @@ -323,9 +323,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md index 78216ab01c..3a44fa88dc 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 cc5eda0d05..34fec96cc6 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 5d48917f89..3a9c92b457 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 e7890dfe9b..9fd0b50426 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.42", + "version": "7.2.48", "bin": { "opencode": "./bin/opencode", }, @@ -68,13 +68,14 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", "@markdoc/markdoc": "^0.5.4", "@markdoc/next.js": "^0.5.0", "@vscode/codicons": "^0.0.44", + "@xyflow/react": "12.10.2", "js-yaml": "^4.1.0", "mermaid": "11.12.3", "next": "^16.1.5", @@ -97,7 +98,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -133,7 +134,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.42", + "version": "7.2.48", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -143,7 +144,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -177,7 +178,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -191,8 +192,9 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { + "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", @@ -200,6 +202,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/rootless": "1.5.2", + "diff": "catalog:", "lucide-solid": "0.576.0", "motion": "12.34.5", "motion-dom": "12.34.3", @@ -226,9 +229,10 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -244,12 +248,14 @@ "dotenv": "^16.4.7", "fastest-levenshtein": "^1.0.16", "friendly-words": "1.3.1", + "fuzzysort": "3.1.0", "ignore": "^7.0.3", "js-tiktoken": "^1.0.18", "lru-cache": "^11.0.2", + "marked": "catalog:", "openai": "^4.85.4", "quick-lru": "^7.0.0", - "simple-git": "3.35.2", + "simple-git": "3.36.0", "solid-js": "^1.9.11", "uri-js": "^4.4.1", "virtua": "catalog:", @@ -286,7 +292,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.42", + "version": "7.2.48", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -389,7 +395,7 @@ "ripgrep": "0.3.1", "rotating-file-stream": "3.2.9", "semver": "^7.6.3", - "simple-git": "3.35.2", + "simple-git": "3.36.0", "solid-js": "catalog:", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", @@ -444,7 +450,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -459,8 +465,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", @@ -469,7 +475,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "semver": "^7.6.3", }, @@ -480,7 +486,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "cross-spawn": "catalog:", }, @@ -495,7 +501,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.42", + "version": "7.2.48", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -518,7 +524,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.42", + "version": "7.2.48", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -533,7 +539,7 @@ "@solidjs/router": "catalog:", "@typescript/native-preview": "catalog:", "diff": "catalog:", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", @@ -586,7 +592,7 @@ "@xmldom/xmldom": ">=0.8.12", "defu": "6.1.6", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "effect": "catalog:", "fastify": ">=5.8.3", "happy-dom": ">=20.8.9", @@ -605,10 +611,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", @@ -625,7 +633,7 @@ "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.57", @@ -1501,21 +1509,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=="], @@ -2197,8 +2205,6 @@ "@types/vscode": ["@types/vscode@1.116.0", "", {}, "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -2313,6 +2319,10 @@ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="], + + "@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], @@ -2421,7 +2431,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=="], @@ -2489,13 +2499,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=="], @@ -2569,6 +2579,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="], "clean-stack": ["clean-stack@4.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg=="], @@ -2813,7 +2825,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], + "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -3109,8 +3121,6 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -4057,7 +4067,7 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "simple-git": ["simple-git@3.35.2", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.2", "@simple-git/argv-parser": "^1.0.3", "debug": "^4.4.0" } }, "sha512-ZMjl06lzTm1EScxEGuM6+mEX+NQd14h/B3x0vWU+YOXAMF8sicyi1K4cjTfj5is+35ChJEHDl1EjypzYFWH2FA=="], + "simple-git": ["simple-git@3.36.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.3", "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" } }, "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q=="], "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], @@ -4445,7 +4455,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4513,6 +4523,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@actions/artifact/@actions/core": ["@actions/core@2.0.3", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.2" } }, "sha512-Od9Thc3T1mQJYddvVPM4QGiLUewdh+3txmDYHHxoNdkqysR1MbCT+rFOtNUxYAz+7+6RIsqipVahY2GJqGPyxA=="], @@ -4721,8 +4733,6 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/plugin/effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], - "@opencode-ai/storybook/@storybook/addon-a11y": ["@storybook/addon-a11y@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.3.5" } }, "sha512-5k6lpgfIeLxvNhE8v3wEzdiu73ONKjF4gmH1AHvfqYd8kIVzQJai0KCDxgvqNncXHQhIWkaf1fg6+9hKaYJyaw=="], "@opencode-ai/storybook/@storybook/addon-docs": ["@storybook/addon-docs@10.3.5", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.3.5", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.3.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.3.5" } }, "sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA=="], @@ -4771,10 +4781,6 @@ "@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], - "@storybook/addon-links/storybook": ["storybook@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw=="], "@storybook/addon-onboarding/storybook": ["storybook@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw=="], @@ -4899,8 +4905,6 @@ "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], - "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4941,6 +4945,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=="], @@ -5065,6 +5071,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=="], @@ -5179,6 +5189,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=="], @@ -5253,7 +5265,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=="], @@ -5297,12 +5309,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/plugin/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@opencode-ai/plugin/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - - "@opencode-ai/plugin/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], - "@opencode-ai/storybook/@storybook/addon-docs/@storybook/csf-plugin": ["@storybook/csf-plugin@10.3.5", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.3.5", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw=="], "@opencode-ai/storybook/@storybook/addon-docs/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.3.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.3.5" } }, "sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g=="], @@ -5327,18 +5333,6 @@ "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-json/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - - "@standard-community/standard-json/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - - "@standard-community/standard-openapi/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], - "@storybook/addon-links/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@storybook/addon-onboarding/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -5591,6 +5585,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=="], @@ -5791,6 +5805,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/nix/hashes.json b/nix/hashes.json index 46fa567f4c..3aa8ec21fe 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-bTa0SN4bOmJTi8jrbksF9x7rHN7/4ih0R73liabbAgU=", - "aarch64-linux": "sha256-CLRpU85zXLKPdk0uHY77EDRZkH/L0DJiAkpg/agpvtE=", - "aarch64-darwin": "sha256-Vqt4NWbeDC44aWSchepIMN2p56Vq+Hlmj8xYMZNdc8w=", - "x86_64-darwin": "sha256-R5iv/ysbtm3nH8YzB84clPEFTjJydD/8Tt3Brxzn4RM=" + "x86_64-linux": "sha256-n8qDAnmhHLO3PICGFAL6UzPi6k+Dwtl9Wg87PKqa/vY=", + "aarch64-linux": "sha256-XBCHg6BYL1J3M5JCUZda9e8ZKi8EEpI2hez6w3ICOYs=", + "aarch64-darwin": "sha256-3K35v/UghrM8CSpWvbvJVLeoF94MALWVA7F5B++Po00=", + "x86_64-darwin": "sha256-aTjNOPvGygJ34A1Q5+qziyyuSFa0w6WMUjUcsXSLtBU=" } } diff --git a/package.json b/package.json index d1008676a0..ca08a84076 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", @@ -47,7 +49,7 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.57", @@ -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" }, @@ -137,7 +141,7 @@ "smol-toml": ">=1.6.1", "fastify": ">=5.8.3", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "happy-dom": ">=20.8.9" }, "patchedDependencies": { @@ -145,6 +149,6 @@ "@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" }, - "version": "7.2.42", + "version": "7.2.48", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 4f5b23705d..a008e10854 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.48", "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 ff01b402d0..aca0259756 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 a514d48d93..1c7003408e 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 92e4042768..8dac8faf01 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 3dc3ee2c8b..c442a62cf5 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 0000000000..4e13e88424 --- /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 3e94a08692..3d0767aaff 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 9e8bc24ace..76cee4f8e0 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 160c631887..4bb417f5b4 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 b7679756f2..47d725042a 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.48" 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.48/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.48/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.48/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.48/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.48/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/__tests__/sitemap.test.ts b/packages/kilo-docs/__tests__/sitemap.test.ts new file mode 100644 index 0000000000..ba2a0161a1 --- /dev/null +++ b/packages/kilo-docs/__tests__/sitemap.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest" +import { buildSitemapXml } from "../pages/api/sitemap.xml" + +describe("sitemap.xml", () => { + it("produces valid XML with urlset root", () => { + const xml = buildSitemapXml() + expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/) + expect(xml).toContain('') + expect(xml).toContain("") + }) + + it("all values start with https://kilo.ai/docs", () => { + const xml = buildSitemapXml() + const locs = [...xml.matchAll(/(.*?)<\/loc>/g)].map((m) => m[1]) + expect(locs.length).toBeGreaterThan(0) + for (const loc of locs) { + expect(loc).toMatch(/^https:\/\/kilo\.ai\/docs/) + } + }) + + it("includes the docs root URL", () => { + const xml = buildSitemapXml() + expect(xml).toContain("https://kilo.ai/docs") + }) + + it("includes known pages from the nav", () => { + const xml = buildSitemapXml() + expect(xml).toContain("https://kilo.ai/docs/getting-started/installing") + expect(xml).toContain("https://kilo.ai/docs/getting-started") + }) + + it("has no duplicate entries", () => { + const xml = buildSitemapXml() + const locs = [...xml.matchAll(/(.*?)<\/loc>/g)].map((m) => m[1]) + const unique = new Set(locs) + expect(locs.length).toBe(unique.size) + }) +}) diff --git a/packages/kilo-docs/components/BrowserFrame/index.tsx b/packages/kilo-docs/components/BrowserFrame/index.tsx new file mode 100644 index 0000000000..bc96def1cf --- /dev/null +++ b/packages/kilo-docs/components/BrowserFrame/index.tsx @@ -0,0 +1,107 @@ +"use client" + +import React from "react" + +/** + * BrowserFrame wraps content (typically an image) in an abstract browser chrome. + * Provides a minimal title bar with traffic light dots and an optional URL bar. + */ +export function BrowserFrame({ + children, + url, + caption, +}: { + children: React.ReactNode + url?: string + caption?: string +}) { + return ( +
+
+ {/* Title bar */} +
+ {/* Traffic lights */} +
+
+
+
+
+ {/* URL bar */} + {url && ( +
+ {url} +
+ )} +
+ {/* Content */} +
{children}
+
+ {caption && ( +
+ {caption} +
+ )} +
+ ) +} diff --git a/packages/kilo-docs/components/FlowDiagram/css.d.ts b/packages/kilo-docs/components/FlowDiagram/css.d.ts new file mode 100644 index 0000000000..eb03f42ff9 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/css.d.ts @@ -0,0 +1,9 @@ +declare module "*.css" { + const content: Record + export default content +} + +declare module "@xyflow/react/dist/style.css" { + const content: string + export default content +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/adversarial-loop.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/adversarial-loop.ts new file mode 100644 index 0000000000..da0a57fbbb --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/adversarial-loop.ts @@ -0,0 +1,149 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const baseStyle = { + padding: "14px 22px", + borderRadius: "8px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "160px", +} + +const nodes: Node[] = [ + { + id: "polecat-write", + position: { x: 0, y: 80 }, + data: { label: "Polecat writes code" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(59,130,246,0.1)", + border: "1px solid rgba(59,130,246,0.5)", + color: "#3b82f6", + }, + }, + { + id: "push", + position: { x: 240, y: 80 }, + data: { label: "Push branch" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(255,255,255,0.03)", + border: "1px solid rgba(255,255,255,0.15)", + color: "#ccc", + minWidth: "120px", + }, + }, + { + id: "refinery-review", + position: { x: 440, y: 80 }, + data: { label: "Refinery reviews" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(251,191,36,0.1)", + border: "1px solid rgba(251,191,36,0.5)", + color: "#fbbf24", + }, + }, + { + id: "approve", + position: { x: 680, y: 20 }, + data: { label: "✓ Approve & Merge" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(34,197,94,0.1)", + border: "1px solid rgba(34,197,94,0.5)", + color: "#22c55e", + }, + }, + { + id: "feedback", + position: { x: 680, y: 140 }, + data: { label: "✗ Send feedback" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { + ...baseStyle, + background: "rgba(249,115,22,0.1)", + border: "1px solid rgba(249,115,22,0.5)", + color: "#f97316", + }, + }, + { + id: "revise", + position: { x: 240, y: 220 }, + data: { label: "Polecat revises" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...baseStyle, + background: "rgba(59,130,246,0.1)", + border: "1px solid rgba(59,130,246,0.5)", + color: "#3b82f6", + }, + }, +] + +const edges: Edge[] = [ + { + id: "write-push", + source: "polecat-write", + target: "push", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "push-review", + source: "push", + target: "refinery-review", + animated: true, + style: { strokeWidth: 2, stroke: "#fbbf24" }, + }, + { + id: "review-approve", + source: "refinery-review", + target: "approve", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "review-feedback", + source: "refinery-review", + target: "feedback", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#f97316", strokeDasharray: "5 3" }, + }, + { + id: "feedback-revise", + source: "feedback", + target: "revise", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#f97316", strokeDasharray: "5 3" }, + }, + { + id: "revise-review", + source: "revise", + target: "refinery-review", + type: "smoothstep", + animated: true, + label: "re-submit", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" }, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, +] + +export const adversarialLoop: DiagramDefinition = { + nodes, + edges, + caption: "The micro-adversarial loop — write, review, revise until quality is met", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/bead-lifecycle.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/bead-lifecycle.ts new file mode 100644 index 0000000000..0f0366822a --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/bead-lifecycle.ts @@ -0,0 +1,139 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const nodeBase = { + style: { + padding: "12px 20px", + borderRadius: "8px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + border: "1px solid", + textAlign: "center" as const, + minWidth: "120px", + }, +} as const satisfies Pick + +const nodes = [ + { + id: "open", + position: { x: 0, y: 120 }, + data: { label: "open" }, + // not sure why, but using Position.Top results in everything breaking at runtime + // we just settle for casing as Position instead + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(34,197,94,0.1)", + borderColor: "rgba(34,197,94,0.5)", + color: "#22c55e", + }, + }, + { + id: "in_progress", + position: { x: 200, y: 120 }, + data: { label: "in_progress" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(59,130,246,0.1)", + borderColor: "rgba(59,130,246,0.5)", + color: "#3b82f6", + }, + }, + { + id: "in_review", + position: { x: 420, y: 120 }, + data: { label: "in_review" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(251,191,36,0.1)", + borderColor: "rgba(251,191,36,0.5)", + color: "#fbbf24", + }, + }, + { + id: "closed", + position: { x: 640, y: 120 }, + data: { label: "closed" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(139,92,246,0.1)", + borderColor: "rgba(139,92,246,0.5)", + color: "#8b5cf6", + }, + }, + { + id: "failed", + position: { x: 310, y: 280 }, + data: { label: "failed" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: { + ...nodeBase.style, + background: "rgba(239,68,68,0.1)", + borderColor: "rgba(239,68,68,0.5)", + color: "#ef4444", + }, + }, +] as const satisfies Node[] + +const edgeBase = { + style: { strokeWidth: 2 }, + animated: true, +} + +const edges = [ + { + id: "open-progress", + source: "open", + target: "in_progress", + type: "smoothstep", + label: "agent dispatched", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" }, + ...edgeBase, + style: { ...edgeBase.style, stroke: "#3b82f6" }, + }, + { + id: "progress-review", + source: "in_progress", + target: "in_review", + type: "smoothstep", + label: "work complete", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888", zIndex: 10 }, + ...edgeBase, + style: { ...edgeBase.style, stroke: "#fbbf24" }, + }, + { + id: "review-closed", + source: "in_review", + target: "closed", + type: "smoothstep", + label: "approved & merged", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" }, + ...edgeBase, + style: { ...edgeBase.style, stroke: "#8b5cf6" }, + }, + { + id: "progress-failed", + source: "in_progress", + target: "failed", + type: "smoothstep", + label: "max retries", + labelStyle: { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888", marginTop: "100px" }, + ...edgeBase, + style: { ...edgeBase.style, strokeWidth: 2, stroke: "#ef4444", strokeDasharray: "5 3" }, + }, +] as const satisfies Edge[] + +export const beadLifecycle = { + nodes, + edges, + caption: "The bead lifecycle — from open to closed, with adversarial review in between", +} as const satisfies DiagramDefinition diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/convoy-execution.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/convoy-execution.ts new file mode 100644 index 0000000000..b96ca725c0 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/convoy-execution.ts @@ -0,0 +1,174 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const stageStyle = (color: string) => ({ + padding: "12px 18px", + borderRadius: "8px", + fontSize: "12px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + background: `rgba(${color},0.1)`, + border: `1px solid rgba(${color},0.5)`, + textAlign: "center" as const, + minWidth: "140px", +}) + +const closedColor = "139,92,246" +const activeColor = "59,130,246" +const pendingColor = "255,255,255" +const reviewColor = "251,191,36" +const mergeColor = "34,197,94" + +const nodes: Node[] = [ + { + id: "bead1", + position: { x: 0, y: 0 }, + data: { label: "1. Explore codebase" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(closedColor), color: "#8b5cf6" }, + }, + { + id: "review1", + position: { x: 0, y: 100 }, + data: { label: "✓ reviewed" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { ...stageStyle(mergeColor), color: "#22c55e", minWidth: "140px", fontSize: "11px" }, + }, + { + id: "bead2", + position: { x: 200, y: 0 }, + data: { label: "2. Design schema" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(closedColor), color: "#8b5cf6" }, + }, + { + id: "review2", + position: { x: 200, y: 100 }, + data: { label: "✓ reviewed" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { ...stageStyle(mergeColor), color: "#22c55e", minWidth: "140px", fontSize: "11px" }, + }, + { + id: "bead3", + position: { x: 400, y: 0 }, + data: { label: "3. Implement API" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(activeColor), color: "#3b82f6" }, + }, + { + id: "review3", + position: { x: 400, y: 100 }, + data: { label: "⟳ in review" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: { ...stageStyle(reviewColor), color: "#fbbf24", minWidth: "140px", fontSize: "11px" }, + }, + { + id: "bead4", + position: { x: 600, y: 0 }, + data: { label: "4. Write tests" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: { ...stageStyle(pendingColor), color: "#666", opacity: 0.6 }, + }, + { + id: "landing", + position: { x: 800, y: 35 }, + data: { label: "Landing Review" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...stageStyle(reviewColor), + color: "#fbbf24", + minWidth: "140px", + opacity: 0.4, + border: "1px dashed rgba(251,191,36,0.4)", + }, + }, + { + id: "main", + position: { x: 990, y: 35 }, + data: { label: "→ main" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: { + ...stageStyle(mergeColor), + color: "#22c55e", + minWidth: "100px", + opacity: 0.4, + border: "1px dashed rgba(34,197,94,0.4)", + }, + }, +] + +const edges: Edge[] = [ + { + id: "b1-r1", + source: "bead1", + target: "review1", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "r1-b2", + source: "review1", + target: "bead2", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + label: "builds on", + labelStyle: { fontSize: 9, fontFamily: "'JetBrains Mono', monospace", fill: "#666" }, + }, + { + id: "b2-r2", + source: "bead2", + target: "review2", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "r2-b3", + source: "review2", + target: "bead3", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + label: "builds on", + labelStyle: { fontSize: 9, fontFamily: "'JetBrains Mono', monospace", fill: "#666" }, + }, + { + id: "b3-r3", + source: "bead3", + target: "review3", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "r3-b4", + source: "review3", + target: "bead4", + style: { strokeWidth: 1, stroke: "#444", strokeDasharray: "4 4" }, + }, + { + id: "b4-landing", + source: "bead4", + target: "landing", + style: { strokeWidth: 1, stroke: "#444", strokeDasharray: "4 4" }, + }, + { + id: "landing-main", + source: "landing", + target: "main", + style: { strokeWidth: 1, stroke: "#444", strokeDasharray: "4 4" }, + }, +] + +export const convoyExecution: DiagramDefinition = { + nodes, + edges, + caption: "A convoy executing — each bead builds on reviewed work from previous stages", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts new file mode 100644 index 0000000000..547f5b8b83 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts @@ -0,0 +1,16 @@ +import type { Node, Edge } from "@xyflow/react" +import { beadLifecycle } from "./bead-lifecycle" +import { adversarialLoop } from "./adversarial-loop" +import { convoyExecution } from "./convoy-execution" + +export interface DiagramDefinition { + nodes: Node[] + edges: Edge[] + caption?: string +} + +export const diagrams: Record = { + "bead-lifecycle": beadLifecycle, + "adversarial-loop": adversarialLoop, + "convoy-execution": convoyExecution, +} diff --git a/packages/kilo-docs/components/FlowDiagram/index.tsx b/packages/kilo-docs/components/FlowDiagram/index.tsx new file mode 100644 index 0000000000..5f18ec04e9 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/index.tsx @@ -0,0 +1,116 @@ +"use client" + +import React, { useState, useEffect, useMemo } from "react" +import { diagrams } from "./diagrams" + +/** + * FlowDiagram renders an interactive React Flow diagram. + * Loaded lazily to avoid bundling the entire @xyflow/react library on pages that don't use it. + * + * Usage in markdown: + * {% flowDiagram name="bead-lifecycle" /%} + * {% flowDiagram name="adversarial-loop" height="500px" /%} + */ +export function FlowDiagram({ name, height = "400px" }: { name: string; height?: string }) { + const [mod, setMod] = useState(null) + const [cssLoaded, setCssLoaded] = useState(false) + + useEffect(() => { + Promise.all([ + import("@xyflow/react"), + import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true)), + ]).then(([xyflow]) => { + setMod(xyflow) + }) + }, []) + + const diagram = diagrams[name] + + if (!diagram) { + return ( +
+ Diagram "{name}" not found +
+ ) + } + + if (!mod || !cssLoaded) { + return ( +
+ Loading diagram... +
+ ) + } + + const { ReactFlow, Background, BackgroundVariant } = mod + + return ( +
+
+ + + +
+ {diagram.caption && ( +
+ {diagram.caption} +
+ )} +
+ ) +} diff --git a/packages/kilo-docs/components/index.js b/packages/kilo-docs/components/index.js index 22e0aff051..96e12f01af 100644 --- a/packages/kilo-docs/components/index.js +++ b/packages/kilo-docs/components/index.js @@ -16,3 +16,5 @@ export * from "./ThemeToggle" export * from "./TopNav" export * from "./VideoEmbed" export * from "./YouTube" +export * from "./FlowDiagram" +export * from "./BrowserFrame" diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index 3668c18ef7..59f6d8a4bd 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -23,6 +23,19 @@ export const CodeWithAiNav: NavSection[] = [ { href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" }, { href: "/code-with-ai/platforms/slack", children: "Slack" }, { href: "/code-with-ai/app-builder", children: "App Builder" }, + { + href: "/code-with-ai/gastown", + children: "Gas Town by Kilo", + subLinks: [ + { href: "/code-with-ai/gastown/quick-start", children: "Quick Start" }, + { href: "/code-with-ai/gastown/concepts", children: "Concepts" }, + { href: "/code-with-ai/gastown/mayor", children: "The Mayor" }, + { href: "/code-with-ai/gastown/sling-work", children: "Sling Work" }, + { href: "/code-with-ai/gastown/code-review", children: "Code Review" }, + { href: "/code-with-ai/gastown/settings", children: "Settings" }, + { href: "/code-with-ai/gastown/troubleshooting", children: "Troubleshooting" }, + ], + }, ], }, { @@ -62,6 +75,7 @@ export const CodeWithAiNav: NavSection[] = [ }, ], }, + { title: "Productivity Tools", links: [ @@ -80,6 +94,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/markdoc/tags/browser-frame.markdoc.ts b/packages/kilo-docs/markdoc/tags/browser-frame.markdoc.ts new file mode 100644 index 0000000000..eec24ead45 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/browser-frame.markdoc.ts @@ -0,0 +1,16 @@ +import { BrowserFrame } from "../../components" + +export const browserFrame = { + render: BrowserFrame, + children: ["paragraph", "tag", "list"], + attributes: { + url: { + type: String, + description: "Optional URL to display in the address bar", + }, + caption: { + type: String, + description: "Optional caption below the frame", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/flow-diagram.markdoc.ts b/packages/kilo-docs/markdoc/tags/flow-diagram.markdoc.ts new file mode 100644 index 0000000000..f395b80fed --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/flow-diagram.markdoc.ts @@ -0,0 +1,18 @@ +import { FlowDiagram } from "../../components" + +export const flowDiagram = { + render: FlowDiagram, + selfClosing: true, + attributes: { + name: { + type: String, + required: true, + description: "Name of the diagram to render (must match a key in diagrams/index.ts)", + }, + height: { + type: String, + default: "400px", + description: "Height of the diagram container", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/index.ts b/packages/kilo-docs/markdoc/tags/index.ts index 27b8d66a20..e158a64b89 100644 --- a/packages/kilo-docs/markdoc/tags/index.ts +++ b/packages/kilo-docs/markdoc/tags/index.ts @@ -7,3 +7,5 @@ export * from "./image.markdoc" export * from "./kilo-code-icon.markdoc" export * from "./video.markdoc" export * from "./youtube.markdoc" +export * from "./flow-diagram.markdoc" +export * from "./browser-frame.markdoc" diff --git a/packages/kilo-docs/next.config.js b/packages/kilo-docs/next.config.js index 3a8835980f..bc017eda4b 100644 --- a/packages/kilo-docs/next.config.js +++ b/packages/kilo-docs/next.config.js @@ -30,6 +30,11 @@ module.exports = withMarkdoc(/* config: https://markdoc.io/docs/nextjs#options * source: "/llms.txt", destination: "/api/llms.txt", }, + { + // Rewrite /docs/sitemap.xml to the API endpoint (internal to basePath) + source: "/sitemap.xml", + destination: "/api/sitemap.xml", + }, ], afterFiles: [ { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 10d60618a3..bc10527fdc 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.48", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", @@ -13,8 +13,9 @@ "@docsearch/js": "^4", "@markdoc/markdoc": "^0.5.4", "@markdoc/next.js": "^0.5.0", - "js-yaml": "^4.1.0", "@vscode/codicons": "^0.0.44", + "@xyflow/react": "12.10.2", + "js-yaml": "^4.1.0", "mermaid": "11.12.3", "next": "^16.1.5", "posthog-js": "^1.335.3", diff --git a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md index 086eb76de3..78eea60097 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md +++ b/packages/kilo-docs/pages/ai-providers/openai-chatgpt-plus-pro.md @@ -1,11 +1,26 @@ --- title: "Using ChatGPT Plus/Pro with Kilo Code" -description: "Use your ChatGPT Plus or Pro subscription with Kilo Code. Setup guide for authenticating without a separate API key." +description: "Use your ChatGPT Plus or Pro subscription with Kilo Code. Flat-rate access to OpenAI Codex models with OAuth login — no separate API key required." sidebar_label: ChatGPT Plus/Pro --- # Using ChatGPT Subscriptions With Kilo Code +If you already pay for ChatGPT Plus or Pro, you can use that subscription to run OpenAI's top coding models directly inside Kilo Code — with no extra API charges beyond your subscription. + +## Why use your ChatGPT subscription? + +- **Flat-rate access to OpenAI models:** Your subscription covers usage without pay-as-you-go API costs. +- **OAuth login — no API keys:** Click "Sign in to OpenAI Codex," authenticate in your browser, and you're done. +- **Full agentic workflows:** Generate, refactor, debug, edit files, and run terminal commands inside Kilo Code. +- **Multiple AI modes:** Switch between Code, Plan, Debug, and Ask modes for different tasks. + +{% callout type="note" %} +Your ChatGPT subscription works with Kilo Code's core functionality (VS Code extension and CLI), but does **not** include cloud features such as Cloud Agents, Kilo Deploy, or KiloClaw. To use GPT models in those features, use the [Kilo Gateway](/docs/gateway). +{% /callout %} + +## Setup + {% tabs %} {% tab label="VSCode (Legacy)" %} @@ -47,12 +62,31 @@ Then set your default model to one of the OpenAI Codex models available in Kilo ## Tips and Notes -- **Subscription Required:** You need an active ChatGPT Plus or Pro subscription. This provider won't work with free ChatGPT accounts. See [OpenAI's ChatGPT plans](https://chatgpt.com/pricing/) for more information. -- **Authentication Errors:** If you receive a CSRF or other error when completing OAuth authentication, ensure you do not have another application already listening on port 1455. You can check on Linux and Mac by using `lsof -i :1455`. +- **Subscription Required:** You need an active ChatGPT Plus or Pro subscription. This provider won't work with free ChatGPT accounts. [Codex is included](https://developers.openai.com/codex/pricing/) in ChatGPT Plus, Pro, Business, Edu, and Enterprise plans. See [OpenAI's ChatGPT plans](https://chatgpt.com/pricing/) for more information. - **No API Costs:** Usage through this provider counts against your ChatGPT subscription, not separately billed API usage. +- **Authentication Errors:** If you receive a CSRF or other error when completing OAuth authentication, ensure you do not have another application already listening on port 1455. You can check on Linux and Mac by using `lsof -i :1455`. - **Sign Out:** To disconnect in VS Code, use the "Disconnect" button in the provider settings. In the CLI, run `kilo auth logout` and choose OpenAI. +- **Switching providers:** You can switch to Claude, Gemini, or local models at any time — this provider is optional. ## Limitations -- **You can't use arbitrary OpenAI API models.** This provider only exposes the models listed in Kilo Code's Codex model catalog. -- **You can't export/migrate your sign-in state with settings export.** OAuth tokens are stored in VS Code SecretStorage, which isn't included in Kilo Code's settings export. +- **Codex catalog models only.** This provider only exposes the models listed in Kilo Code's Codex model catalog. It does not give access to every model available through the OpenAI API. +- **OAuth tokens can't be exported with settings.** Tokens are stored in VS Code SecretStorage, which isn't included in Kilo Code's settings export. +- **Cloud features not included.** Cloud Agents, Kilo Deploy, and KiloClaw require the [Kilo Gateway](/docs/gateway). + +## FAQ + +**Do I need a separate API key?** +No — just sign in with OAuth using your ChatGPT subscription. + +**Which ChatGPT plans include Codex access?** +ChatGPT Plus, Pro, Business, Edu, and Enterprise. Free accounts are not supported. + +**How is usage billed?** +Usage counts against your ChatGPT subscription limits — there are no separate API charges. + +**Can I still switch to other AI providers?** +Yes. Use OpenAI when it fits and switch to Claude, Gemini, or any local model at any time. + +**Can I use my ChatGPT subscription in KiloClaw?** +No. For KiloClaw and other cloud features, use GPT models through the [Kilo Gateway](/docs/gateway), which gives access to 500+ models including the latest GPT releases. diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 89551016dc..02af740576 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 f100680ccf..867d6399be 100644 --- a/packages/kilo-docs/pages/ai-providers/openai.md +++ b/packages/kilo-docs/pages/ai-providers/openai.md @@ -10,6 +10,10 @@ Kilo Code supports accessing models directly through the official OpenAI API. **Website:** [https://openai.com/](https://openai.com/) +{% callout type="tip" %} +**Already have a ChatGPT Plus or Pro subscription?** You can use it to access OpenAI's Codex models inside Kilo Code — no separate API key or pay-as-you-go charges needed. See the [ChatGPT Plus/Pro provider page](/docs/ai-providers/openai-chatgpt-plus-pro) for setup instructions. +{% /callout %} + ## Getting an API Key 1. **Sign Up/Sign In:** Go to the [OpenAI Platform](https://platform.openai.com/). Create an account or sign in. @@ -71,4 +75,4 @@ Then set your default model: ## Tips and Notes - **Pricing:** Refer to the [OpenAI Pricing](https://openai.com/pricing) page for details on model costs. -- **Azure OpenAI Service:** If you'd like to use the Azure OpenAI service, please see our section on [OpenAI-compatible](/docs/ai-providers/openai-compatible) providers. +- **Azure OpenAI Service:** Use Kilo Code's native `azure` provider for Azure OpenAI, especially GPT-5 deployments. Do not configure Azure GPT-5 through a generic [OpenAI-compatible](/docs/ai-providers/openai-compatible) custom provider. diff --git a/packages/kilo-docs/pages/api/sitemap.xml.ts b/packages/kilo-docs/pages/api/sitemap.xml.ts new file mode 100644 index 0000000000..76b67a40f2 --- /dev/null +++ b/packages/kilo-docs/pages/api/sitemap.xml.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { Nav } from "../../lib/nav" +import type { NavLink, NavSection } from "../../lib/types" + +const origin = "https://kilo.ai" +const base = "/docs" + +function links(sections: NavSection[]): string[] { + const out: string[] = [] + function walk(items: NavLink[]) { + for (const item of items) { + out.push(item.href) + if (item.subLinks) walk(item.subLinks) + } + } + for (const s of sections) walk(s.links) + return out +} + +export function buildSitemapXml(): string { + const all = Object.values(Nav).flatMap(links) + const hrefs = Array.from(new Set(["", ...all])).sort() + const urls = hrefs.map((h) => ` ${origin}${base}${h}`).join("\n") + return ` + +${urls} +` +} + +export default function handler(_: NextApiRequest, res: NextApiResponse) { + const xml = buildSitemapXml() + res.setHeader("Content-Type", "application/xml; charset=utf-8") + res.setHeader("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=86400") + res.status(200).send(xml) +} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 5d84f04f8d..a8b035662c 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 0000000000..43200d4c9a --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/features/message-feedback.md @@ -0,0 +1,74 @@ +--- +title: "Message Feedback" +description: "Rate assistant responses with thumbs up/down to help Kilo improve" +--- + +# Message Feedback + +You can give a thumbs up or thumbs down to any response Kilo gives you. It's a quick way to tell us when something worked well, or when it didn't. + +{% callout type="info" %} +Feedback is only available when telemetry is on. If you turn telemetry off, the buttons no longer display. See [Turning it off](#turning-it-off) below. +{% /callout %} + +## How to use it + +{% tabs %} +{% tab label="VSCode" %} + +When Kilo finishes a response, you'll see thumbs-up and thumbs-down buttons next to the **Copy response** button. Click one to send your rating. Click the same button again to take it back, or click the other one to change your mind. + +If you reload the window or switch to a different session, the feedback status will be reset. + +{% /tab %} + +{% tab label="CLI" %} + +In the terminal, two keybinds rate the most recent assistant message: + +| Action | Keybind | +|---|---| +| Helpful | `=` | +| Not helpful | `-` | + +`` defaults to `Ctrl+X`, so you press `Ctrl+X` and then `=` for thumbs-up, or `Ctrl+X` and then `-` for thumbs-down. + +{% /tab %} +{% /tabs %} +## Why it matters + +Your feedback is the most direct way to tell us what's working and what isn't. We use it to tune the prompts behind Kilo's behavior, pick better default models, and find and fix problems faster than we could on our own. + +## What we send + +When you rate a response, here's what gets sent: + +- That you rated a response thumbs up or thumbs down. +- The provider and model ID that produced the response (for example, `anthropic` and `claude-sonnet-4-5`). +- For responses that came through Kilo Gateway, the request ID so we can match the rating back to the session. + +No further information is included in the rating feedback. For more information on telemetry, see [PRIVACY.md](https://github.com/Kilo-Org/kilocode/blob/main/PRIVACY.md). + +## Turning it off + +Feedback is part of telemetry, so turning telemetry off will cause the feedback buttons to no longer appear. + +{% tabs %} +{% tab label="VSCode" %} + +Open Settings (`Cmd+,` on Mac, `Ctrl+,` on Windows/Linux), search for **`telemetry.telemetryLevel`**, and set it to **Off**. The feedback buttons will no longer appear. Set it back to **All** to bring them back. (For more on what this setting controls, see [VS Code's telemetry docs](https://code.visualstudio.com/docs/getstarted/telemetry).) + +{% /tab %} + +{% tab label="CLI" %} + +Set `KILO_TELEMETRY_LEVEL=off` in your environment before starting Kilo: + +```bash +KILO_TELEMETRY_LEVEL=off kilo +``` + +With telemetry off, pressing the feedback keybinds shows a message saying feedback is disabled, and nothing is sent. + +{% /tab %} +{% /tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/code-review.md b/packages/kilo-docs/pages/code-with-ai/gastown/code-review.md new file mode 100644 index 0000000000..217cda3159 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/code-review.md @@ -0,0 +1,135 @@ +--- +title: "Code Review" +description: "How the refinery agent reviews and merges code" +--- + +# {% $markdoc.frontmatter.title %} + +Every piece of code produced by Gas Town agents goes through automated review before merging. The **refinery** agent is dedicated to critiquing, verifying, and gatekeeping what lands in your codebase. + +## The Review Pipeline + +When a polecat finishes a bead and pushes its branch, the work enters the review pipeline: + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="A bead in review — the refinery is evaluating the polecat's work" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-bead-in-review.png" alt="Gas Town rig page showing a bead in review status" /%} +{% /browserFrame %} + +The refinery evaluates: +- **Correctness** — does the code do what the task asked? +- **Style** — does it follow project conventions? +- **Completeness** — are tests included? Are edge cases handled? +- **Safety** — any security issues, data leaks, or breaking changes? + +## Micro-Adversarial Loops + +The core insight behind Gas Town's review system is **adversarial iteration**. Rather than one agent producing a final answer, two agents with different objectives improve the output through tension: + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +This pattern is fundamentally different from having a single agent self-review: +- Self-review has a **confirmation bias** — the same "mind" that wrote the code evaluates it +- Adversarial review creates **genuine tension** — the refinery has different priorities than the polecat +- Each revision cycle **measurably improves** the output because feedback is specific and actionable + +### The Loop in Practice + +A typical bead goes through 1-2 revision cycles: + +| Cycle | What happens | +|---|---| +| **Write** | Polecat reads the task, writes code, runs tests, pushes | +| **Review 1** | Refinery finds 2 issues: missing test case, inconsistent naming | +| **Revise 1** | Polecat adds the test, fixes naming, pushes again | +| **Review 2** | Refinery approves — code meets quality bar | +| **Merge** | Code lands on target branch | + +After 3 failed revision cycles, the bead escalates rather than looping forever. + +## Merge Strategies + +Gas Town supports two merge strategies, configurable per-town or per-rig: + +### Direct Merge + +The refinery merges directly to the target branch (convoy feature branch or main) without creating a GitHub PR. This is faster but gives you less visibility into individual merges. + +**Best for:** trusted agent output, internal projects, rapid iteration. + +### Pull Request Mode + +The refinery creates a GitHub PR for each merge. The PR includes: +- The diff +- Review comments from the refinery +- Status checks from CI + +You can configure whether PRs auto-merge after refinery approval or require human approval. + +**Best for:** production codebases, team environments, audit trails. + +## Convoy-Level Review + +Convoys add an additional review layer beyond per-bead review: + +{% flowDiagram name="convoy-execution" height="200px" /%} + +{% browserFrame url="app.kilo.ai/gastown/town/merges" caption="The merge queue — review detail showing refinery feedback" %} +{% image src="/docs/img/gastown/gt-merge-queue-page-review-detail.png" alt="Gas Town merge queue with review detail" /%} +{% /browserFrame %} + +| Review Layer | What's checked | Who reviews | +|---|---|---| +| Per-bead | Individual contribution quality | Refinery agent | +| Landing | Combined feature coherence | Refinery agent | +| Human (optional) | Business logic, architecture | Your team | + +## Combining with Kilo Code Review + +Gas Town's refinery works independently, but combining it with [Kilo Code Review](/docs/code-with-ai/platforms/cloud-agent) creates an even stronger pipeline: + +1. **Agent writes** → agent-level refinery reviews (fast, automated) +2. **Code lands as PR** → Kilo Code Review provides human-readable review (deeper, contextual) +3. **Human approves** → code ships + +This gives you automated adversarial review for speed **plus** AI-assisted human review for judgment — the best of both approaches. + +## The Merge Queue + +The merge queue page shows all active and completed reviews in your town: + +{% browserFrame url="app.kilo.ai/gastown/town/merges" caption="The merge queue — all reviews at a glance" %} +{% image src="/docs/img/gastown/gt-merge-queue-page.png" alt="Gas Town merge queue page" /%} +{% /browserFrame %} + +## Review Configuration + +Customize the refinery's behavior in **Town Settings** → **Review**: + +| Setting | Options | Default | +|---|---|---| +| `review_mode` | `always` / `never` / `pr_only` | `always` | +| `merge_strategy` | `direct` / `pr` | `direct` | +| `auto_merge` | `true` / `false` | `true` | +| `review_gates` | Strictness level (1-5) | 3 | +| `max_review_cycles` | How many revision attempts | 3 | + +### Review Mode + +- **`always`** — every bead goes through refinery review (recommended) +- **`never`** — skip review, merge directly on polecat completion (fast but risky) +- **`pr_only`** — only review work that creates a PR + +### Review Gates + +Higher gate levels make the refinery stricter: +- **Level 1** — basic sanity (compiles, doesn't break tests) +- **Level 3** — standard (style, tests, correctness) — default +- **Level 5** — strict (architecture review, performance, security) + +## Handling Review Feedback + +When the refinery rejects a submission, it provides specific, actionable feedback. The polecat receives this feedback and revises accordingly. You can see the feedback exchange in the bead's event history. + +If a bead fails review 3 times, it transitions to `failed` and creates an **escalation** for human attention. This prevents infinite loops while ensuring difficult code doesn't slip through without proper quality. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/concepts.md b/packages/kilo-docs/pages/code-with-ai/gastown/concepts.md new file mode 100644 index 0000000000..406d6885b0 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/concepts.md @@ -0,0 +1,156 @@ +--- +title: "Concepts" +description: "Understand the building blocks of Gas Town — towns, beads, convoys, rigs, and agents" +--- + +# {% $markdoc.frontmatter.title %} + +Gas Town by Kilo is built around a small set of composable primitives. Understanding these concepts is key to getting the most out of agent orchestration. + +## Towns + +A **town** is a persistent workspace where agents operate on your code. It maintains: + +- **Configuration** — models, merge strategy, review settings, custom instructions +- **Agent state** — which agents exist, what they're working on, their capabilities +- **Work history** — every bead that's been created, worked on, reviewed, and closed +- **Rig connections** — which repositories are connected and how they're configured + +Think of a town as a team's workspace — it accumulates institutional knowledge over time. Agents in your town learn from the history of what's been built, reviewed, and merged. + +A town can be personal (owned by you) or organizational (shared across a team). + +## Beads + +A **bead** is the fundamental unit of work. Every task, review, and coordination action is represented as a bead with a lifecycle: + +{% flowDiagram name="bead-lifecycle" height="320px" /%} + +| Status | What's happening | +|---|---| +| `open` | Waiting to be picked up by an agent | +| `in_progress` | An agent is actively working on it | +| `in_review` | Work is complete, awaiting review by the refinery | +| `closed` | Successfully completed and merged | +| `failed` | Could not be completed (agent exhausted retries) | + +### Bead Types + +| Type | Purpose | +|---|---| +| `issue` | A coding task — bug fix, feature, refactor | +| `merge_request` | A review task for the refinery | +| `convoy` | A container for multi-step workflows | +| `escalation` | An issue the agents couldn't resolve — needs human input | +| `message` | Inter-agent communication | + +## Convoys + +A **convoy** is a multi-bead workflow where tasks can depend on each other. Instead of slinging isolated tasks, convoys let you express complex work as a directed graph. + +{% flowDiagram name="convoy-execution" height="200px" /%} + +When you create a convoy, you define: +- **Tasks** — what needs to be done (each becomes a bead) +- **Dependencies** — which tasks must complete before others can start +- **Feature branch** — a shared branch that convoy work lands on + +The reconciler ensures beads are only dispatched when their dependencies are met. This means agents naturally build on each other's work. + +### Staged Convoys + +Convoys can be **staged** — created but not started immediately. This lets you review the plan before agents begin executing. Un-stage when you're ready to go. + +## Rigs + +A **rig** connects a repository to your town. Each rig has: + +- Its own set of agents (polecats, refinery) +- Branch configuration (default branch, merge target) +- Override settings (model, review mode, merge strategy) + +A town can have **multiple rigs** — useful when your project spans several repositories. + +## Agents + +Agents are the workers in your town. Each has a specialized role: + +### Polecats (Coding Agents) + +Polecats do the actual software engineering: + +- Read and understand your codebase +- Write code changes in isolated git worktrees +- Run tests and commands to verify their work +- Push branches when done + +Multiple polecats can work in **parallel** on different beads. The default is 2 per rig, configurable up to 5+. + +Each polecat gets its own git worktree — they never conflict with each other or with your local development. + +### The Refinery (Review Agent) + +The refinery is the quality gate. When a polecat finishes a bead: + +1. The refinery reviews the diff +2. Checks for issues, style violations, missing tests +3. Either **approves and merges** or **sends feedback** +4. If feedback is sent, the polecat revises and resubmits + +This creates a **micro-adversarial loop** — one agent writes, another critiques, forcing iterative improvement before code lands. + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +### The Mayor (Coordination Agent) + +The mayor is your interface to the town: + +- Plans convoys from high-level descriptions +- Reports on status and progress +- Triages issues and escalations +- Manages agent configuration +- Answers questions about the codebase and work history + +The mayor runs persistently — always available for conversation. + +## The Reconciler + +The reconciler is the engine that drives the town forward. It runs on every alarm tick (every 5 seconds when work is active) and: + +1. **Drains events** — agent completions, status changes, failures +2. **Evaluates rules** — which beads need agents, which convoys are ready to advance +3. **Emits actions** — dispatch an agent, create a review, update convoy progress +4. **Enforces invariants** — no double-dispatch, no orphaned hooks, bounded retries + +You don't interact with the reconciler directly — it's the autonomous engine that keeps the town moving. + +## The Micro-Adversarial Loop + +The most powerful concept in Gas Town is the **micro-adversarial loop**. Rather than trusting a single agent's output, every piece of work goes through an adversarial cycle: + +{% flowDiagram name="adversarial-loop" height="340px" /%} + +This pattern compounds when combined with **convoys**: + +1. Bead 1: Explore the codebase → reviewed → merged to convoy branch +2. Bead 2: Design the schema (builds on bead 1's context) → reviewed → merged +3. Bead 3: Implement the feature (builds on beads 1+2) → reviewed → merged +4. **Landing review**: The full convoy branch is reviewed as a cohesive unit before merging to main + +At every stage, work is critiqued and refined. Combined with Kilo's [Code Review](/docs/code-with-ai/gastown/code-review) product, this creates a pipeline where code is reviewed multiple times by different agents with different perspectives before it ever reaches your main branch. + +## How It All Fits Together + +{% browserFrame url="app.kilo.ai/gastown/town" caption="The complete Gas Town experience — Mayor chat, convoy progress, and agent coordination" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town overview showing the full architecture in action" /%} +{% /browserFrame %} + +| Component | Responsibility | +|---|---| +| **You** | Describe work, review PRs, set direction | +| **Mayor** | Plan, coordinate, communicate | +| **Reconciler** | Schedule, dispatch, enforce rules | +| **Polecats** | Write code, run tests, push branches | +| **Refinery** | Review, critique, merge | +| **Container** | Isolated environment with git, tools, runtime | +| **Rig** | Repository connection and configuration | diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/index.md b/packages/kilo-docs/pages/code-with-ai/gastown/index.md new file mode 100644 index 0000000000..55f665f589 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/index.md @@ -0,0 +1,113 @@ +--- +title: "Gas Town by Kilo" +description: "Autonomous AI agent orchestration for your codebase" +--- + +# {% $markdoc.frontmatter.title %} + +Gastown by Kilo is an autonomous agent orchestration platform that manages teams of AI agents working on your codebase. Built on [Gastown](https://gastown.dev) — the open protocol for agent orchestration — Kilo's implementation coordinates coding agents, a code review agent, and a conversational coordinator to ship features, fix bugs, and maintain your projects with minimal human intervention. + +You describe the work. Agents figure out how to do it, write the code, review each other's output, and land clean PRs — while you stay in control of what ships. + +{% browserFrame url="app.kilo.ai/gastown/town" caption="A Gas Town with active agents, convoy progress, and Mayor chat" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town overview showing active work and Mayor chat" /%} +{% /browserFrame %} + +## What Makes Gastown Different + +Unlike single-agent coding tools that handle one task at a time, Gastown orchestrates **multiple specialized agents** working in parallel: + +| Agent | Role | What it does | +|---|---|---| +| **Polecats** | Coding | Write code, run tests, push branches. Multiple can work simultaneously on different tasks. | +| **Refinery** | Code Review | Reviews polecat output, runs quality checks, merges approved work. | +| **Mayor** | Coordination | Your conversational interface. Plans work, answers questions, triages issues, keeps things moving. | + +These agents operate within a **town** — a persistent workspace connected to your repository. The town maintains state across sessions: work history, agent configuration, and institutional knowledge about your codebase. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="A staged convoy ready to be kicked off" %} +{% image src="/docs/img/gastown/gt-rig-page-staged-convoy.png" alt="Gas Town rig page with a staged convoy" /%} +{% /browserFrame %} + +## How It Works + +### 1. Create a town + +Connect a GitHub repository to a new town. Gastown provisions a dedicated environment with agents ready to work. + +### 2. Sling work + +Describe what needs to be done — a bug to fix, a feature to build, a refactor to execute. You can sling a single task or a **convoy** (a multi-step plan where tasks depend on each other). + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Agents working on a convoy — beads flowing through the pipeline" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-in-progress.png" alt="Gas Town rig page with convoy in progress" /%} +{% /browserFrame %} + +### 3. Agents pick it up + +The reconciler assigns open work to available polecats. Each agent gets its own git worktree, writes code, runs commands, and pushes a branch when done. + +### 4. Code gets reviewed + +Completed work flows to the refinery for automated code review. Depending on your merge strategy, the refinery either merges directly or creates a PR for your approval. + +### 5. You stay in control + +Monitor progress from the town dashboard. Chat with the mayor. Review PRs. Adjust priorities. Intervene when agents need guidance. Everything the agents do is visible — branches, commits, review comments, and decision reasoning. + +{% browserFrame url="app.kilo.ai/gastown/town/beads" caption="The beads page — all work items with status, type, and history" %} +{% image src="/docs/img/gastown/gt-beads-page.png" alt="Gas Town beads page showing beads in various states" /%} +{% /browserFrame %} + +## Core Concepts + +| Concept | What it is | +|---|---| +| **Town** | A persistent workspace connecting your repo to a team of agents. Maintains configuration, history, and state. | +| **Bead** | A unit of work — an issue to fix, a task to complete, a review to perform. Beads have a lifecycle: open, in progress, in review, closed. | +| **Convoy** | A multi-bead workflow. Beads within a convoy can depend on each other, forming a DAG that agents execute in order. | +| **Rig** | A repository connection within a town. Each rig has its own branch configuration, agents, and settings. A town can have multiple rigs. | +| **Mayor** | The coordination agent. Your primary interface for interacting with the town through natural language. | +| **Polecat** | A coding agent. Works on individual beads — reads code, makes changes, runs tests, pushes branches. | +| **Refinery** | The review agent. Checks polecat output for quality, performs merges, and gates what lands in your codebase. | + +For a deeper dive, see [Concepts](/docs/code-with-ai/gastown/concepts). + +## The Mayor — Your Interface to the Town + +The mayor is how you interact with Gastown conversationally. Think of it as a technical lead that knows your codebase, tracks what all agents are doing, and can take action on your behalf. + +You can ask the mayor to: + +- Plan and create convoys from high-level descriptions +- Check on the status of in-progress work +- Investigate failures and stuck agents +- Update town settings and configuration +- Triage incoming issues or bug reports + +The mayor runs persistently in your town — it's always available, even when no coding agents are active. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Staged convoy detail — review the plan before agents start" %} +{% image src="/docs/img/gastown/gt-rig-page-staged-convoy-detail.png" alt="Gas Town staged convoy detail view" /%} +{% /browserFrame %} + +## What You Can Build With Gastown + +- **Feature development** — Describe a feature, get back a PR with tests +- **Bug fixes** — Point agents at an issue, they investigate and fix it +- **Refactoring** — Large-scale codebase changes executed methodically across convoys +- **Maintenance** — Dependency updates, lint fixes, documentation generation +- **Code review** — Automated first-pass review on all agent and human PRs + +## Getting Started + +- [Quick Start](/docs/code-with-ai/gastown/quick-start) — Create your first town and see agents work in under 5 minutes +- [Concepts](/docs/code-with-ai/gastown/concepts) — Detailed explanation of towns, beads, convoys, and the agent lifecycle +- [The Mayor](/docs/code-with-ai/gastown/mayor) — How to interact with the coordination agent effectively + +## Guides + +- [Sling Work](/docs/code-with-ai/gastown/sling-work) — Creating tasks, convoys, and staged workflows +- [Code Review](/docs/code-with-ai/gastown/code-review) — Merge strategies, review gates, and the refinery pipeline +- [Settings](/docs/code-with-ai/gastown/settings) — Model configuration, agent limits, custom instructions, and environment variables +- [Troubleshooting](/docs/code-with-ai/gastown/troubleshooting) — Common issues and how to resolve them diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/mayor.md b/packages/kilo-docs/pages/code-with-ai/gastown/mayor.md new file mode 100644 index 0000000000..9330e6d96c --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/mayor.md @@ -0,0 +1,143 @@ +--- +title: "The Mayor" +description: "How to interact with your town's coordination agent" +--- + +# {% $markdoc.frontmatter.title %} + +The Mayor is your primary interface to a Gas Town. It's a persistent conversational agent that coordinates work, answers questions, and takes action on your behalf. + +## What the Mayor Does + +The Mayor operates as a technical lead that: + +- **Plans work** — converts high-level descriptions into convoys and beads +- **Reports status** — knows what every agent is doing, what's stuck, what's shipped +- **Triages issues** — investigates failures, stuck agents, and escalations +- **Configures the town** — updates settings, manages rigs, adjusts agent behavior +- **Answers questions** — about the codebase, work history, and town state + +Unlike polecats (which spin up to work on specific beads), the Mayor runs **persistently**. It's always available, even when no coding agents are active. + +{% browserFrame url="app.kilo.ai/gastown/town" caption="The Mayor chat — always available for coordination and questions" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town Mayor chat interface" /%} +{% /browserFrame %} + +## Talking to the Mayor + +Open the Mayor panel from your town dashboard. The conversation is persistent — the Mayor remembers context from previous messages within the same session. + +### Planning Work + +Ask the Mayor to create work for the town: + +> *"Create a convoy to add authentication to the API. We need JWT token generation, middleware for protected routes, and integration tests."* + +The Mayor will: +1. Break this into individual beads with dependencies +2. Propose a convoy plan for your review +3. Create the convoy (staged by default, so you can review before agents start) + +### Checking Status + +> *"What's everyone working on?"* +> *"Is anything stuck?"* +> *"How did the last convoy go?"* + +The Mayor has full visibility into agent state, bead progress, and recent history. + +### Investigating Problems + +> *"The auth refactor bead has been in progress for 20 minutes — what's happening?"* +> *"Why did the refinery reject the last review?"* + +The Mayor can inspect agent status messages, review feedback, and container logs to diagnose issues. + +### Updating Configuration + +> *"Switch the model to Auto Frontier for this town"* +> *"Set max polecats to 4"* +> *"Add a custom instruction: always use TypeScript strict mode"* + +The Mayor can modify town settings on your behalf through natural language. + +## Mayor Tools + +The Mayor has access to 21 specialized tools for town management: + +### Work Creation + +| Tool | What it does | +|---|---| +| `gt_sling` | Delegate a single task to a polecat agent in a specific rig | +| `gt_sling_batch` | Create a multi-bead convoy with dependency ordering, merge mode, and staging options | + +### Convoy Management + +| Tool | What it does | +|---|---| +| `gt_convoy_status` | Show detailed status of a convoy — each bead's progress and assignee | +| `gt_convoy_start` | Start a staged convoy — begin agent dispatch | +| `gt_convoy_close` | Force-close a convoy and optionally its tracked beads | +| `gt_convoy_update` | Edit convoy metadata (merge mode, feature branch) | +| `gt_convoy_add_bead` | Add an existing bead to a convoy's tracking | +| `gt_convoy_remove_bead` | Remove a bead from a convoy | +| `gt_list_convoys` | List active convoys with progress counts | + +### Bead Management + +| Tool | What it does | +|---|---| +| `gt_bead_update` | Edit a bead's status, title, body, priority, labels, or dependencies | +| `gt_bead_reassign` | Reassign a bead to a different agent | +| `gt_bead_delete` | Delete one or more beads (supports bulk — up to 5000) | +| `gt_list_beads` | List beads in a rig, filterable by status and type | + +### Agent Management + +| Tool | What it does | +|---|---| +| `gt_agent_reset` | Force-reset an agent to idle, unhooking it from any bead | +| `gt_nudge` | Send a real-time nudge to a polecat (immediate, wait-idle, or queued) | +| `gt_list_agents` | List all agents in a rig with their roles and status | +| `gt_mail_send` | Send a persistent mail message to any agent in any rig | + +### Town & UI + +| Tool | What it does | +|---|---| +| `gt_list_rigs` | List all rigs (repositories) in the town | +| `gt_ui_action` | Trigger UI actions — open drawers, navigate pages, highlight items | +| `gt_escalation_acknowledge` | Acknowledge an escalation as reviewed | +| `gt_report_bug` | File a bug report on the Gastown GitHub repo (checks for duplicates first) | + +These tools are used automatically when you make requests — you don't need to invoke them directly. + +## Tips for Effective Communication + +### Be specific about scope + +Instead of: *"Fix the bugs"* + +Try: *"Fix the TypeScript type errors in src/auth/. There are 3 reported in the CI output."* + +### Use convoys for complex work + +Instead of: *"Add a user dashboard with charts, settings, and notifications"* + +Try: *"Create a staged convoy for the user dashboard. Break it into: 1) dashboard layout and navigation, 2) chart components with mock data, 3) settings page, 4) notification system. Each should build on the previous."* + +### Let the Mayor triage + +When something goes wrong, ask the Mayor before investigating yourself: + +> *"Bead abc123 has been stuck for 30 minutes — can you investigate?"* + +The Mayor can often diagnose and resolve issues (reset agents, close stuck convoys, re-dispatch work) without you needing to dig into the admin panel. + +## Mayor Limitations + +- The Mayor coordinates but doesn't write code itself — that's what polecats are for +- It can only see what's happening inside your town, not external systems +- Complex multi-repo orchestration may need manual coordination between towns +- The Mayor's context window is bounded — very long conversations may lose early context diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/quick-start.md b/packages/kilo-docs/pages/code-with-ai/gastown/quick-start.md new file mode 100644 index 0000000000..fb940a3131 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/quick-start.md @@ -0,0 +1,100 @@ +--- +title: "Quick Start" +description: "Get your first Gas Town running in minutes" +--- + +# {% $markdoc.frontmatter.title %} + +This guide walks you through creating your first town, connecting a repository, and watching agents work on real code. + +## Prerequisites + +- A [Kilo account](https://app.kilo.ai) (free tier works) +- A GitHub repository you want agents to work on +- A GitHub Personal Access Token (recommended) + +## 1. Create a Town + +When you first visit Gas Town with no existing towns, you'll be taken directly into the new town onboarding flow. Give your town a name — this is just for your reference. + +{% browserFrame url="app.kilo.ai/gastown" caption="The new town onboarding flow" %} +{% image src="/docs/img/gastown/gt-new-town-onboarding.png" alt="Gas Town new town onboarding flow" /%} +{% /browserFrame %} + +## 2. Connect a Repository + +Add a **rig** to your town. A rig is a connection to a specific repository. + +1. Click **Add Rig** +2. Select your GitHub repository (or paste the URL) +3. Choose the default branch (usually `main`) +4. Click **Connect** + +Gastown uses the [Kilo GitHub App](https://github.com/apps/kilo-code) to access your repository. You'll be prompted to install it if you haven't already. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/new" caption="Adding a new rig — connect your repository" %} +{% image src="/docs/img/gastown/gt-new-rig.png" alt="Gas Town new rig creation flow" /%} +{% /browserFrame %} + +## 3. Add a GitHub Personal Access Token + +{% callout type="tip" title="Recommended" %} +Adding a GitHub PAT means all commits, branches, and PRs created by your town's agents will appear as **you** — not a bot account. It also enables agents to use `gh` CLI commands (creating issues, commenting on PRs, etc.) on your behalf. +{% /callout %} + +1. Go to **Town Settings** → **Git & Authentication** +2. Generate a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) scoped to the repository your town is connected to +3. Required permissions: **Contents** (read/write), **Pull requests** (read/write), **Metadata** (read) +4. Optional: add **Actions** (read/write) if your repo uses GitHub Actions workflows +5. Paste the token and save + +{% callout type="info" %} +Use a fine-grained token scoped to only the repository your town works on. Since agents act autonomously on your behalf, limiting the token's scope reduces risk. +{% /callout %} + +Without a PAT, agents use the GitHub App installation token — functional but shows up as a bot in your git history. + +## 4. Sling Your First Task + +Now let's give the agents something to do. The easiest way is to ask the Mayor: + +> *"Add a CONTRIBUTING.md file with basic setup instructions"* + +Or use the **Sling Work** action in the town header to describe the task directly. + +## 5. Watch Agents Work + +The reconciler assigns your task to an available polecat agent. Head to the **rig page** to watch it in action: + +1. A **bead** appears in the kanban board's open column +2. It moves to `in_progress` as a polecat picks it up +3. The agent reads your code, makes changes, and pushes a branch +4. The bead moves to `in_review` as the refinery checks the work +5. The refinery merges (or creates a PR depending on your settings) +6. The bead lands in the `closed` column + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="The rig page — convoy tracker and kanban board showing beads in various states" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-in-progress.png" alt="Gas Town rig page with an active convoy and beads in progress" /%} +{% /browserFrame %} + +The whole cycle typically takes 2-10 minutes depending on complexity and the model you're using. + +## 6. Talk to the Mayor + +Click the **Mayor** chat to interact with your town's coordinator. Try: + +- *"What's the status of the town?"* +- *"Create a convoy to add unit tests for the auth module"* +- *"What settings should I change for faster reviews?"* + +The Mayor is always running — it's your primary interface for managing the town conversationally. + +{% browserFrame url="app.kilo.ai/gastown/town" caption="The Mayor — your conversational interface to the town" %} +{% image src="/docs/img/gastown/gt-town-overview.png" alt="Gas Town overview with Mayor chat" /%} +{% /browserFrame %} + +## What's Next? + +- [Concepts](/docs/code-with-ai/gastown/concepts) — Understand the building blocks in depth +- [Sling Work](/docs/code-with-ai/gastown/sling-work) — Learn about convoys and multi-step workflows +- [Settings](/docs/code-with-ai/gastown/settings) — Configure models, merge strategies, and agent behavior diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md new file mode 100644 index 0000000000..d95fa6bf14 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md @@ -0,0 +1,162 @@ +--- +title: "Settings" +description: "Configure models, merge strategies, and agent behavior" +--- + +# {% $markdoc.frontmatter.title %} + +Gas Town settings control how your agents behave — which models they use, how they review code, and how they interact with your repositories. + +Access settings from your town dashboard → **Settings**. + +## Models + +### Default Model + +The primary model used by all agents (polecats, refinery, mayor). This affects quality, speed, and cost. + +Popular choices: +- **Kilo Auto Frontier** — highest quality models, best results (recommended) +- **Kilo Auto Balanced** — good balance of quality and cost (minimum for Gas Town) + +### Role-Specific Models + +Override the default model for specific agent roles. By default, all roles use the town-level model. You can override per-role if you want to optimize cost vs quality for different tasks (e.g., a faster model for the mayor, a stronger model for the refinery). + +### Small Model + +Used for lightweight tasks (classification, routing, summarization). Usually a smaller, cheaper model. + +## Git & Authentication + +### GitHub Personal Access Token + +{% callout type="tip" title="Strongly Recommended" %} +Adding a GitHub PAT ensures that all commits, branches, and PRs created by your agents appear as **you** in git history. Without it, activity shows up under the Kilo GitHub App bot account. +{% /callout %} + +**To add a PAT:** +1. Go to **Settings** → **Git & Authentication** +2. Generate a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) scoped to the connected repository +3. Required permissions: **Contents** (read/write), **Pull requests** (read/write), **Metadata** (read) +4. Optional: add **Actions** (read/write) if your repo uses GitHub Actions workflows +5. Paste the token and save + +{% callout type="info" %} +Use a fine-grained token limited to only the repository your town is connected to. Agents act autonomously on your behalf, so limiting scope is a best practice. +{% /callout %} + +**What the PAT enables:** +- Commits and PRs appear as you (your avatar, your username) +- Agents can use `gh` CLI commands on your behalf +- Access to the specific repository you scoped the token to +- Ability to trigger CI workflows (if Actions permission is granted) + +**Without a PAT:** +- The GitHub App installation token is used (functional but less personal) +- PRs show as created by the Kilo bot +- Some `gh` CLI operations may not work + +### GitHub App Installation + +The [Kilo GitHub App](https://github.com/apps/kilo-code) provides base-level repository access. It's installed per-organization or per-repository and gives agents read/write access to code, PRs, and issues. + +The GitHub App is **required** — it's how Gastown gets installation tokens for cloning and pushing. The PAT is **optional but recommended** — it provides user-level attribution. + +## Merge Strategy + +Controls how reviewed code lands in your repository: + +| Strategy | Behavior | Best for | +|---|---|---| +| `direct` | Refinery merges directly (no PR) | Speed, trusted environments | +| `pr` | Refinery creates a PR | Audit trail, team visibility, CI integration | + +For `pr` mode, you can also configure: +- **Auto-merge** — PRs merge automatically after refinery approval +- **Require human approval** — PRs wait for a human reviewer + +## Refinery Configuration + +### Review Mode + +| Mode | Behavior | +|---|---| +| `always` | Every completed bead goes through review (default, recommended) | +| `never` | Skip review entirely — merge on polecat completion | +| `pr_only` | Only review work that generates a PR | + +### Review Gates + +Strictness level from 1 (lenient) to 5 (strict). Higher levels mean the refinery will reject more frequently but produce higher quality output. + +### Max Review Cycles + +How many write → review → revise cycles before a bead fails and escalates. Default: 3. + +## Agent Limits + +### Max Polecats Per Rig + +How many coding agents can work simultaneously on a single rig. Default: 2. + +Higher values mean more parallel work but also more resource consumption. Consider: +- **1-2** — conservative, good for small repos or limited budgets +- **3-4** — moderate parallelism, good for medium projects +- **5+** — aggressive, best for large repos with lots of independent work + +### Alarm Intervals + +Controls how frequently the reconciler checks for work: +- **Active interval** — when agents are working (default: 5s) +- **Idle interval** — when no work is pending (default: 60s) + +Lower active intervals mean faster response to events but more compute usage. + +## Environment Variables + +Add environment variables that are available to all agents in the container. Useful for: +- API keys for external services agents might test against +- Database connection strings for test environments +- Feature flags or configuration values + +{% callout type="warning" %} +Environment variables are visible to all agents in the town. Do not store production secrets here — use test/development credentials only. +{% /callout %} + +## Custom Instructions + +Free-form text injected into every agent's system prompt. Use this to communicate: + +- Project conventions: *"Always use TypeScript strict mode"* +- Architecture decisions: *"The auth module uses JWT with RS256. Never use HS256."* +- Style preferences: *"Use functional components with hooks, never class components"* +- Constraints: *"Do not modify files in the `vendor/` directory"* +- Testing requirements: *"Every new function must have a corresponding unit test"* + +Custom instructions are powerful — they let you encode institutional knowledge that agents follow consistently. + +## Convoy Settings + +### Staged Convoys Default + +When `true`, new convoys are created as staged (paused until you un-stage). Default: `true`. + +### Convoy Merge Mode + +| Mode | Behavior | +|---|---| +| `review-then-land` | Each bead merges to a feature branch, then a landing review merges to main (default) | +| `review-and-merge` | Each bead goes through review and merges directly to main (no feature branch) | + +`review-then-land` provides the strongest quality guarantees but takes longer. Use `review-and-merge` for simpler tasks or when you want immediate feedback. + +## Per-Rig Overrides + +Any town-level setting can be overridden at the rig level. This lets you: +- Use a more powerful model for a complex repository +- Set stricter review gates for a production codebase +- Allow more polecats on a repo with many independent modules +- Disable review for a documentation-only repo + +Access per-rig settings from the rig detail page → **Settings**. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/sling-work.md b/packages/kilo-docs/pages/code-with-ai/gastown/sling-work.md new file mode 100644 index 0000000000..53c7086877 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/sling-work.md @@ -0,0 +1,158 @@ +--- +title: "Sling Work" +description: "Creating tasks and convoys for agents to work on" +--- + +# {% $markdoc.frontmatter.title %} + +"Slinging work" is how you give agents something to do. You can sling a single task (a bead) or a structured multi-step plan (a convoy). + +## Single Tasks + +The simplest way to use Gas Town — describe what needs to be done, and an agent picks it up. + +Ask the Mayor: + +> *"Fix the 404 error on the /settings page — the route is missing from the router config"* + +Or use the **Sling Work** action in the town header. Either way, the reconciler assigns the bead to an available polecat. The agent reads the relevant code, makes the fix, runs any tests, and pushes a branch. + +### Writing Good Task Descriptions + +The quality of agent output directly correlates with the clarity of your description: + +| Approach | Example | +|---|---| +| **Vague** (avoid) | "Fix the auth" | +| **Specific** (better) | "Fix the JWT token expiration — tokens should last 24h, not 1h. The constant is in `src/auth/config.ts`" | +| **Contextual** (best) | "Fix #142: users are getting logged out after 1 hour. The issue is the JWT expiration in `src/auth/config.ts` is set to 3600 (1h) but should be 86400 (24h). Add a test to verify." | + +Include: +- **What** needs to change +- **Where** in the codebase (file paths if you know them) +- **Why** it's needed (link to issues, error messages) +- **How to verify** (tests to run, behavior to check) + +## Convoys + +Convoys are where Gas Town really shines. Instead of one agent doing everything in one pass, convoys break complex work into stages where each builds on the last — with adversarial review at every step. + +### Why Convoys? + +Single-pass agent output has a quality ceiling. The longer an agent works on one task, the more likely it is to accumulate compounding errors. Convoys solve this by: + +1. **Decomposing** complex work into focused, reviewable chunks +2. **Sequencing** so later steps build on reviewed, merged code +3. **Reviewing** each chunk independently before it becomes the foundation for the next step +4. **Containing failures** — if step 3 fails, steps 1 and 2 are already safely merged + +{% flowDiagram name="convoy-execution" height="200px" /%} + +### Creating a Convoy + +**Via the Mayor:** +> *"Create a convoy to migrate the database from PostgreSQL to MySQL. Steps: 1) audit current schema and queries, 2) design the new schema with migration plan, 3) implement the migration scripts, 4) update the application layer, 5) add integration tests"* + +The Mayor converts this into a convoy with proper dependencies. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="A staged convoy — review the task breakdown before agents begin" %} +{% image src="/docs/img/gastown/gt-rig-page-staged-convoy-detail.png" alt="Gas Town staged convoy detail showing task dependencies" /%} +{% /browserFrame %} + +### Convoy Execution + +Once started, the reconciler manages the convoy: + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Convoy in progress — review bead detail showing the refinery at work" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-review-bead-detail.png" alt="Gas Town convoy review bead detail" /%} +{% /browserFrame %} + +Key behaviors: +- Each polecat starts from the **convoy feature branch**, which accumulates all previously merged work +- Beads only dispatch when their **dependencies are satisfied** (upstream beads closed) +- The refinery reviews each sub-PR against the convoy branch +- Once all beads close, a **landing review** checks the full combined diff before merging to main + +### The Adversarial Advantage + +The convoy pattern creates **layered adversarial review**: + +1. **Per-bead review** — refinery critiques each individual contribution +2. **Context accumulation** — each agent builds on verified, reviewed code +3. **Landing review** — the complete feature is reviewed holistically +4. **Combined with Kilo Code Review** — if configured, human reviewers see the final PR too + +This means code goes through **3-4 review passes** before landing in your main branch. Bugs get caught at the smallest possible scope where they're cheapest to fix. + +### Staged Convoys + +By default, convoys are created **staged** — the plan exists but agents don't start until you un-stage it. This lets you: + +- Review the task breakdown before execution +- Adjust descriptions, add context, reorder +- Ensure the plan makes sense before burning compute + +Un-stage via the convoy detail page or ask the Mayor: *"Start the database migration convoy"* + +## Assigning Priority + +Beads have priority levels: `low`, `medium` (default), `high`, `critical`. + +Higher priority beads are dispatched first when multiple beads are waiting for agents. Set priority: +- In the Sling Work dialog +- Via the Mayor: *"Make the auth fix high priority"* +- By editing the bead after creation + +## Watching Progress + +### Rig Page — Convoy Tracker + +The best place to observe your town in action is the **rig page**. At the top, active convoys show their progress as a visual tracker — each bead in the convoy displayed with its current status and dependency relationships. You can see exactly where in the DAG execution has reached and which beads are blocking downstream work. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Convoy tracker — see exactly where execution has reached" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-in-progress.png" alt="Gas Town rig page convoy tracker with beads in various states" /%} +{% /browserFrame %} + +### Rig Page — Kanban Board + +Below the convoy tracker, a kanban board shows beads organized by status — open, in progress, in review, and closed — updating in real-time as agents move work through the pipeline. + +{% browserFrame url="app.kilo.ai/gastown/town/rigs/main" caption="Kanban board — beads flow through columns as agents work" %} +{% image src="/docs/img/gastown/gt-rig-page-convoy-bead-in-review.png" alt="Gas Town rig page kanban board with a bead in review" /%} +{% /browserFrame %} + +You can see at a glance: +- What's queued up (open column) +- What agents are actively working on (in progress) +- What's awaiting review (in review) +- What's shipped (closed) + +Beads move through columns autonomously as the reconciler dispatches agents and work progresses. + +### Beads Page + +For a more detailed, filterable view across all rigs, the beads page shows every bead in your town. Filter by: +- Status (open, in progress, in review, closed, failed) +- Type (issue, merge_request, convoy) +- Rig (if you have multiple repos) + +{% browserFrame url="app.kilo.ai/gastown/town/beads" caption="Beads page — filterable list of all work items" %} +{% image src="/docs/img/gastown/gt-beads-page.png" alt="Gas Town beads page" /%} +{% /browserFrame %} + +Click any bead to see its full detail — description, event history, agent activity, and review feedback: + +{% browserFrame url="app.kilo.ai/gastown/town/beads/detail" caption="Bead detail — full history and status" %} +{% image src="/docs/img/gastown/gt-beads-page-detail.png" alt="Gas Town bead detail view" /%} +{% /browserFrame %} + +### Town Overview + +The town overview shows a high-level summary: +- Active agents and what they're working on +- Recent completions +- Pending work queue + +### Real-Time Events + +The event timeline shows every state transition as it happens — bead dispatched, review submitted, merge completed. Useful for understanding the flow when you want to see exactly what's happening under the hood. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md new file mode 100644 index 0000000000..62067e340f --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md @@ -0,0 +1,136 @@ +--- +title: "Troubleshooting" +description: "Common issues and how to resolve them" +--- + +# {% $markdoc.frontmatter.title %} + +Gas Town is a complex system with multiple agents, containers, and external integrations. When things go wrong, this guide helps you diagnose and fix common issues. + +## Agents Not Picking Up Work + +**Symptom:** Beads stay in `open` status. No agents transition to `working`. + +**Common causes:** + +| Cause | Fix | +|---|---| +| All polecats at max dispatch attempts | Ask the Mayor: *"Reset agent dispatch attempts"* | +| Reconciler paused (draining) | Wait for drain to complete | +| No available polecats | Check `max_polecats_per_rig` — you may need to increase it | + +**Quick fix:** Ask the Mayor: *"Why aren't beads getting picked up?"* — it can diagnose and often resolve the issue. + +## Container Not Starting + +**Symptom:** The container status shows errors or stays in "starting" indefinitely. + +**Common causes:** +- Git clone failure (bad credentials, repo not accessible) +- GitHub App not installed on the repository +- Environment variables causing startup crash + +**Fix:** +1. Check that the [Kilo GitHub App](https://github.com/apps/kilo-code) is installed on your repository +2. Verify your GitHub PAT is valid (if configured) +3. Remove any environment variables that might cause issues during container init +4. Try a container restart from town settings + +## Git Authentication Failures + +**Symptom:** Agents can't clone, push, or fetch. Errors mention "authentication failed" or "permission denied". + +**Common causes:** + +| Cause | Fix | +|---|---| +| GitHub App uninstalled | Reinstall at [github.com/apps/kilo-code](https://github.com/apps/kilo-code) | +| PAT expired or revoked | Generate a new token and update in Settings → Git & Authentication | +| Repository visibility changed | Ensure the GitHub App has access to the repo | +| Org SSO not authorized | Authorize the token for your organization's SSO | + +**Diagnosis:** The Mayor may report *"git credential refresh failed: no_installation_found"* — this definitively means the GitHub App needs to be reinstalled. + +## Review Loop / Stuck in Review + +**Symptom:** A bead cycles between `in_review` and `in_progress` repeatedly, or MR beads keep failing. + +**Common causes:** +- The refinery finds issues the polecat can't fix (e.g., fundamental architecture problems) +- Missing PR URL on merge request beads +- Convoy feature branch deleted from remote + +**Fix:** +1. Check the bead's event history for review feedback +2. If the feedback is a dead end, ask the Mayor to close the bead: *"Close bead [id] — the approach isn't working"* +3. For stuck convoys, the Mayor can force-close: *"Force close convoy [name]"* + +{% callout type="info" %} +Beads automatically escalate after 3 failed review cycles. If a bead is genuinely stuck in a loop, it will eventually fail and notify you rather than running forever. +{% /callout %} + +## The Mayor is Unresponsive + +**Symptom:** Messages to the Mayor don't get responses, or the Mayor says it's "unauthenticated". + +**Common causes:** +- Container sleeping (wakes up after ~30 seconds) +- KILOCODE_TOKEN expired +- Gateway authentication failure + +**Fix:** +1. Wait 30 seconds — the container may be waking from sleep +2. If persistent: go to Settings → refresh the container token +3. If still failing: go to settings, force container shutdown + +## Convoy Stuck / Never Completes + +**Symptom:** A convoy shows open but no beads are being dispatched, or it never reaches "landed". + +**Common causes:** +- Upstream dependency bead failed (blocks downstream) +- Convoy feature branch doesn't exist on remote +- Landing MR repeatedly failing + +**Fix:** +1. Check convoy progress: which beads are closed? which are open/failed? +2. For failed dependencies: fix or close the blocking bead, then downstream beads will dispatch +3. Ask the Mayor: *"What's blocking convoy [name]?"* +4. If truly stuck: force-close the convoy and re-create it + +## Agent Permanently Stuck + +**Symptom:** An agent shows as `working` but hasn't produced output for 20+ minutes. + +**Common causes:** +- Container process crashed but heartbeat continues +- Agent waiting on an external resource (network, API) +- Infinite loop in agent execution + +**Fix:** +1. Ask the Mayor: *"Reset agent [name]"* +2. This clears the hook, resets the agent to idle, and returns the bead to `open` +3. The reconciler will re-dispatch the bead to a fresh agent + +## High Failure Rate + +**Symptom:** Many beads ending in `failed` status. + +**Common causes:** +- Task descriptions are too vague (agents can't figure out what to do) +- The codebase has issues that prevent agents from working (broken build, missing dependencies) +- Model quality is too low for the complexity of the work + +**Fix:** +1. Review failed bead descriptions — make them more specific +2. Ensure the repo builds cleanly (agents struggle with pre-existing broken builds) +3. Consider upgrading the model (Auto Balanced → Auto Frontier for complex work) +4. Add custom instructions to guide agents: test commands, build steps, conventions + +## Getting Help + +If you can't resolve an issue: + +1. **Ask the Mayor** — it can diagnose most problems +2. **Check the event timeline** — see exactly what happened and when +3. **Contact support** — reach out at [kilo.ai/discord](https://kilo.ai/discord) with your town ID diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 4990f11ee0..7a647f2e3e 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -1,5 +1,5 @@ --- -title: "Kilo CLI" +title: "Kilo Code CLI: Run the AI Coding Agent from Your Terminal" description: "Using Kilo Code from the command line" platform: new --- @@ -8,7 +8,7 @@ platform: new This documentation applies only to Kilo version 1.0 and later. Users running versions below 1.0 should upgrade before proceeding. {% /callout %} -# Kilo CLI +# Kilo Code CLI: AI Coding Agent in Your Terminal Orchestrate agents from your terminal. Plan, debug, and code fast with keyboard-first navigation on the command line. @@ -346,6 +346,43 @@ Both keys also accept object configuration for specific tools or language server The TUI gives `Ctrl+Z` to input undo on Windows because native Windows terminals do not support POSIX terminal suspend. On Windows, `input_undo` defaults to `ctrl+z,ctrl+-,super+z` and `terminal_suspend` is disabled. On macOS and Linux, `terminal_suspend` defaults to `ctrl+z`. +#### Enabling Shift+Enter in Windows Terminal + +Some terminals don't send modifier keys with Enter by default. Windows Terminal requires a one-time configuration to forward `Shift+Enter` as an escape sequence that Kilo can read. + +Open your `settings.json` at: + +``` +%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json +``` + +Add this entry to the root-level `actions` array: + +```json +"actions": [ + { + "command": { + "action": "sendInput", + "input": "\u001b[13;2u" + }, + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Add this entry to the root-level `keybindings` array: + +```json +"keybindings": [ + { + "keys": "shift+enter", + "id": "User.sendInput.ShiftEnterCustom" + } +] +``` + +Save the file and restart Windows Terminal or open a new tab. `Shift+Enter` will now insert a newline in the Kilo prompt instead of submitting the message. + ### OpenTelemetry Export Kilo telemetry is enabled by default and can be disabled with `experimental.openTelemetry = false`: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md b/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md index e6abe34661..a94b3cfc49 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md @@ -1,9 +1,9 @@ --- -title: "JetBrains Extension" +title: "Kilo Code for JetBrains: Free Open-Source AI Coding Plugin" description: "Using Kilo Code in JetBrains IDEs" --- -# JetBrains Extension +# Kilo Code for JetBrains: Free AI Coding Plugin ## Installation diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md index e539edf26b..6bc15d57ba 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md @@ -1,9 +1,9 @@ --- -title: "VS Code Extension" +title: "Kilo Code for VS Code: Free Open-Source AI Coding Extension" description: "Using Kilo Code in Visual Studio Code" --- -# VS Code Extension +# Kilo Code for VS Code Kilo Code is available as two VS Code extensions: the **VSCode (Legacy)** extension and the current **VSCode** version built on Kilo's shared agent runtime. 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 b638dba13d..834906dc84 100644 --- a/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md +++ b/packages/kilo-docs/pages/contributing/architecture/per-message-feedback.md @@ -24,7 +24,7 @@ Add a thumbs-up / thumbs-down widget next to the existing copy button on every a | Surface | Approach | |---|---| | VS Code extension | Thumbs buttons inline next to the copy button | -| TUI | Keybinds (`+` / `-`) on the last assistant message | +| TUI | Keybinds (`=` / `-`) on the last assistant message | ### Telemetry Payload diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index 6145275787..f95b39d715 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react" +import Head from "next/head" import Link from "next/link" // Terminal content for each tab @@ -236,6 +237,9 @@ export default function HomePage() { return (
+ + Kilo Code Docs: Setup, Models, MCP, Custom Modes & CLI + {/* Dotted background pattern */}
diff --git a/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png b/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png new file mode 100644 index 0000000000..01722c3f1f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-beads-page-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-beads-page.png b/packages/kilo-docs/public/img/gastown/gt-beads-page.png new file mode 100644 index 0000000000..baed8ac1a1 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-beads-page.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png new file mode 100644 index 0000000000..349af417c8 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page-review-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png new file mode 100644 index 0000000000..368f8ac325 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-merge-queue-page.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-new-rig.png b/packages/kilo-docs/public/img/gastown/gt-new-rig.png new file mode 100644 index 0000000000..7cb3444e53 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-new-rig.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png b/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png new file mode 100644 index 0000000000..d1a3e6ac8f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-new-town-onboarding.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png new file mode 100644 index 0000000000..210e5aaa67 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-bead-in-review.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png new file mode 100644 index 0000000000..8880690dc1 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-in-progress.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png new file mode 100644 index 0000000000..43dae48328 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-convoy-review-bead-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png new file mode 100644 index 0000000000..ec185145d0 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy-detail.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png new file mode 100644 index 0000000000..fdb9f9251f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-rig-page-staged-convoy.png differ diff --git a/packages/kilo-docs/public/img/gastown/gt-town-overview.png b/packages/kilo-docs/public/img/gastown/gt-town-overview.png new file mode 100644 index 0000000000..12306817e5 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/gt-town-overview.png differ diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-collapse-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-collapse-all-button-chromium-linux.png new file mode 100644 index 0000000000..38e00a5a77 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-collapse-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85c08e55bbd0b2c4bd77ed868fde4d6dac03ffcbb41c6103a8a48fd724875e79 +size 3251 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-expand-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-expand-all-button-chromium-linux.png new file mode 100644 index 0000000000..46f4de3628 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-bulk-action-expand-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:200794b3b5539cd5ef4977f315214644d4e038766b48f5fdf0a86f735a276bcd +size 3076 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-collapse-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-collapse-all-button-chromium-linux.png new file mode 100644 index 0000000000..ec8047255e --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-collapse-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd92f927ee977559db9f2a9cafd19f7f8ec88542c2d5e46ce5ecb2bd11ab6ad4 +size 1682 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-expand-all-button-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-expand-all-button-chromium-linux.png new file mode 100644 index 0000000000..17b1602177 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/inline-diff-bulk-action-expand-all-button-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4f037c58f94e856a1093e914380efe29b5f1f59aebbc8247df4a434575bd4ab +size 1714 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png index bc4bc54558..d6b7e3dee6 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 999c6b2f9e..fe9ea05396 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 0760abd4b8..e3f811c439 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 0495921387..d341a7a2c7 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 d7d21c928f..889daa6e72 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 2283986b26..18c542093c 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 0000000000..e395b8b062 --- /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 9cd64a85b0..124f0eccf1 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.48", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/api/embedding-models.ts b/packages/kilo-gateway/src/api/embedding-models.ts new file mode 100644 index 0000000000..475d8d2524 --- /dev/null +++ b/packages/kilo-gateway/src/api/embedding-models.ts @@ -0,0 +1,63 @@ +import { z } from "zod" +import { resolveKiloGatewayBaseUrl } from "./url.js" + +export type KiloEmbeddingModel = { + id: string + name: string + dimension: number + scoreThreshold: number + note?: string +} + +export type KiloEmbeddingModelCatalog = { + defaultModel: string + models: KiloEmbeddingModel[] + aliases: Record +} + +export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = { + defaultModel: "", + models: [], + aliases: {}, +} + +const model = z.object({ + id: z.string(), + name: z.string(), + dimension: z.number().int().positive(), + scoreThreshold: z.number(), + note: z.string().optional(), +}) + +const catalog = z.object({ + defaultModel: z.string(), + models: z.array(model), + aliases: z.record(z.string(), z.string()), +}) + +type Options = { + baseURL?: string + token?: string + signal?: AbortSignal +} + +export async function fetchKiloEmbeddingModelCatalog(options: Options = {}): Promise { + const url = new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: options.baseURL, token: options.token })) + + try { + const response = await fetch(url, { signal: options.signal }) + if (!response.ok) { + console.warn(`[Kilo Gateway] Failed to fetch embedding model catalog: ${response.status}`) + return EMPTY_KILO_EMBEDDING_MODEL_CATALOG + } + const parsed = catalog.safeParse(await response.json()) + if (!parsed.success) { + console.warn("[Kilo Gateway] Embedding model catalog response validation failed:", parsed.error.format()) + return EMPTY_KILO_EMBEDDING_MODEL_CATALOG + } + return parsed.data + } catch (err) { + console.warn("[Kilo Gateway] Error fetching embedding model catalog:", err) + return EMPTY_KILO_EMBEDDING_MODEL_CATALOG + } +} diff --git a/packages/kilo-gateway/src/api/url.ts b/packages/kilo-gateway/src/api/url.ts new file mode 100644 index 0000000000..9184f7d5ea --- /dev/null +++ b/packages/kilo-gateway/src/api/url.ts @@ -0,0 +1,30 @@ +import { KILO_API_BASE } from "./constants.js" +import { getKiloUrlFromToken } from "../auth/token.js" + +type UrlOptions = { + baseURL?: string + token?: string +} + +function route(raw: string, name: "gateway" | "openrouter"): string { + const url = new URL(raw) + const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean) + const api = parts.lastIndexOf("api") + const prefix = api >= 0 ? parts.slice(0, api) : parts + url.pathname = `/${[...prefix, "api", name].join("/")}/` + url.search = "" + url.hash = "" + return url.toString() +} + +function base(options: UrlOptions): string { + return getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, options.token ?? "") +} + +export function resolveKiloGatewayBaseUrl(options: UrlOptions = {}): string { + return route(base(options), "gateway") +} + +export function resolveKiloOpenRouterBaseUrl(options: UrlOptions = {}): string { + return route(base(options), "openrouter") +} diff --git a/packages/kilo-gateway/src/auth/token.ts b/packages/kilo-gateway/src/auth/token.ts index 0759a74cc5..375d15bafc 100644 --- a/packages/kilo-gateway/src/auth/token.ts +++ b/packages/kilo-gateway/src/auth/token.ts @@ -4,20 +4,16 @@ */ export function getKiloUrlFromToken(defaultUrl: string, token: string): string { // If token contains URL information, extract it - // This is a simplified version - adjust based on actual token format if (!token) return defaultUrl - try { - // Check if token has URL prefix (format: "url:base64token") - const parts = token.split(":") - if (parts.length > 1 && parts[0].startsWith("http")) { - return parts[0] - } - } catch (e) { - // If parsing fails, return default - } + const match = token.match(/^(https?:\/\/[^:]+(?::\d+)?(?:\/[^:]*)?):/) + if (!match) return defaultUrl - return defaultUrl + try { + return new URL(match[1]).toString().replace(/\/+$/, "") + } catch { + return defaultUrl + } } /** diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 8538737e6e..b3c2d8ace5 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -34,6 +34,13 @@ export { promptOrganizationSelection, } from "./api/profile.js" export { fetchKiloModels } from "./api/models.js" +export { + EMPTY_KILO_EMBEDDING_MODEL_CATALOG, + fetchKiloEmbeddingModelCatalog, + type KiloEmbeddingModel, + type KiloEmbeddingModelCatalog, +} from "./api/embedding-models.js" +export { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "./api/url.js" export { fetchOrganizationModes, clearModesCache, diff --git a/packages/kilo-gateway/src/provider-debug.ts b/packages/kilo-gateway/src/provider-debug.ts index 71f7f2a2d9..57d779f7f3 100644 --- a/packages/kilo-gateway/src/provider-debug.ts +++ b/packages/kilo-gateway/src/provider-debug.ts @@ -1,9 +1,10 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider" import type { Provider as SDK } from "ai" import type { KiloProviderOptions } from "./types.js" -import { getKiloUrlFromToken, getApiKey } from "./auth/token.js" +import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" -import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js" +import { ANONYMOUS_API_KEY } from "./api/constants.js" +import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" /** * Debug version of createKilo with extensive logging @@ -18,16 +19,7 @@ export function createKiloDebug(options: KiloProviderOptions = {}): SDK { console.log(" - Source:", options.kilocodeToken ? "kilocodeToken" : options.apiKey ? "apiKey" : "none") console.log(" - Value:", apiKey ? `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 8)}` : "MISSING!") - // Determine base URL - const baseApiUrl = getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, apiKey ?? "") - console.log("🌐 [KILO DEBUG] Base URL resolved:", baseApiUrl) - - // Build OpenRouter URL - only append /openrouter/ if not already present - const openRouterUrl = baseApiUrl.includes("/openrouter") - ? baseApiUrl - : baseApiUrl.endsWith("/") - ? `${baseApiUrl}openrouter/` - : `${baseApiUrl}/openrouter/` + const openRouterUrl = resolveKiloOpenRouterBaseUrl({ baseURL: options.baseURL, token: apiKey }) console.log("🔗 [KILO DEBUG] OpenRouter URL:", openRouterUrl) // Merge custom headers with defaults diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index 62bd6716d1..dea75818a7 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -4,9 +4,10 @@ import { createAnthropic } from "@ai-sdk/anthropic" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import type { KiloProvider, KiloProviderOptions } from "./types.js" -import { getKiloUrlFromToken, getApiKey } from "./auth/token.js" +import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" -import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js" +import { ANONYMOUS_API_KEY } from "./api/constants.js" +import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" /** * Create a KiloCode provider instance @@ -28,15 +29,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { // Get API key from options or environment const apiKey = getApiKey(options) - // Determine base URL - const baseApiUrl = getKiloUrlFromToken(options.baseURL ?? KILO_API_BASE, apiKey ?? "") - - // Build OpenRouter URL - only append /openrouter/ if not already present - const openRouterUrl = baseApiUrl.includes("/openrouter") - ? baseApiUrl - : baseApiUrl.endsWith("/") - ? `${baseApiUrl}openrouter/` - : `${baseApiUrl}/openrouter/` + const openRouterUrl = resolveKiloOpenRouterBaseUrl({ baseURL: options.baseURL, token: apiKey }) // Merge custom headers with defaults const customHeaders = { diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 27b4c8b857..269427b7f4 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -38,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 @@ -85,6 +85,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { SessionCreatedEvent, Identifier, ModelCache, + InstanceStore, } = deps const Organization = z.object({ @@ -216,7 +217,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { ModelCache.clear("kilo") clearModesCache() - await Instance.disposeAll() + await InstanceStore.disposeAllInstances() return c.json(true) }, diff --git a/packages/kilo-gateway/test/api/embedding-models.test.ts b/packages/kilo-gateway/test/api/embedding-models.test.ts new file mode 100644 index 0000000000..f2e15a9e25 --- /dev/null +++ b/packages/kilo-gateway/test/api/embedding-models.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, mock, test } from "bun:test" +import { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog } from "../../src/api/embedding-models" + +describe("fetchKiloEmbeddingModelCatalog", () => { + test("fetches catalog from Kilo Gateway", async () => { + const prev = global.fetch + const fn = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + defaultModel: "provider/model", + models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }], + aliases: { model: "provider/model" }, + }), + ), + ), + ) as unknown as typeof fetch + global.fetch = fn + + try { + const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" }) + + expect(catalog.defaultModel).toBe("provider/model") + expect((fn as unknown as { mock: { calls: Array<[URL]> } }).mock.calls[0]?.[0].toString()).toBe( + "https://example.test/api/gateway/embedding-models", + ) + } finally { + global.fetch = prev + } + }) + + test("falls back when the request fails", async () => { + const prev = global.fetch + global.fetch = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) as unknown as typeof fetch + + try { + await expect(fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })).resolves.toEqual( + EMPTY_KILO_EMBEDDING_MODEL_CATALOG, + ) + } finally { + global.fetch = prev + } + }) + + test("fallback catalog is empty so Cloud owns model metadata", () => { + expect(EMPTY_KILO_EMBEDDING_MODEL_CATALOG).toEqual({ + defaultModel: "", + models: [], + aliases: {}, + }) + }) +}) diff --git a/packages/kilo-gateway/test/api/url.test.ts b/packages/kilo-gateway/test/api/url.test.ts new file mode 100644 index 0000000000..e778182cab --- /dev/null +++ b/packages/kilo-gateway/test/api/url.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "../../src/api/url" + +describe("Kilo API URL resolvers", () => { + test("resolves production route bases", () => { + expect(resolveKiloGatewayBaseUrl()).toBe("https://api.kilo.ai/api/gateway/") + expect(resolveKiloOpenRouterBaseUrl()).toBe("https://api.kilo.ai/api/openrouter/") + }) + + test("normalizes root API base overrides", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test" })).toBe("https://example.test/api/gateway/") + expect(resolveKiloOpenRouterBaseUrl({ baseURL: "https://example.test/" })).toBe( + "https://example.test/api/openrouter/", + ) + }) + + test("replaces existing Kilo API route paths", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test/api/openrouter/" })).toBe( + "https://example.test/api/gateway/", + ) + expect(resolveKiloOpenRouterBaseUrl({ baseURL: "https://example.test/api/gateway/" })).toBe( + "https://example.test/api/openrouter/", + ) + }) + + test("preserves path prefixes before api", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test/dev/api/openrouter/" })).toBe( + "https://example.test/dev/api/gateway/", + ) + expect(resolveKiloOpenRouterBaseUrl({ baseURL: "https://example.test/dev" })).toBe( + "https://example.test/dev/api/openrouter/", + ) + }) + + test("strips search and hash components", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://example.test/api/openrouter/?x=1#frag" })).toBe( + "https://example.test/api/gateway/", + ) + }) + + test("prefers token-derived URL when token contains one", () => { + expect(resolveKiloGatewayBaseUrl({ baseURL: "https://fallback.test", token: "https://token.test:opaque" })).toBe( + "https://token.test/api/gateway/", + ) + }) + + test("resolves child endpoint URLs", () => { + expect(new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: "https://example.test" })).toString()).toBe( + "https://example.test/api/gateway/embedding-models", + ) + }) +}) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 8be4c75497..cc1ef749a1 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.48", "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 857501d6d6..7dd1f9304f 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 8624128af4..e79ef39bf3 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 8773799ea6..e23e964bbd 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 bbed59f151..2a086970b4 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 51f7adefc8..437f5fc649 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 e9007fb2ca..bd80d92857 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 91d40b832a..8b05522cb9 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 f44f61b142..b64ace8f1f 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 604bbba38d..9d5214c7a4 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 678e80af0c..6308cf9fc6 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 4529ff8eaa..4fb55eddca 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 c49b1b4873..915801390a 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 a42adca11b..67b83e46c2 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 73e695f75b..d9cff803db 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 613b621ef0..be90eddc10 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 ef8ec7f0b0..0ebf2a73f2 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 e12d8bb9c5..3e325a33f7 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 a891782e07..29a57c5290 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 519d0be6cc..0858281256 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 67919b6d79..01b6a92e9b 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.48", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", @@ -17,6 +17,7 @@ ".": "./src/index.ts", "./config": "./src/config.ts", "./detect": "./src/detect.ts", + "./embedding-models": "./src/kilo-embedding-models.ts", "./engine": "./src/indexing/index.ts", "./server": "./src/server/routes.ts", "./status": "./src/status.ts" diff --git a/packages/kilo-indexing/src/config.ts b/packages/kilo-indexing/src/config.ts index e9811da454..7eafc17b06 100644 --- a/packages/kilo-indexing/src/config.ts +++ b/packages/kilo-indexing/src/config.ts @@ -3,6 +3,7 @@ import type { IndexingConfigInput } from "./indexing/config-manager" import type { EmbedderProvider } from "./indexing/interfaces/manager" const providers = [ + "kilo", "openai", "ollama", "openai-compatible", @@ -26,6 +27,15 @@ export const IndexingConfig = z .optional() .describe("Override embedding vector dimension (auto-detected from model if omitted)"), vectorStore: z.enum(["lancedb", "qdrant"]).optional().describe("Vector store backend (default: qdrant)"), + kilo: z + .object({ + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + organizationId: z.string().optional(), + }) + .strict() + .optional() + .describe("Kilo-hosted embedding provider options"), openai: z .object({ apiKey: z.string().optional() }) .strict() @@ -134,6 +144,9 @@ export function toIndexingConfigInput(cfg: IndexingConfig | undefined): Indexing searchMaxResults: cfg?.searchMaxResults, embeddingBatchSize: cfg?.embeddingBatchSize, scannerMaxBatchRetries: cfg?.scannerMaxBatchRetries, + kiloApiKey: cfg?.kilo?.apiKey, + kiloBaseUrl: cfg?.kilo?.baseUrl, + kiloOrganizationId: cfg?.kilo?.organizationId, openAiKey: cfg?.openai?.apiKey, ollamaBaseUrl: cfg?.ollama?.baseUrl, openAiCompatibleBaseUrl: cfg?.["openai-compatible"]?.baseUrl, diff --git a/packages/kilo-indexing/src/indexing/config-manager.ts b/packages/kilo-indexing/src/indexing/config-manager.ts index 6653551bc6..00c9efd527 100644 --- a/packages/kilo-indexing/src/indexing/config-manager.ts +++ b/packages/kilo-indexing/src/indexing/config-manager.ts @@ -22,6 +22,9 @@ export interface IndexingConfigInput { searchMaxResults?: number embeddingBatchSize?: number scannerMaxBatchRetries?: number + kiloApiKey?: string + kiloBaseUrl?: string + kiloOrganizationId?: string openAiKey?: string ollamaBaseUrl?: string openAiCompatibleBaseUrl?: string @@ -51,6 +54,7 @@ export class CodeIndexConfigManager { private lancedbVectorStoreDirectory?: string private modelId?: string private modelDimension?: number + private kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string } private openAiOptions?: { apiKey: string } private ollamaOptions?: { baseUrl: string; modelId?: string } private openAiCompatibleOptions?: { baseUrl: string; apiKey: string } @@ -102,6 +106,9 @@ export class CodeIndexConfigManager { this.modelDimension = undefined } + this.kiloOptions = input.kiloApiKey + ? { apiKey: input.kiloApiKey, baseUrl: input.kiloBaseUrl, organizationId: input.kiloOrganizationId } + : undefined this.openAiOptions = input.openAiKey ? { apiKey: input.openAiKey } : undefined const url = input.ollamaBaseUrl ?? (input.embedderProvider === "ollama" ? "http://localhost:11434" : undefined) this.ollamaOptions = url ? { baseUrl: url, modelId: input.modelId } : undefined @@ -130,6 +137,9 @@ export class CodeIndexConfigManager { lancedbVectorStoreDirectory: this.lancedbVectorStoreDirectory, modelId: this.modelId, modelDimension: this.modelDimension, + kiloApiKey: this.kiloOptions?.apiKey ?? "", + kiloBaseUrl: this.kiloOptions?.baseUrl ?? "", + kiloOrganizationId: this.kiloOptions?.organizationId ?? "", openAiKey: this.openAiOptions?.apiKey ?? "", ollamaBaseUrl: this.ollamaOptions?.baseUrl ?? "", openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "", @@ -154,6 +164,7 @@ export class CodeIndexConfigManager { // LanceDB doesn't need a qdrant URL; qdrant does const hasStore = isLancedb || !!qdrant + if (provider === "kilo") return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore) if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore) if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore) if (provider === "openai-compatible") @@ -194,6 +205,9 @@ export class CodeIndexConfigManager { return true // Auth changes + if ((prev.kiloApiKey ?? "") !== (this.kiloOptions?.apiKey ?? "")) return true + if ((prev.kiloBaseUrl ?? "") !== (this.kiloOptions?.baseUrl ?? "")) return true + if ((prev.kiloOrganizationId ?? "") !== (this.kiloOptions?.organizationId ?? "")) return true if ((prev.openAiKey ?? "") !== (this.openAiOptions?.apiKey ?? "")) return true if ((prev.ollamaBaseUrl ?? "") !== (this.ollamaOptions?.baseUrl ?? "")) return true if ( @@ -247,6 +261,7 @@ export class CodeIndexConfigManager { lancedbVectorStoreDirectoryPlaceholder: this.lancedbVectorStoreDirectory, modelId: this.modelId, modelDimension: this.modelDimension, + kiloOptions: this.kiloOptions, openAiOptions: this.openAiOptions, ollamaOptions: this.ollamaOptions, openAiCompatibleOptions: this.openAiCompatibleOptions, diff --git a/packages/kilo-indexing/src/indexing/embedders/kilo.ts b/packages/kilo-indexing/src/indexing/embedders/kilo.ts new file mode 100644 index 0000000000..0627f2f1fd --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/kilo.ts @@ -0,0 +1,68 @@ +import { resolveKiloGatewayBaseUrl } from "@kilocode/kilo-gateway" +import { HEADER_FEATURE, HEADER_ORGANIZATIONID } from "@kilocode/kilo-gateway" +import { MAX_ITEM_TOKENS } from "../constants" +import type { EmbedderInfo, EmbeddingResponse, IEmbedder } from "../interfaces/embedder" +import { Log } from "../../util/log" +import { OpenAICompatibleEmbedder } from "./openai-compatible" + +const log = Log.create({ service: "embedder-kilo" }) + +export const KILO_INDEXING_FEATURE = "managed-indexing" + +export class KiloEmbedder implements IEmbedder { + private readonly embedder: OpenAICompatibleEmbedder + private readonly model: string + + constructor(input: { + apiKey: string + baseUrl?: string + organizationId?: string + modelId?: string + dimensions?: number + }) { + if (!input.apiKey) throw new Error("Kilo API key is required for embedding.") + + if (!input.modelId) throw new Error("Kilo embedding model is required.") + this.model = input.modelId + const headers: Record = { + [HEADER_FEATURE]: KILO_INDEXING_FEATURE, + ...(input.organizationId ? { [HEADER_ORGANIZATIONID]: input.organizationId } : {}), + } + + this.embedder = new OpenAICompatibleEmbedder( + resolveKiloGatewayBaseUrl({ baseURL: input.baseUrl, token: input.apiKey }), + input.apiKey, + this.model, + MAX_ITEM_TOKENS, + { headers, dimensions: input.dimensions }, + ) + } + + async createEmbeddings(texts: string[], model?: string): Promise { + try { + return await this.embedder.createEmbeddings(texts, model || this.model) + } catch (err) { + log.error("Kilo embedder error", { + err: err instanceof Error ? err.message : String(err), + location: "KiloEmbedder:createEmbeddings", + }) + throw err + } + } + + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + try { + return await this.embedder.validateConfiguration() + } catch (err) { + log.error("Kilo embedder validation error", { + err: err instanceof Error ? err.message : String(err), + location: "KiloEmbedder:validateConfiguration", + }) + throw err + } + } + + get embedderInfo(): EmbedderInfo { + return { name: "kilo" } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts index 45ef6dc08d..5eb7500a6a 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -28,6 +28,11 @@ interface OpenAIEmbeddingResponse { } } +type OpenAICompatibleOptions = { + headers?: Record + dimensions?: number +} + /** * OpenAI Compatible implementation of the embedder interface with batching and rate limiting. * This embedder allows using any OpenAI-compatible API endpoint by specifying a custom baseURL. @@ -40,6 +45,8 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly apiKey: string private readonly isFullUrl: boolean private readonly maxItemTokens: number + private readonly headers: Record + private readonly dimensions?: number // Global rate limiting state shared across all instances private static globalRateLimitState = { @@ -58,7 +65,13 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @param modelId Optional model identifier (defaults to "text-embedding-3-small") * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) */ - constructor(baseUrl: string, apiKey: string, modelId?: string, maxItemTokens?: number) { + constructor( + baseUrl: string, + apiKey: string, + modelId?: string, + maxItemTokens?: number, + options: OpenAICompatibleOptions = {}, + ) { if (!baseUrl) { throw new Error("Base URL is required for OpenAI-compatible embedder") } @@ -73,6 +86,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { this.embeddingsClient = new OpenAI({ baseURL: baseUrl, apiKey: apiKey, + defaultHeaders: options.headers, }) } catch (error) { throw error instanceof Error ? error : new Error(String(error)) @@ -82,6 +96,8 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Cache the URL type check for performance this.isFullUrl = this.isFullEndpointUrl(baseUrl) this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + this.headers = options.headers ?? {} + this.dimensions = options.dimensions } /** @@ -196,6 +212,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { method: "POST", headers: { "Content-Type": "application/json", + ...this.headers, // Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization' // We'll try 'api-key' first for Azure compatibility "api-key": this.apiKey, @@ -205,6 +222,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { input: batchTexts, model: model, encoding_format: "base64", + ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), }), signal, }) @@ -268,6 +286,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // when processing numeric arrays, which breaks compatibility with models using larger dimensions. // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. encoding_format: "base64", + ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), })) as OpenAIEmbeddingResponse } @@ -363,6 +382,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { input: testTexts, model: modelToUse, encoding_format: "base64", + ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), }, { timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, @@ -371,6 +391,10 @@ export class OpenAICompatibleEmbedder implements IEmbedder { )) as OpenAIEmbeddingResponse } + const error = (response as { error?: string | { message?: string } }).error + const message = typeof error === "string" ? error : error?.message + if (message) return { valid: false, error: message } + // Check if we got a valid response if (!response?.data || response.data.length === 0) { return { diff --git a/packages/kilo-indexing/src/indexing/interfaces/config.ts b/packages/kilo-indexing/src/indexing/interfaces/config.ts index 96c370748c..817ddac977 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/config.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/config.ts @@ -14,6 +14,7 @@ export interface CodeIndexConfig { lancedbVectorStoreDirectoryPlaceholder?: string modelId?: string modelDimension?: number + kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string } openAiOptions?: { apiKey: string } ollamaOptions?: { baseUrl: string; modelId?: string } openAiCompatibleOptions?: { baseUrl: string; apiKey: string } @@ -39,6 +40,9 @@ export type PreviousConfigSnapshot = { lancedbVectorStoreDirectory?: string modelId?: string modelDimension?: number + kiloApiKey?: string + kiloBaseUrl?: string + kiloOrganizationId?: string openAiKey?: string ollamaBaseUrl?: string openAiCompatibleBaseUrl?: string diff --git a/packages/kilo-indexing/src/indexing/interfaces/embedder.ts b/packages/kilo-indexing/src/indexing/interfaces/embedder.ts index 8ff6de5be5..598f8ccfb9 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/embedder.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/embedder.ts @@ -29,6 +29,7 @@ export interface EmbeddingResponse { } export type AvailableEmbedders = + | "kilo" | "openai" | "ollama" | "openai-compatible" diff --git a/packages/kilo-indexing/src/indexing/interfaces/manager.ts b/packages/kilo-indexing/src/indexing/interfaces/manager.ts index 5cb4c96f91..5d1e0433f4 100644 --- a/packages/kilo-indexing/src/indexing/interfaces/manager.ts +++ b/packages/kilo-indexing/src/indexing/interfaces/manager.ts @@ -37,6 +37,7 @@ export interface ICodeIndexManager { export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" export type EmbedderProvider = + | "kilo" | "openai" | "ollama" | "openai-compatible" diff --git a/packages/kilo-indexing/src/indexing/model-registry.ts b/packages/kilo-indexing/src/indexing/model-registry.ts index b5378c5c47..12a427d210 100644 --- a/packages/kilo-indexing/src/indexing/model-registry.ts +++ b/packages/kilo-indexing/src/indexing/model-registry.ts @@ -1,11 +1,9 @@ /** * Indexing-local embedding model metadata registry. * - * RATIONALE: The legacy codebase imported model metadata from a shared module - * (`shared/embeddingModels`) that does not exist in this package. Rather than - * recreating the full legacy module, we keep a focused registry of the models - * the indexing engine needs to know about — primarily for dimension resolution, - * default model selection, and score thresholds. + * RATIONALE: This registry only contains provider-local defaults and static + * metadata. Kilo-hosted embedding catalog metadata is fetched from Cloud so the + * model list is defined in one place. */ import type { EmbedderProvider } from "./interfaces/manager" @@ -16,8 +14,6 @@ interface ModelProfile { queryPrefix?: string } -// ASSUMPTION: These dimensions and defaults match the provider documentation -// as of 2025-Q1. Update when providers ship new embedding models. const profiles: Record> = { openai: { "text-embedding-3-small": { dimension: 1536, scoreThreshold: 0.4 }, @@ -60,6 +56,7 @@ const profiles: Record> = { } const defaults: Record = { + kilo: "", openai: "text-embedding-3-small", ollama: "nomic-embed-text", gemini: "gemini-embedding-001", @@ -86,3 +83,12 @@ export function getModelScoreThreshold(provider: EmbedderProvider, modelId: stri export function getModelQueryPrefix(provider: EmbedderProvider, modelId: string): string | undefined { return profiles[provider]?.[modelId]?.queryPrefix } + +export function normalizeKiloModelId(modelId: string | undefined): string | undefined { + return modelId +} + +export function hasModelProfile(provider: EmbedderProvider, modelId: string | undefined): boolean { + if (!modelId) return false + return profiles[provider]?.[modelId] !== undefined +} diff --git a/packages/kilo-indexing/src/indexing/service-factory.ts b/packages/kilo-indexing/src/indexing/service-factory.ts index d0c523ec7e..aa8f14eef7 100644 --- a/packages/kilo-indexing/src/indexing/service-factory.ts +++ b/packages/kilo-indexing/src/indexing/service-factory.ts @@ -5,6 +5,7 @@ import { getDefaultModelId } from "./model-registry" import { resolveEmbeddingProfile } from "./embedding-profile" import { OpenAiEmbedder } from "./embedders/openai" +import { KiloEmbedder } from "./embedders/kilo" import { CodeIndexOllamaEmbedder } from "./embedders/ollama" import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible" import { GeminiEmbedder } from "./embedders/gemini" @@ -63,6 +64,17 @@ export class CodeIndexServiceFactory { const config = this.configManager.getConfig() const provider = config.embedderProvider + if (provider === "kilo") { + if (!config.kiloOptions?.apiKey) throw new Error("Kilo API key is required for embedding.") + if (!config.modelId) throw new Error("Kilo embedding model is required.") + return new KiloEmbedder({ + apiKey: config.kiloOptions.apiKey, + baseUrl: config.kiloOptions.baseUrl, + organizationId: config.kiloOptions.organizationId, + modelId: config.modelId, + dimensions: config.modelDimension, + }) + } if (provider === "openai") { if (!config.openAiOptions?.apiKey) throw new Error("OpenAI API key is required for embedding.") return new OpenAiEmbedder(config.openAiOptions.apiKey, config.modelId) diff --git a/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts b/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts index 1302fd0fd2..26d33aaadc 100644 --- a/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts +++ b/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts @@ -78,6 +78,8 @@ export function getErrorMessageForStatus(status: number | undefined, embedderTyp : "Invalid endpoint URL. Please verify the endpoint." case 429: return "Service is temporarily unavailable due to rate limiting. Please try again later." + case 402: + return "Add credits to continue, or switch to a free embedding model." default: if (status && status >= 400 && status < 600) { return "Configuration error. Please verify your embedder settings." diff --git a/packages/kilo-indexing/src/kilo-embedding-models.ts b/packages/kilo-indexing/src/kilo-embedding-models.ts new file mode 100644 index 0000000000..0fe6c62f80 --- /dev/null +++ b/packages/kilo-indexing/src/kilo-embedding-models.ts @@ -0,0 +1,34 @@ +export type KiloEmbeddingModel = { + id: string + name: string + dimension: number + scoreThreshold: number + note?: string +} + +export type KiloEmbeddingModelCatalog = { + defaultModel: string + models: KiloEmbeddingModel[] + aliases: Record +} + +export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = { + defaultModel: "", + models: [], + aliases: {}, +} + +export function normalizeKiloEmbeddingModelId(model: string | undefined, catalog = EMPTY_KILO_EMBEDDING_MODEL_CATALOG) { + if (!model) return undefined + return catalog.aliases[model] ?? model +} + +export function getKiloEmbeddingModel(model: string | undefined, catalog = EMPTY_KILO_EMBEDDING_MODEL_CATALOG) { + const id = normalizeKiloEmbeddingModelId(model, catalog) + return catalog.models.find((item) => item.id === id) +} + +export function formatKiloEmbeddingModelLabel(model: KiloEmbeddingModel): string { + const note = model.note ? `${model.note}, ` : "" + return `${model.name} (${note}${model.dimension}d)` +} diff --git a/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts index 1656cf75b1..cc56020ab7 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts @@ -31,6 +31,58 @@ describe("CodeIndexConfigManager", () => { expect(cfg.getConfig().vectorStoreProvider).toBe("qdrant") }) + test("configures Kilo with hosted auth options and explicit model metadata", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + kiloBaseUrl: "https://example.test/api/gateway/", + kiloOrganizationId: "org_123", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }), + ) + + expect(cfg.isFeatureConfigured).toBe(true) + expect(cfg.getConfig().kiloOptions).toEqual({ + apiKey: "kilo-token", + baseUrl: "https://example.test/api/gateway/", + organizationId: "org_123", + }) + expect(cfg.currentModelId).toBe("mistralai/mistral-embed-2312") + expect(cfg.currentModelDimension).toBe(1024) + }) + + test("requires Kilo model metadata from Cloud config", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + }), + ) + + expect(cfg.isFeatureConfigured).toBe(false) + expect(cfg.currentModelId).toBeUndefined() + expect(cfg.currentModelDimension).toBeUndefined() + }) + + test("uses configured dimension for Kilo models outside the fallback catalog", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + modelId: "custom/model", + modelDimension: 2048, + }), + ) + + expect(cfg.currentModelId).toBe("custom/model") + expect(cfg.currentModelDimension).toBe(2048) + }) + describe("loadConfiguration restart checks", () => { test("requires restart when model changes with same dimension", () => { const cfg = new CodeIndexConfigManager(createInput({ modelId: "text-embedding-3-small" })) @@ -62,5 +114,29 @@ describe("CodeIndexConfigManager", () => { expect(result.requiresRestart).toBe(true) }) + + test("requires restart when Kilo auth changes", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "old-token", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }), + ) + + const result = cfg.loadConfiguration( + createInput({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "new-token", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }), + ) + + expect(result.requiresRestart).toBe(true) + }) }) }) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/kilo.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/kilo.test.ts new file mode 100644 index 0000000000..abcbc066bc --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/kilo.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory, setOpenAIConstructorHook } from "./__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) + +import { KiloEmbedder, KILO_INDEXING_FEATURE } from "../../../../src/indexing/embedders/kilo" + +describe("KiloEmbedder", () => { + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + setOpenAIConstructorHook(undefined) + }) + + test("uses Kilo Gateway headers and configured embedding model", async () => { + const seen: unknown[] = [] + setOpenAIConstructorHook((cfg) => seen.push(cfg)) + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + const embedder = new KiloEmbedder({ + apiKey: "kilo-token", + organizationId: "org_123", + modelId: "mistralai/mistral-embed-2312", + }) + + await embedder.createEmbeddings(["hello"]) + + expect(seen[0]).toEqual({ + baseURL: "https://api.kilo.ai/api/gateway/", + apiKey: "kilo-token", + defaultHeaders: { + "X-KILOCODE-FEATURE": KILO_INDEXING_FEATURE, + "X-KILOCODE-ORGANIZATIONID": "org_123", + }, + }) + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["hello"], + model: "mistralai/mistral-embed-2312", + encoding_format: "base64", + }) + }) + + test("normalizes custom gateway base URLs", () => { + const seen: unknown[] = [] + setOpenAIConstructorHook((cfg) => seen.push(cfg)) + + new KiloEmbedder({ + apiKey: "kilo-token", + baseUrl: "https://example.test/api/openrouter/", + modelId: "mistralai/mistral-embed-2312", + }) + + expect((seen[0] as { baseURL: string }).baseURL).toBe("https://example.test/api/gateway/") + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts index 2b80ef331d..67aff168c9 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, mock, beforeEach } from "bun:test" import path from "path" -import { mockEmbeddingsCreate, openAIMockFactory } from "./embedders/__helpers__/openai-mock" +import { mockEmbeddingsCreate, openAIMockFactory, setOpenAIConstructorHook } from "./embedders/__helpers__/openai-mock" mock.module("openai", openAIMockFactory) import { CodeIndexServiceFactory } from "../../../src/indexing/service-factory" @@ -26,6 +26,7 @@ function createFactory(input?: Partial { beforeEach(() => { mockEmbeddingsCreate.mockReset() + setOpenAIConstructorHook(undefined) }) test("uses default LanceDB directory when config is unset", () => { @@ -152,4 +153,30 @@ describe("CodeIndexServiceFactory", () => { dimensions: 1024, }) }) + + test("creates Kilo embedder with Cloud-provided model", async () => { + const factory = createFactory({ + embedderProvider: "kilo", + openAiKey: undefined, + kiloApiKey: "kilo-token", + kiloOrganizationId: "org_123", + modelId: "mistralai/mistral-embed-2312", + modelDimension: 1024, + }) + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + const embedder = factory.createEmbedder() + await embedder.createEmbeddings(["hello"]) + + expect(embedder.embedderInfo).toEqual({ name: "kilo" }) + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["hello"], + model: "mistralai/mistral-embed-2312", + encoding_format: "base64", + }) + }) }) diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 730a66c48f..b6cd54c3a2 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.48", "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 d61d4ae06b..59b489a34a 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 2e037e3aa6..16dbcc9220 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 48b1770c7f..a3733c7a8d 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.48", "type": "module", "license": "MIT", "exports": { @@ -114,10 +114,12 @@ "test:visual:update": "playwright test --update-snapshots" }, "dependencies": { + "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", "@solid-primitives/lifecycle": "0.1.2", + "diff": "catalog:", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/rootless": "1.5.2", diff --git a/packages/kilo-ui/src/components/diff-ssr.tsx b/packages/kilo-ui/src/components/diff-ssr.tsx index 7b7701cbb3..84b862f8d7 100644 --- a/packages/kilo-ui/src/components/diff-ssr.tsx +++ b/packages/kilo-ui/src/components/diff-ssr.tsx @@ -2,7 +2,7 @@ import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" -import { createDefaultOptions, styleVariables, type DiffProps } from "@opencode-ai/ui/pierre" +import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" import { acquireVirtualizer, virtualMetrics } from "@opencode-ai/ui/pierre/virtualizer" import { useWorkerPool } from "@opencode-ai/ui/context/worker-pool" diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index 33740ed045..43abaaf245 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -2,12 +2,100 @@ import { sampledChecksum } from "@opencode-ai/core/util/encode" import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack } from "solid-js" -import { createDefaultOptions, type DiffProps, styleVariables } from "@opencode-ai/ui/pierre" +import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" import { acquireVirtualizer, virtualMetrics } from "@opencode-ai/ui/pierre/virtualizer" import { getWorkerPool } from "@opencode-ai/ui/pierre/worker" type SelectionSide = "additions" | "deletions" +const OBSERVER_MARGIN = "2000px 0px" +// Placeholder sizing only: keeps scroll position stable until Pierre renders. +// This does not affect which files are expanded or collapsed. +const ESTIMATED_LINE_HEIGHT = 20 +const MIN_PLACEHOLDER_HEIGHT = 160 +const MAX_PLACEHOLDER_HEIGHT = 1200 +type Job = { run: () => void; cancelled: boolean } + +// A review can contain many expanded diff components. Creating one +// IntersectionObserver per diff showed up in profiles, so all deferred diffs +// share a single observer and only register their element + render callback. +const watchers = new Map() +const queue: Job[] = [] +let shared: IntersectionObserver | undefined +let frame: number | undefined + +function lines(text: string): number { + if (!text) return 0 + let count = 1 + const cap = MAX_PLACEHOLDER_HEIGHT / ESTIMATED_LINE_HEIGHT + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) !== 10) continue + count++ + if (count >= cap) return cap + } + return count +} + +function release(node: Element) { + if (!shared) return + shared.unobserve(node) + if (watchers.size > 0) return + shared.disconnect() + shared = undefined +} + +function enqueue(job: Job) { + queue.push(job) + schedule() +} + +// When a large batch of diffs becomes near-visible at once, render one diff per +// animation frame. This keeps the UI responsive while preserving expanded state. +function schedule() { + if (frame !== undefined) return + frame = requestAnimationFrame(() => { + frame = undefined + const job = queue.shift() + if (job && !job.cancelled) job.run() + if (queue.length > 0) schedule() + }) +} + +// Defer Pierre's expensive DOM render until the diff is close to the viewport. +// The caller still mounts an expanded diff container immediately, but the body +// render is queued here so offscreen expanded diffs do not block worktree +// switches or message handling. +function observe(node: Element, cb: () => void): () => void { + if (typeof IntersectionObserver === "undefined") { + cb() + return () => {} + } + + const job: Job = { run: cb, cancelled: false } + + shared ??= new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue + const item = watchers.get(entry.target) + if (!item) continue + watchers.delete(entry.target) + release(entry.target) + enqueue(item) + } + }, + { rootMargin: OBSERVER_MARGIN }, + ) + + watchers.set(node, job) + shared.observe(node) + return () => { + job.cancelled = true + if (!watchers.delete(node)) return + release(node) + } +} + function findElement(node: Node | null): HTMLElement | undefined { if (!node) return if (node instanceof HTMLElement) return node @@ -68,6 +156,7 @@ export function Diff(props: DiffProps) { const [local, others] = splitProps(props, [ "before", "after", + "fileDiff", "class", "classList", "annotations", @@ -77,11 +166,25 @@ export function Diff(props: DiffProps) { ]) const mobile = createMediaQuery("(max-width: 640px)") + const [visible, setVisible] = createSignal(false) + + const before = createMemo(() => { + if (local.fileDiff) return local.fileDiff.deletionLines.join("") + return typeof local.before?.contents === "string" ? local.before.contents : "" + }) + const after = createMemo(() => { + if (local.fileDiff) return local.fileDiff.additionLines.join("") + return typeof local.after?.contents === "string" ? local.after.contents : "" + }) + + const estimate = createMemo(() => { + const value = Math.max(lines(before()), lines(after())) * ESTIMATED_LINE_HEIGHT + if (value === 0) return MIN_PLACEHOLDER_HEIGHT + return Math.max(MIN_PLACEHOLDER_HEIGHT, Math.min(value, MAX_PLACEHOLDER_HEIGHT)) + }) const large = createMemo(() => { - const before = typeof local.before?.contents === "string" ? local.before.contents : "" - const after = typeof local.after?.contents === "string" ? local.after.contents : "" - return Math.max(before.length, after.length) > 500_000 + return Math.max(before().length, after().length) > 500_000 }) const largeOptions = { @@ -119,6 +222,17 @@ export function Diff(props: DiffProps) { return result.virtualizer } + createEffect(() => { + if (visible()) return + container.style.minHeight = `${estimate()}px` + }) + + createEffect(() => { + if (visible()) return + const cleanup = observe(container, () => setVisible(true)) + onCleanup(cleanup) + }) + const getRoot = () => { const host = container.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return @@ -355,7 +469,10 @@ export function Diff(props: DiffProps) { const setSelectedLines = (range: SelectedLineRange | null) => { const active = current() - if (!active) return + if (!active) { + lastSelection = range + return + } const fixed = fixSelection(range) if (fixed === undefined) { @@ -563,16 +680,12 @@ export function Diff(props: DiffProps) { } createEffect(() => { + if (!visible()) return + const opts = options() const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) const virtualizer = getVirtualizer() - const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" - const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" - - const cacheKey = (contents: string) => { - if (!large()) return sampledChecksum(contents, contents.length) - return sampledChecksum(contents) - } + const annotations = untrack(() => local.annotations) // Preserve container height during re-render to prevent scroll jumps. // When Pierre tears down the DOM (innerHTML = ""), the container collapses @@ -588,20 +701,29 @@ export function Diff(props: DiffProps) { setCurrent(instance) container.innerHTML = "" - instance.render({ - oldFile: { - ...local.before, - contents: beforeContents, - cacheKey: cacheKey(beforeContents), - }, - newFile: { - ...local.after, - contents: afterContents, - cacheKey: cacheKey(afterContents), - }, - lineAnnotations: untrack(() => local.annotations), - containerWrapper: container, - }) + + if (local.fileDiff) { + instance.render({ + fileDiff: local.fileDiff, + lineAnnotations: annotations, + containerWrapper: container, + }) + } else { + const beforeContents = before() + const afterContents = after() + + const cacheKey = (contents: string) => { + if (!large()) return sampledChecksum(contents, contents.length) + return sampledChecksum(contents) + } + + instance.render({ + oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }, + newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }, + lineAnnotations: annotations, + containerWrapper: container, + }) + } applyScheme() patchSeparatorLayout() diff --git a/packages/kilo-ui/src/components/icon-button.tsx b/packages/kilo-ui/src/components/icon-button.tsx index 52c1b666f8..457283aa03 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 130949d4cf..ff3335bea0 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -1,9 +1,39 @@ 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: ``, + }, + "files-expand": { + viewBox: "0 0 16 16", + path: ``, + }, + "files-collapse": { + viewBox: "0 0 16 16", + path: ``, + }, } type Name = keyof typeof icons @@ -14,8 +44,7 @@ export interface IconProps extends Omit { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) - const name = local.name as Name - if (!(name in icons)) { + if (!((local.name as Name) in icons)) { return (
@@ -2064,19 +2119,33 @@ 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 canOpenDiff = () => !!data.openDiff && !!path() && (before() !== "" || after() !== "") + const view = createMemo(() => { + const diff = props.metadata?.filediff + if (diff?.patch) return normalize(diff) + // Pending state: tool-part metadata.filediff is written only after the + // permission ask completes, so render from the tool input in the meantime. + const before = props.input.oldString ?? "" + const after = props.input.newString ?? "" + if (!before && !after) return + return normalize({ + file: diff?.file ?? path(), + before, + after, + additions: diff?.additions ?? 0, + deletions: diff?.deletions ?? 0, + }) + }) + const canOpenDiff = () => !!data.openDiff && !!path() && !!view() const canOpenFile = () => !!data.openFile && !!path() const openDiff = () => { - if (!canOpenDiff()) return + const v = view() + if (!canOpenDiff() || !v) return data.openDiff!({ file: path(), - before: before(), - after: after(), - additions: props.metadata?.filediff?.additions ?? 0, - deletions: props.metadata?.filediff?.deletions ?? 0, + patch: v.patch, + additions: v.additions, + deletions: v.deletions, }) } @@ -2125,7 +2194,7 @@ ToolRegistry.register({
- +
- + + {(v) => ( + + )} +
@@ -2184,11 +2246,39 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) + const view = createMemo(() => { + const diff = props.metadata?.filediff + if (!diff?.patch) return + return normalize(diff) + }) + const canOpenDiff = () => !!data.openDiff && !!props.input.filePath && !!view() + const canOpenFile = () => !!data.openFile && !!props.input.filePath + + const openDiff = () => { + const v = view() + if (!data.openDiff || !props.input.filePath || !v) return + data.openDiff({ + file: props.metadata?.filediff?.file || props.input.filePath, + patch: v.patch, + additions: v.additions, + deletions: v.deletions, + }) + } 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 +2299,60 @@ ToolRegistry.register({ )} + + + + e.preventDefault()} + onClick={handleOpenDiffClick} + aria-label={i18n.t("ui.messagePart.openInDiffViewer")} + /> + + + } > - - + + + {(diff) => } + + } + >
- + + } + > + {(diff) => ( + + )} +
@@ -2246,9 +2367,8 @@ interface ApplyPatchFile { filePath: string relativePath: string type: "add" | "update" | "delete" | "move" + patch?: string diff: string - before?: string - after?: string additions: number deletions: number movePath?: string @@ -2261,6 +2381,16 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + const view = (file: ApplyPatchFile) => { + const patch = file.patch ?? file.diff + if (!patch) return + return normalize({ + file: file.relativePath, + patch, + additions: file.additions, + deletions: file.deletions, + }) + } const pending = createMemo(() => busy(props.status)) const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { @@ -2398,15 +2528,17 @@ ToolRegistry.register({ - -
- -
+ + {(diff) => ( +
+ +
+ )}
@@ -2446,15 +2578,17 @@ ToolRegistry.register({ } > - -
- -
+ + {(diff) => ( +
+ +
+ )}
)} diff --git a/packages/kilo-ui/src/components/session-diff.test.ts b/packages/kilo-ui/src/components/session-diff.test.ts new file mode 100644 index 0000000000..b576d28ffd --- /dev/null +++ b/packages/kilo-ui/src/components/session-diff.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import { normalize, text } from "./session-diff" + +describe("session diff", () => { + test("keeps unified patch content", () => { + const diff = { + file: "a.ts", + 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", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(view.fileDiff.name).toBe("a.ts") + expect(text(view, "deletions")).toBe("one\ntwo\n") + expect(text(view, "additions")).toBe("one\nthree\n") + }) + + test("converts legacy content into a patch", () => { + const diff = { + file: "a.ts", + before: "one\n", + after: "two\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toContain("@@ -1,1 +1,1 @@") + expect(text(view, "deletions")).toBe("one\n") + expect(text(view, "additions")).toBe("two\n") + }) + + test("preserves real line numbers from hunk headers without padding", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -340,2 +340,2 @@\n one\n-two\n+three\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.fileDiff.hunks[0]?.deletionStart).toBe(340) + expect(view.fileDiff.hunks[0]?.additionStart).toBe(340) + expect(view.fileDiff.isPartial).toBe(true) + // No blank-line padding: only the hunk lines are materialized. + expect(view.fileDiff.deletionLines.length).toBe(2) + expect(view.fileDiff.additionLines.length).toBe(2) + }) +}) diff --git a/packages/kilo-ui/src/components/session-diff.ts b/packages/kilo-ui/src/components/session-diff.ts index a6bea2ed92..de8528d076 100644 --- a/packages/kilo-ui/src/components/session-diff.ts +++ b/packages/kilo-ui/src/components/session-diff.ts @@ -1 +1,102 @@ -export { normalize, text, type ViewDiff } from "../../../ui/src/components/session-diff" +// Hunk-anchored diff normalization for the Kilo extension. Pure upstream +// `packages/ui/src/components/session-diff.ts` reconstructs full before/after +// strings, which loses the source line numbers from the hunk header. Here we +// instead let Pierre's `processFile` parse the patch directly so partial +// diffs render at their real file positions. +import { parseDiffFromFile, processFile, type FileDiffMetadata } from "@pierre/diffs" +import { formatPatch, parsePatch, structuredPatch } from "diff" +import type { SnapshotFileDiff, VcsFileDiff } from "@kilocode/sdk/v2" + +type LegacyDiff = { + file: string + patch?: string + before?: string + after?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff + +export type ViewDiff = { + file: string + patch: string + before: string + after: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + fileDiff: FileDiffMetadata +} + +const cache = new Map() + +// Reconstruct before/after strings from a patch by concatenating hunk lines. +function reconstruct(patch: string) { + const [parsed] = parsePatch(patch) + const before: string[] = [] + const after: string[] = [] + if (!parsed) return { before: "", after: "" } + for (const hunk of parsed.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("-")) before.push(line.slice(1)) + else if (line.startsWith("+")) after.push(line.slice(1)) + else { + before.push(line.slice(1)) + after.push(line.slice(1)) + } + } + } + return { before: before.join("\n") + "\n", after: after.join("\n") + "\n" } +} + +type DiffText = { before: string; after: string; patch: string } + +function contents(diff: ReviewDiff): DiffText { + if (typeof diff.patch === "string") { + return { ...reconstruct(diff.patch), patch: diff.patch } + } + const before = "before" in diff && typeof diff.before === "string" ? diff.before : "" + const after = "after" in diff && typeof diff.after === "string" ? diff.after : "" + const patch = formatPatch( + structuredPatch(diff.file, diff.file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }), + ) + return { before, after, patch } +} + +function fileDiffFor(diff: ReviewDiff, view: DiffText): FileDiffMetadata { + const hit = cache.get(view.patch) + if (hit) return hit + const fromPatch = typeof diff.patch === "string" ? processFile(diff.patch, { cacheKey: diff.patch }) : undefined + const value = + fromPatch ?? + parseDiffFromFile({ name: diff.file, contents: view.before }, { name: diff.file, contents: view.after }) + cache.set(view.patch, value) + return value +} + +export function normalize(diff: ReviewDiff): ViewDiff { + const view = contents(diff) + return { + file: diff.file, + patch: view.patch, + before: view.before, + after: view.after, + additions: diff.additions, + deletions: diff.deletions, + status: diff.status, + fileDiff: fileDiffFor(diff, view), + } +} + +export function text(diff: ViewDiff, side: "deletions" | "additions") { + const lines = side === "deletions" ? diff.fileDiff.deletionLines : diff.fileDiff.additionLines + const out = lines.join("") + // Pierre's processFile preserves the patch's trailing-newline state, so when + // a patch ends without `\n` the last line comes back without one too. + // Consumers (toSessionDiffFile, markdown export, openDiff payloads) expect + // file-shaped content with a trailing newline; normalize here. + if (out === "" || out.endsWith("\n")) return out + return out + "\n" +} diff --git a/packages/kilo-ui/src/pierre/index.ts b/packages/kilo-ui/src/pierre/index.ts index ad2f1af906..20b1ad7fd1 100644 --- a/packages/kilo-ui/src/pierre/index.ts +++ b/packages/kilo-ui/src/pierre/index.ts @@ -1 +1,37 @@ -export * from "@opencode-ai/ui/pierre" +import { + DiffLineAnnotation, + FileContents, + FileDiffMetadata, + FileDiffOptions, + type SelectedLineRange, +} from "@pierre/diffs" +import { ComponentProps } from "solid-js" + +export { createDefaultOptions, styleVariables } from "@opencode-ai/ui/pierre" + +// Extends upstream DiffProps with a `fileDiff` variant so Pierre can render +// a precomputed FileDiffMetadata directly. The pair (before/after) variant +// stays compatible with upstream usage. +type DiffShared = FileDiffOptions & { + annotations?: DiffLineAnnotation[] + selectedLines?: SelectedLineRange | null + commentedLines?: SelectedLineRange[] + onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void + onRendered?: () => void + class?: string + classList?: ComponentProps<"div">["classList"] +} + +type DiffPair = DiffShared & { + before: FileContents + after: FileContents + fileDiff?: undefined +} + +type DiffPatch = DiffShared & { + fileDiff: FileDiffMetadata + before?: undefined + after?: undefined +} + +export type DiffProps = DiffPair | DiffPatch diff --git a/packages/kilo-ui/src/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index 8c256487b1..5e1912f3af 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 daf3437ee8..c825105ff1 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,99 @@ # kilo-code +## 7.2.48 + +### Minor Changes + +- [#10054](https://github.com/Kilo-Org/kilocode/pull/10054) [`e5d8c4f`](https://github.com/Kilo-Org/kilocode/commit/e5d8c4f18d6abc81d0caa73575f3bf42a7c3b8fd) - Support commenting on rendered Markdown lines in diff reviewers. + +### Patch Changes + +- [#10066](https://github.com/Kilo-Org/kilocode/pull/10066) [`3cf5d15`](https://github.com/Kilo-Org/kilocode/commit/3cf5d151ff03f086db5882ce525be904a716e20b) - Open the Kilo Code sidebar before adding selected code or terminal output to chat context. + +- [#10051](https://github.com/Kilo-Org/kilocode/pull/10051) [`2d50e1f`](https://github.com/Kilo-Org/kilocode/commit/2d50e1f2dda5533196425b55e5915ee2a49334b6) - Harden git operations against malicious repositories and environment variables by upgrading the underlying git library. + +- [#10055](https://github.com/Kilo-Org/kilocode/pull/10055) [`f9fa1a7`](https://github.com/Kilo-Org/kilocode/commit/f9fa1a7efd46453ad7d603262c42c1f266dc4bc9) - Open reviewable Agent Manager diffs by default while keeping generated or extreme files collapsed. + +- [#10057](https://github.com/Kilo-Org/kilocode/pull/10057) [`05b10d9`](https://github.com/Kilo-Org/kilocode/commit/05b10d975dd2604eda5ef3583626bdb4b68255e2) - Improve the diff viewer bulk expand and collapse control so it reflects the current file state. + +- [#10064](https://github.com/Kilo-Org/kilocode/pull/10064) [`ebda89b`](https://github.com/Kilo-Org/kilocode/commit/ebda89b34ae8f46a99f5b492327bfc9e96dfb562) - Stop repeatedly reloading non-renderable Agent Manager diff previews. + +- [#10058](https://github.com/Kilo-Org/kilocode/pull/10058) [`eb8334d`](https://github.com/Kilo-Org/kilocode/commit/eb8334db016e7942ce3f99a55353e2e10cfadf39) - Keep Agent Manager promoted sessions aware of their worktree location. + +- [#10063](https://github.com/Kilo-Org/kilocode/pull/10063) [`d08238d`](https://github.com/Kilo-Org/kilocode/commit/d08238d5d12e66eaebf9872e653c35539c0ef65e) - Improve Changes diff responsiveness when switching worktrees by deferring offscreen diff rendering and ignoring stale diff updates. + +- [#10053](https://github.com/Kilo-Org/kilocode/pull/10053) [`09a7be8`](https://github.com/Kilo-Org/kilocode/commit/09a7be8fcdab92263b4c589523f2011fd086b0c3) - Improve @ file search relevance and keep mention suggestions responsive while typing. + +- [#10067](https://github.com/Kilo-Org/kilocode/pull/10067) [`c451d00`](https://github.com/Kilo-Org/kilocode/commit/c451d007e8050c114846ff86fce6470104954ade) - Keep Agent Manager review diffs responsive during worktree diff refreshes. + +- [#10059](https://github.com/Kilo-Org/kilocode/pull/10059) [`5456c96`](https://github.com/Kilo-Org/kilocode/commit/5456c962dd83631c7b1625d5adfaad2a3e0cf320) - Speed up Agent Manager worktree creation when refreshing the base branch by avoiding unnecessary tag fetches. + +- Updated dependencies [[`924f034`](https://github.com/Kilo-Org/kilocode/commit/924f034e12f3455f8cb69bb112541f887f4adfe5), [`05b10d9`](https://github.com/Kilo-Org/kilocode/commit/05b10d975dd2604eda5ef3583626bdb4b68255e2), [`d08238d`](https://github.com/Kilo-Org/kilocode/commit/d08238d5d12e66eaebf9872e653c35539c0ef65e)]: + - @kilocode/kilo-indexing@7.2.48 + - @kilocode/kilo-ui@7.2.48 + +## 7.2.47 + +### Minor Changes + +- [#9851](https://github.com/Kilo-Org/kilocode/pull/9851) [`9de7c98`](https://github.com/Kilo-Org/kilocode/commit/9de7c986e78683015631d14fabd513c3123ff330) - Support Kilo-hosted embeddings as a selectable code indexing provider. + +### Patch Changes + +- [#10016](https://github.com/Kilo-Org/kilocode/pull/10016) [`d2ae16a`](https://github.com/Kilo-Org/kilocode/commit/d2ae16a9216f0de6e1cb08950f739108515e7998) - Support configuring Azure OpenAI resource names or endpoint URLs from the provider settings flow, and document using the native Azure provider for GPT-5 family deployments. + +- [#10017](https://github.com/Kilo-Org/kilocode/pull/10017) [`d9560d8`](https://github.com/Kilo-Org/kilocode/commit/d9560d8feb1b55509098090d4c09a8e0eaf90823) - Fix the task cost shown at the top of the chat double-counting subagent costs. Sessions that spawned subagents were overreporting their totals because the backend already rolls descendant costs up into the parent session, and the webview was then summing them again. + +- [#10015](https://github.com/Kilo-Org/kilocode/pull/10015) [`556b5e4`](https://github.com/Kilo-Org/kilocode/commit/556b5e47e0a2eeb037bd5e950451688362485e2b) - Collapse the Auto-Approve Exceptions list by default when it has more than 5 entries. + +- [#10008](https://github.com/Kilo-Org/kilocode/pull/10008) [`f539f59`](https://github.com/Kilo-Org/kilocode/commit/f539f59210e79a1e028ea6b2021283f7f9f89bf2) - Show an "Open in Diff Viewer" icon on Write tool calls (matching the Edit tool), so the changes can be inspected in a full diff tab. + +## 7.2.44 + +### Minor Changes + +- [#9857](https://github.com/Kilo-Org/kilocode/pull/9857) [`6fe38e4`](https://github.com/Kilo-Org/kilocode/commit/6fe38e47bc96b8649a97cd183fca602ddd7038e6) - Support copying, previewing, and exporting rendered Mermaid diagrams. + +- [#9968](https://github.com/Kilo-Org/kilocode/pull/9968) [`bf0c4df`](https://github.com/Kilo-Org/kilocode/commit/bf0c4df9d3067339e3438d51bf6ce2ff77d75092) - Add a searchable current-tabs menu to Agent Manager. + +- [#9764](https://github.com/Kilo-Org/kilocode/pull/9764) [`9886674`](https://github.com/Kilo-Org/kilocode/commit/98866740afd7f6c2fd06fecda1ffc69c1703974e) - Migrate KiloClaw chat to the new kilo-chat backend. Replaces the single-channel Stream Chat integration with a multi-conversation experience that matches the web UX at app.kilo.ai/claw/kilo-chat: conversation list, reactions, typing indicators, editing, and action approvals. The TUI continues to render a single chat view backed by the user's primary conversation. + +- [#9718](https://github.com/Kilo-Org/kilocode/pull/9718) [`dcaccf3`](https://github.com/Kilo-Org/kilocode/commit/dcaccf38658415819b72390255b9f6555e4795e5) - Rate assistant responses with thumbs up/down. Click the thumbs buttons next to the copy button on any assistant message, or press `=` / `-` in the terminal UI. Only shown when telemetry is enabled; feedback is sent to Kilo to help improve model and prompt quality. + +- [#9897](https://github.com/Kilo-Org/kilocode/pull/9897) [`9c581d3`](https://github.com/Kilo-Org/kilocode/commit/9c581d39926688d2c1698afb540ae7670763a48d) - Unify the Changes panel into a single tab with a source dropdown to switch between workspace and current-session diffs. Show the session's additions and deletions on the sidebar changes badge, and warn in the panel when snapshots are disabled for the repository. + +### Patch Changes + +- [#9971](https://github.com/Kilo-Org/kilocode/pull/9971) [`de58267`](https://github.com/Kilo-Org/kilocode/commit/de58267b4026f25b21560f85b334a7fdd00b2d48) - Keep Agent Manager branch, mode, and model selectors usable in the new worktree dialog. + +- [#9989](https://github.com/Kilo-Org/kilocode/pull/9989) [`c46b40a`](https://github.com/Kilo-Org/kilocode/commit/c46b40ac1799bbbfae159b1d57205dc4903eff1b) - Show the autocomplete model selector with the same picker layout as other model selectors and save changes from the settings save bar. + +- [#10004](https://github.com/Kilo-Org/kilocode/pull/10004) [`6bb0f82`](https://github.com/Kilo-Org/kilocode/commit/6bb0f8261cadb91783f712b892efea17003617d7) - Keep Agent Manager worktree recovery metadata out of worktree git changes. + +- [#9970](https://github.com/Kilo-Org/kilocode/pull/9970) [`43204b6`](https://github.com/Kilo-Org/kilocode/commit/43204b680e82921b3139367f2e0fba3698d3901d) - Make Agent Manager session tabs compact, status-aware, and easier to scan when many tabs are open. + +- [#9915](https://github.com/Kilo-Org/kilocode/pull/9915) [`bcb47be`](https://github.com/Kilo-Org/kilocode/commit/bcb47be3b0cf71990fd3ee1ec562a716aefe3571) - Preserve the selected thinking level after compacting a session. + +- [#9688](https://github.com/Kilo-Org/kilocode/pull/9688) [`3095efc`](https://github.com/Kilo-Org/kilocode/commit/3095efcc4c141a2532f48dc77b38cf0309125c82) - Restore inline diff previews after session reloads while keeping large file contents stripped from VS Code webview messages. + +- [#9993](https://github.com/Kilo-Org/kilocode/pull/9993) [`98f5f65`](https://github.com/Kilo-Org/kilocode/commit/98f5f65c1a8a543687ae5b308805eec1a2c23dca) - Support global and per-project codebase indexing enablement. + +- [#9961](https://github.com/Kilo-Org/kilocode/pull/9961) [`aec2425`](https://github.com/Kilo-Org/kilocode/commit/aec2425f6e116ab6657d3ae048085073deec70b9) - Update regular Kilo editor tab titles to follow the current session title. + +- [#9991](https://github.com/Kilo-Org/kilocode/pull/9991) [`fa326f2`](https://github.com/Kilo-Org/kilocode/commit/fa326f21e33a8cb1a81b5e32a7735e96a66bef8a) - Support accepting command permission prompts with Enter and denying them with Escape. + +- [#9921](https://github.com/Kilo-Org/kilocode/pull/9921) [`e5e9d0b`](https://github.com/Kilo-Org/kilocode/commit/e5e9d0ba37bd1065aea5a9a83834c6749121e5bd) - Remove custom providers from settings when disconnecting them so they do not reappear after being disabled and re-enabled. + +- [#9922](https://github.com/Kilo-Org/kilocode/pull/9922) [`5fcc423`](https://github.com/Kilo-Org/kilocode/commit/5fcc4231c0a716e1012475b01ce2d77f99fdbe87) - Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. + +- [#9917](https://github.com/Kilo-Org/kilocode/pull/9917) [`42e43c4`](https://github.com/Kilo-Org/kilocode/commit/42e43c4e44a5ae783ca6eae51c05d28c2ef3cedf) - Capture the active VS Code terminal when using `@terminal` in Agent Manager. + +- Updated dependencies [[`3095efc`](https://github.com/Kilo-Org/kilocode/commit/3095efcc4c141a2532f48dc77b38cf0309125c82), [`9886674`](https://github.com/Kilo-Org/kilocode/commit/98866740afd7f6c2fd06fecda1ffc69c1703974e), [`e5e9d0b`](https://github.com/Kilo-Org/kilocode/commit/e5e9d0ba37bd1065aea5a9a83834c6749121e5bd)]: + - @opencode-ai/ui@7.2.43 + - @kilocode/sdk@7.3.0 + - @kilocode/kilo-ui@7.2.43 + - @kilocode/kilo-indexing@7.2.43 + ## 7.2.42 ### Minor Changes diff --git a/packages/kilo-vscode/README.md b/packages/kilo-vscode/README.md index 40687268e9..328d3f5d62 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 c1d89d6735..ebb9764ef6 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 7f8125e5df..0914b0366f 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", 3500] }, + 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 53485a2738..ccc67e6495 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.48", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -927,6 +927,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "fuzzysort": "3.1.0", + "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -945,9 +947,10 @@ "ignore": "^7.0.3", "js-tiktoken": "^1.0.18", "lru-cache": "^11.0.2", + "marked": "catalog:", "openai": "^4.85.4", "quick-lru": "^7.0.0", - "simple-git": "3.35.2", + "simple-git": "3.36.0", "solid-js": "^1.9.11", "uri-js": "^4.4.1", "virtua": "catalog:", diff --git a/packages/kilo-vscode/src/DiffVirtualProvider.ts b/packages/kilo-vscode/src/DiffVirtualProvider.ts index 0e2954bbf7..90a170559d 100644 --- a/packages/kilo-vscode/src/DiffVirtualProvider.ts +++ b/packages/kilo-vscode/src/DiffVirtualProvider.ts @@ -6,8 +6,7 @@ import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" export interface DiffVirtualFile { file: string - before: string - after: string + patch?: string additions: number deletions: number initialDiffStyle: "unified" | "split" diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index e226602ecd..cf3c33479e 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" @@ -124,6 +132,7 @@ import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" +import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" // Helper to map agent data to the subset of fields sent to the webview @@ -169,8 +178,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null + private cachedGlobalConfig: Config | null = null /** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */ private cachedIndexingStatusMessage: unknown = null + /** Cached kiloEmbeddingModelsLoaded payload so requestKiloEmbeddingModels is resilient offline. */ + private cachedKiloEmbeddingModelsMessage: unknown = null /** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */ private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ @@ -216,6 +228,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 @@ -357,10 +370,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) { @@ -581,6 +592,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) + this.telemetryStateDisposable?.dispose() + this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { const intercepted = await interceptMessage(message, { workspaceDir: (sid) => this.getWorkspaceDirectory(sid ?? this.currentSession?.id), @@ -599,8 +612,8 @@ 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: (sessionId?: string) => - vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId }), + 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) @@ -744,7 +757,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) => @@ -757,6 +769,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) @@ -852,6 +866,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] fetchAndSendIndexingStatus failed:", e), ) break + case "requestKiloEmbeddingModels": + this.fetchAndSendKiloEmbeddingModels().catch((e) => + console.error("[Kilo New] fetchAndSendKiloEmbeddingModels failed:", e), + ) + break case "updateConfig": await this.handleUpdateConfig(message.config, message.projectConfig) break @@ -1040,7 +1059,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({ @@ -1713,13 +1733,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) @@ -2066,10 +2090,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 @@ -2084,6 +2111,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) @@ -2123,6 +2151,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async fetchAndSendKiloEmbeddingModels(): Promise { + const catalog = await fetchKiloEmbeddingModelCatalog() + const message = { type: "kiloEmbeddingModelsLoaded", catalog } + this.cachedKiloEmbeddingModelsMessage = message + this.postMessage(message) + } + /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see @@ -2147,8 +2182,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) } @@ -2332,8 +2374,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) @@ -2348,6 +2402,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 { @@ -2505,14 +2560,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } parts.push({ type: "text", text }) - const editorContext = await this.gatherEditorContext() - - if (messageID) { - this.connectionService.recordMessageSessionId(messageID, resolved!.sid) - } - const sid = resolved!.sid const dir = resolved!.dir + const editorContext = await this.gatherEditorContext(dir) + + if (messageID) { + this.connectionService.recordMessageSessionId(messageID, sid) + } + await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Message request", () => this.withRetry( () => @@ -2864,6 +2919,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) } @@ -3197,8 +3253,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return controller } - private async gatherEditorContext(): Promise { - const workspaceDir = this.getWorkspaceDirectory() + private async gatherEditorContext(dir?: string): Promise { + const workspaceDir = dir ?? this.getWorkspaceDirectory() const controller = await this.getIgnoreController(workspaceDir) const toRelative = (fsPath: string): string | undefined => { @@ -3404,6 +3460,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 309bc46626..4890788eae 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -29,6 +29,7 @@ import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" import { WorktreeImporter } from "./worktree-importer" +import { recordPromotionHandoff } from "./promotion-handoff" import { restoreWorktrees } from "./state-recovery" import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff" import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" @@ -417,7 +418,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 +482,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 @@ -1016,11 +1018,25 @@ export class AgentManagerProvider implements Disposable { } this.registerWorktreeSession(sessionId, created.result.path) + await this.recordPromotionHandoff(sessionId, created.result.path, created.result.branch) this.notifyWorktreeReady(sessionId, created.result, created.worktree.id) this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`) return null } + private async recordPromotionHandoff(sessionId: string, dir: string, branch: string): Promise { + try { + await recordPromotionHandoff({ + client: this.connectionService.getClient(), + sessionId, + directory: dir, + branch, + }) + } catch (err) { + this.log("Failed to record worktree promotion handoff:", getErrorMessage(err)) + } + } + /** Add a new session to an existing worktree. */ private async onAddSessionToWorktree(worktreeId: string): Promise { let client: KiloClient diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 0f0ff9a204..c490a57641 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -3,7 +3,13 @@ import * as os from "os" import * as fs from "fs/promises" import { spawn } from "../util/process" import simpleGit from "simple-git" -import { parseWorktreeList, normalizePath } from "./git-import" +import { + parseWorktreeList, + normalizePath, + parseForEachRefOutput, + buildBranchList, + type BranchListItem, +} from "./git-import" import type { Semaphore } from "./semaphore" interface GitOpsOptions { @@ -42,6 +48,14 @@ export interface ExecResult { stderr: string } +/** + * Fixed SSH command injected by {@link nonInteractiveEnv} when the user has + * not already configured their own. Exported so callers can check whether a + * `GIT_SSH_COMMAND` originated from Kilo (safe) or was inherited from the + * parent process (untrusted). + */ +export const KILO_NON_INTERACTIVE_SSH_COMMAND = "ssh -o BatchMode=yes" + /** * Build environment variables that prevent git and SSH from opening interactive * prompts. Used for background operations (e.g. periodic fetch) so users with @@ -57,11 +71,20 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv { GIT_TERMINAL_PROMPT: "0", } if (!process.env.GIT_SSH_COMMAND) { - env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes" + env.GIT_SSH_COMMAND = KILO_NON_INTERACTIVE_SSH_COMMAND } return env } +/** + * True when `env.GIT_SSH_COMMAND` is the fixed value Kilo sets, rather than + * an inherited one from the parent process. Use this to decide whether it's + * safe to pass `allowUnsafeSshCommand: true` to simple-git. + */ +export function isKiloOwnedSshCommand(env: NodeJS.ProcessEnv): boolean { + return env.GIT_SSH_COMMAND === KILO_NON_INTERACTIVE_SSH_COMMAND +} + export class GitOps { private readonly log: (...args: unknown[]) => void private readonly runGit: (args: string[], cwd: string) => Promise @@ -219,6 +242,31 @@ export class GitOps { .catch(() => false) } + /** + * List local branches and `origin/*` remotes sorted by last commit date, + * with the resolved default branch flagged. Mirrors WorktreeManager's + * `listBranches` shape but takes `cwd` per call so it works outside the + * worktree context (e.g. for the diff viewer's base branch picker). + */ + async listBranches(cwd: string): Promise<{ branches: BranchListItem[]; defaultBranch: string }> { + const def = (await this.resolveDefaultBranch(cwd)) ?? "" + const raw = await this.raw( + [ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)\t%(committerdate:iso-strict)", + "refs/heads/", + "refs/remotes/origin/", + ], + cwd, + ).catch((err) => { + this.log("listBranches: for-each-ref failed", err instanceof Error ? err.message : String(err)) + return "" + }) + const { locals, remotes, dates } = parseForEachRefOutput(raw) + return { branches: buildBranchList(locals, remotes, dates, def), defaultBranch: def } + } + /** Return the set of worktree paths for the repo, excluding bare entries. */ async listWorktreePaths(cwd: string): Promise> { const raw = await this.raw(["worktree", "list", "--porcelain"], cwd) diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 63e310f8bc..189045d5a7 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 7ed0c1e011..ae1d77e05d 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -11,7 +11,7 @@ import * as fs from "fs" import { randomUUID } from "crypto" import simpleGit, { type SimpleGit } from "simple-git" import { generateBranchName, sanitizeBranchName } from "./branch-name" -import { type GitOps, nonInteractiveEnv } from "./GitOps" +import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps" import { execWithShellEnv } from "./shell-env" import { markNoIndex } from "../util/spotlight" import { @@ -86,6 +86,7 @@ import { KILO_DIR, LEGACY_DIR, migrateAgentManagerData } from "./constants" const SESSION_ID_FILE = "session-id" const METADATA_FILE = "metadata.json" +const GIT_METADATA_FILE = "kilo-agent-manager-metadata.json" export class WorktreeManager { private readonly root: string @@ -438,24 +439,21 @@ export class WorktreeManager { } async writeMetadata(worktreePath: string, sessionId: string, parentBranch: string, remote?: string): Promise { - const dir = path.join(worktreePath, KILO_DIR) - if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }) - const meta: Record = { sessionId, parentBranch } if (remote) meta.remote = remote - // Write both formats: session-id for backward compat, metadata.json for parentBranch+remote - await Promise.all([ - fs.promises.writeFile(path.join(dir, SESSION_ID_FILE), sessionId, "utf-8"), - fs.promises.writeFile(path.join(dir, METADATA_FILE), JSON.stringify(meta), "utf-8"), - ]) + const file = await this.gitMetadataPath(worktreePath) + if (!file) throw new Error(`Could not resolve git metadata directory for ${worktreePath}`) + await fs.promises.writeFile(file, JSON.stringify(meta), "utf-8") this.log(`Wrote metadata for session ${sessionId} to ${worktreePath}`) - await this.ensureWorktreeExclude(worktreePath) } async readMetadata( worktreePath: string, ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + const current = await this.readCurrentMetadata(worktreePath) + if (current) return current + // Check .kilo/ first, then legacy .kilocode/ for (const dirName of [KILO_DIR, LEGACY_DIR]) { const result = await this.readMetadataFrom(worktreePath, dirName) @@ -464,6 +462,44 @@ export class WorktreeManager { return undefined } + private async readCurrentMetadata( + worktreePath: string, + ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + try { + const file = await this.gitMetadataPath(worktreePath) + if (!file) return undefined + const content = await fs.promises.readFile(file, "utf-8") + const data = JSON.parse(content) as { sessionId?: string; parentBranch?: string; remote?: string } + if (!data.sessionId) return undefined + return { + sessionId: data.sessionId, + parentBranch: data.parentBranch, + remote: data.remote, + } + } catch (e) { + this.log(`readMetadata: git metadata unreadable in ${worktreePath}: ${e}`) + return undefined + } + } + + private async gitMetadataPath(worktreePath: string): Promise { + const dir = await this.worktreeGitDir(worktreePath) + if (!dir) return undefined + return path.join(dir, GIT_METADATA_FILE) + } + + private async worktreeGitDir(worktreePath: string): Promise { + const gitPath = path.join(worktreePath, ".git") + const stat = await fs.promises.stat(gitPath) + if (stat.isDirectory()) return gitPath + if (!stat.isFile()) return undefined + + const content = await fs.promises.readFile(gitPath, "utf-8") + const match = content.match(/^gitdir:\s*(.+)$/m) + if (!match) return undefined + return path.resolve(worktreePath, match[1].trim()) + } + private async readMetadataFrom( worktreePath: string, dirName: string, @@ -526,20 +562,6 @@ export class WorktreeManager { } } - private async ensureWorktreeExclude(worktreePath: string): Promise { - try { - const content = await fs.promises.readFile(path.join(worktreePath, ".git"), "utf-8") - const match = content.match(/^gitdir:\s*(.+)$/m) - if (!match) return - - const worktreeGitDir = path.resolve(worktreePath, match[1].trim()) - const mainGitDir = path.dirname(path.dirname(worktreeGitDir)) - await this.addExcludeEntry(path.join(mainGitDir, "info", "exclude"), `${KILO_DIR}/`, "Kilo Code session metadata") - } catch (error) { - this.log(`Warning: Failed to update git exclude for worktree: ${error}`) - } - } - /** * Returns true when target is strictly inside the managed worktrees directory. * Prevents sibling-prefix confusion such as "/worktrees-evil". @@ -669,7 +691,13 @@ export class WorktreeManager { // Use non-interactive env to prevent SSH passphrase popups. onProgress?.("fetching", `Fetching ${remote}/${branch}...`) try { - await simpleGit(this.root).env(nonInteractiveEnv()).fetch(remote, branch) + // Only opt into simple-git's allowUnsafeSshCommand when the SSH command + // is the fixed value Kilo injects — never for an inherited one, which + // could be attacker-controlled. + const env = nonInteractiveEnv() + await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } }) + .env(env) + .fetch(remote, branch, { "--quiet": null, "--no-tags": null }) WorktreeManager.fetchCache.set(cacheKey, Date.now()) if (await this.refExistsLocally(`${remote}/${branch}`)) { return { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 2bbe74ce35..74fea89b55 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -44,7 +44,7 @@ export interface Section { name: string /** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */ color: string | null - /** Position among top-level sidebar children (sections and ungrouped worktrees). */ + /** Position among sections. Ungrouped worktrees are always rendered above sections. */ order: number collapsed: boolean } @@ -334,6 +334,14 @@ export class WorktreeStateManager { void this.save() } + private ordered(order: string[]): string[] { + const idx = new Map(order.map((id, i) => [id, i] as const)) + return [...this.worktrees.values()] + .filter((wt) => !wt.sectionId) + .sort((a, b) => (idx.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (idx.get(b.id) ?? Number.MAX_SAFE_INTEGER)) + .map((wt) => wt.id) + } + private setNormalizedWorktreeOrder(order: string[]): boolean { const valid = new Set() for (const sec of this.sections.values()) valid.add(sec.id) @@ -351,17 +359,22 @@ export class WorktreeStateManager { for (const sec of [...this.sections.values()].sort((a, b) => a.order - b.order)) add(sec.id) for (const wt of this.worktrees.values()) add(wt.id) + const normalized = [ + ...this.ordered(result), + ...result.filter((id) => this.sections.has(id)), + ...result.filter((id) => this.worktrees.get(id)?.sectionId), + ] + const changed = - result.length !== this.worktreeOrder.length || result.some((id, idx) => id !== this.worktreeOrder[idx]) - this.worktreeOrder = result + normalized.length !== this.worktreeOrder.length || normalized.some((id, idx) => id !== this.worktreeOrder[idx]) + this.worktreeOrder = normalized return this.syncSectionOrder() || changed } private syncSectionOrder(): boolean { const top = this.worktreeOrder.filter((id) => { if (this.sections.has(id)) return true - const wt = this.worktrees.get(id) - return !!wt && !wt.sectionId + return false }) const index = new Map(top.map((id, idx) => [id, idx] as const)) const changes = [...this.sections.values()].map((sec) => { diff --git a/packages/kilo-vscode/src/agent-manager/promotion-handoff.ts b/packages/kilo-vscode/src/agent-manager/promotion-handoff.ts new file mode 100644 index 0000000000..31d20870b5 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/promotion-handoff.ts @@ -0,0 +1,35 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export interface PromoteHandoffInput { + client: KiloClient + sessionId: string + directory: string + branch: string +} + +export function handoffText(input: Omit): string { + return [ + "", + "This session was moved to a git worktree.", + `Use this as the current working directory: ${input.directory}`, + `The worktree branch is: ${input.branch}`, + "", + ].join("\n") +} + +export async function recordPromotionHandoff(input: PromoteHandoffInput): Promise { + const payload = { + sessionID: input.sessionId, + directory: input.directory, + noReply: true, + parts: [ + { + type: "text", + text: handoffText(input), + synthetic: true, + }, + ], + } as Parameters[0] + + await input.client.session.promptAsync(payload, { throwOnError: true }) +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 765442070b..ef52f68ad1 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 4939d703ac..94625e0544 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,8 +1,9 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import { hashFileDiffs } from "../diff/shared/hash" +import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" -import { DIFF_POLL_INTERVAL_MS } from "../diff/polling" import { WorktreeDiffClient } from "../diff/shared/client" +import type { DiffFile } from "../diff/types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" @@ -12,13 +13,15 @@ const LOCAL_DIFF_ID = "local" as const type Target = { sessionId: string; directory: string; baseBranch: string } +type AgentManagerDiffFile = DiffFile & WorktreeDiffEntry + export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined /** * SDK client — used by `revert()` via `WorktreeDiffClient` for the one-shot - * file-status lookup. Hot polling paths (`request`, `requestFile`, `poll`) + * file-status lookup. Hot polling paths (`request`, `requestFile`, polling) * deliberately bypass the client and go through `localDiff`/`localDiffFile` * to keep git spawns out of the Bun `kilo serve` process (see oven-sh/bun#18265). */ @@ -33,16 +36,53 @@ export interface WorktreeDiffControllerContext { } export class WorktreeDiffController { - private interval: ReturnType | undefined - private session: string | undefined - private hash: string | undefined + private readonly controller: SourceController private target: Target | undefined private applying: string | undefined - constructor(private readonly ctx: WorktreeDiffControllerContext) {} + constructor(private readonly ctx: WorktreeDiffControllerContext) { + this.controller = new SourceController( + (id) => this.source(id), + () => [], + (msg) => this.ctx.post(msg as AgentManagerOutMessage), + { + loading: (source, loading) => ({ + type: "agentManager.worktreeDiffLoading", + sessionId: source.descriptor.id, + loading, + }), + diffs: (source, diffs) => ({ + type: "agentManager.worktreeDiff", + sessionId: source.descriptor.id, + diffs: diffs as AgentManagerDiffFile[], + }), + diffFile: (source, file, diff) => ({ + type: "agentManager.worktreeDiffFile", + sessionId: source?.descriptor.id ?? "", + file, + diff: diff as AgentManagerDiffFile | null, + }), + revertFileResult: (source, file, result) => ({ + type: "agentManager.revertWorktreeFileResult", + sessionId: source?.descriptor.id ?? "", + file, + status: result.ok ? "success" : "error", + message: result.message, + }), + unsupportedRevert: (source, file) => ({ + type: "agentManager.revertWorktreeFileResult", + sessionId: source?.descriptor.id ?? "", + file, + status: "error", + message: "Revert is not supported for the current source", + }), + }, + ) + this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) + } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.session) + return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -108,128 +148,47 @@ export class WorktreeDiffController { public async revert(sessionId: string, file: string): Promise { if (!file) return - await this.ready("stateReady rejected, continuing revert resolve:") - - const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId) - if (!target) { - this.ctx.post({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: "error", - message: "Could not resolve diff target", - }) + if (this.controller.currentId !== sessionId) { + const result = await this.revertFile(sessionId, file) + this.postRevertResult(sessionId, file, result) return } - - try { - const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) - const result = await diff.revertFile(target, file) - this.ctx.post({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: result.ok ? "success" : "error", - message: result.message, - }) - - if (result.ok) void this.request(sessionId) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - this.ctx.log("Failed to revert worktree file:", msg) - this.ctx.post({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: "error", - message: msg, - }) - } + await this.controller.revertFile(file) } public async request(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - - const target = await this.resolve(sessionId) - if (!target) return - - this.target = { sessionId, ...target } - this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) - - try { - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - this.hash = hashFileDiffs(files) - this.session = sessionId - this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) - } catch (error) { - this.ctx.log("Failed to fetch worktree diff:", error) - } finally { - this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: false }) + if (this.controller.currentId !== sessionId) { + await this.activate(sessionId, false, true) + return } + this.target = undefined + await this.controller.refresh() } public async requestFile(sessionId: string, file: string): Promise { if (!file) return - await this.ready("stateReady rejected, continuing diff detail resolve:") - - const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId) - if (!target) return - - this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch } - - try { - const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file) - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data }) - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) + if (this.controller.currentId !== sessionId) { this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + return } + await this.controller.requestFile(file) } public start(sessionId: string): void { - if (this.session === sessionId && this.interval) { - this.ctx.log(`Already polling session ${sessionId}, skipping restart`) - return - } - - this.stop() - this.session = sessionId - this.hash = undefined + if (this.controller.isPolling && this.controller.currentId === sessionId) return this.ctx.log(`Starting diff polling for session ${sessionId}`) - - void this.request(sessionId).then(() => { - if (this.session !== sessionId) return - this.interval = setInterval(() => { - void this.poll(sessionId) - }, DIFF_POLL_INTERVAL_MS) - }) + void this.activate(sessionId, true, true) } public stop(): void { - if (this.interval) { - clearInterval(this.interval) - this.interval = undefined - } - this.session = undefined - this.hash = undefined + this.controller.stop() this.target = undefined } - private async poll(sessionId: string): Promise { - const target = this.target?.sessionId === sessionId ? this.target : undefined - if (!target) return - - try { - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - const hash = hashFileDiffs(files) - if (hash === this.hash && this.session === sessionId) return - this.hash = hash - this.session = sessionId - this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) - } catch (error) { - this.ctx.log("Failed to poll worktree diff:", error) - } + private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + this.target = undefined + this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) + await this.controller.activate(sessionId, { poll, fetch }) } private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { @@ -268,6 +227,83 @@ export class WorktreeDiffController { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } + private source(sessionId: string): DiffSource { + const descriptor: DiffSourceDescriptor = { + id: sessionId, + type: "workspace", + group: "Git", + capabilities: { revert: true, comments: true }, + } + + return { + descriptor, + fetch: () => this.fetch(sessionId), + fetchFile: (file) => this.fetchFile(sessionId, file), + revert: (file) => this.revertFile(sessionId, file), + } + } + + private async fetch(sessionId: string): Promise { + await this.ready("stateReady rejected, continuing diff resolve:") + const target = await this.ensureTarget(sessionId) + if (!target) return { diffs: [], stopPolling: true } + + const files = await this.ctx.localDiff(target.directory, target.baseBranch) + this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) + return { diffs: files as AgentManagerDiffFile[] } + } + + private async fetchFile(sessionId: string, file: string): Promise { + await this.ready("stateReady rejected, continuing diff detail resolve:") + const target = await this.ensureTarget(sessionId) + if (!target) return null + + try { + return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null + } catch (error) { + this.ctx.log("Failed to fetch worktree diff file:", error) + return null + } + } + + private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + await this.ready("stateReady rejected, continuing revert resolve:") + const target = await this.resolveTarget(sessionId) + if (!target) return { ok: false, message: "Could not resolve diff target" } + + try { + const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) + return await diff.revertFile(target, file) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.ctx.log("Failed to revert worktree file:", msg) + return { ok: false, message: msg } + } + } + + private async ensureTarget(sessionId: string): Promise { + if (this.controller.currentId !== sessionId) return undefined + if (this.target?.sessionId === sessionId) return this.target + return await this.resolveTarget(sessionId) + } + + private async resolveTarget(sessionId: string): Promise { + const target = await this.resolve(sessionId) + if (!target) return undefined + this.target = { sessionId, ...target } + return this.target + } + + private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { + this.ctx.post({ + type: "agentManager.revertWorktreeFileResult", + sessionId, + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + } + private postApplyResult( worktreeId: string, status: "checking" | "applying" | "success" | "conflict" | "error", diff --git a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts index 6bc6ba5470..476cdb9203 100644 --- a/packages/kilo-vscode/src/diff/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/diff/DiffViewerProvider.ts @@ -5,6 +5,7 @@ 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" @@ -28,6 +29,7 @@ export class DiffViewerProvider implements vscode.Disposable { private panelDisposables: vscode.Disposable[] = [] private commentHandler: CommentHandler | undefined private fontConfigDisposable: vscode.Disposable | undefined + private baseBranchOverride: string | undefined private readonly sessionIdProvider: () => string | undefined private readonly output: vscode.OutputChannel @@ -46,12 +48,12 @@ export class DiffViewerProvider implements vscode.Disposable { } openPanel(ctx: PanelContext): void { - this.ctx = ctx + this.ctx = { ...ctx, baseBranchOverride: this.baseBranchOverride } if (this.panel && this.controller) { this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.One) - this.controller.setContext(ctx) - const nextId = this.catalog.defaultSourceId(ctx) + this.controller.setContext(this.ctx) + const nextId = this.catalog.defaultSourceId(this.ctx) if (nextId && nextId !== this.controller.currentId) this.swap(nextId) return } @@ -63,12 +65,19 @@ export class DiffViewerProvider implements vscode.Disposable { * 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; initialSourceId?: string }): void { + 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: arg?.sessionId ?? this.sessionIdProvider(), - initialSourceId: arg?.initialSourceId, + sessionId, + initialSourceId: turnInitialSourceId ?? arg?.initialSourceId, + hidePicker: !!turnInitialSourceId, }) } @@ -124,6 +133,7 @@ export class DiffViewerProvider implements vscode.Disposable { this.controller = undefined this.fontConfigDisposable?.dispose() this.fontConfigDisposable = undefined + this.baseBranchOverride = undefined this.disposePanel() } @@ -157,12 +167,44 @@ export class DiffViewerProvider implements vscode.Disposable { "diffViewer.requestFile": (msg) => { if (typeof msg.file === "string") void this.controller?.requestFile(msg.file) }, + "diffViewer.requestBranches": () => { + void this.sendBranches() + }, + "diffViewer.setBaseBranch": (msg) => { + const branch = typeof msg.branch === "string" && msg.branch.length > 0 ? msg.branch : undefined + this.baseBranchOverride = branch + if (this.ctx) { + this.ctx = { ...this.ctx, baseBranchOverride: branch } + this.controller?.setContext(this.ctx) + } + void this.controller?.reactivate() + void this.sendBranches() + }, openFile: (msg) => { if (typeof msg.filePath !== "string") return openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) }, } + private async sendBranches(): Promise { + if (!this.panel) return + try { + const result = await this.catalog.listWorkspaceBranches(this.baseBranchOverride) + if (!result || !this.panel) return + void this.panel.webview.postMessage({ + type: "diffViewer.branches", + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } catch (err) { + this.log("Failed to list workspace branches:", err instanceof Error ? err.message : String(err)) + } + } + private onWebviewReady(): void { if (!this.panel) return void this.panel.webview.postMessage({ diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 4433cab1dd..47d1a77147 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -1,27 +1,78 @@ -import type * as vscode from "vscode" -import type { DiffSource, DiffSourceDescriptor, DiffSourceMessage, DiffSourcePost } from "./sources/types" +import { hashFileDiffs } from "./shared/hash" +import { DIFF_POLL_INTERVAL_MS } from "./polling" +import type { DiffSource, DiffSourceCapabilities, DiffSourceDescriptor, DiffSourceNotice } from "./sources/types" +import type { DiffFile } from "./types" import type { PanelContext } from "./types" +type Messages = { + available?: (descriptors: DiffSourceDescriptor[], id: string) => unknown + capabilities?: (capabilities: DiffSourceCapabilities) => unknown + loading?: (source: DiffSource, loading: boolean) => unknown + diffs: (source: DiffSource, diffs: DiffFile[]) => unknown + notice?: (source: DiffSource, notice: DiffSourceNotice | undefined) => unknown + diffFile: (source: DiffSource | undefined, file: string, diff: DiffFile | null) => unknown + revertFileResult: (source: DiffSource | undefined, file: string, result: { ok: boolean; message: string }) => unknown + unsupportedRevert: (source: DiffSource | undefined, file: string) => unknown +} + +type ActivateOptions = { poll?: boolean; fetch?: boolean } + +const viewerMessages: Messages = { + available: (descriptors, id) => ({ + type: "setAvailableSources", + descriptors, + currentId: id, + }), + capabilities: (capabilities) => ({ + type: "diffViewer.capabilities", + capabilities, + }), + loading: (_source, loading) => ({ type: "diffViewer.loading", loading }), + diffs: (_source, diffs) => ({ type: "diffViewer.diffs", diffs }), + notice: (_source, notice) => ({ type: "diffViewer.notice", notice }), + diffFile: (_source, file, diff) => ({ type: "diffViewer.diffFile", file, diff }), + revertFileResult: (_source, file, result) => ({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }), + unsupportedRevert: (_source, file) => ({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Revert is not supported for the current source", + }), +} + /** - * Owns the active DiffSource for a panel: builds it via the injected - * `build` function, runs initialFetch + start, and disposes it on swap - * or teardown. + * 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. * - * Decoupled from the webview panel — receives neutral callbacks. Stale - * messages are filtered via an internal epoch counter that bumps on - * every stop/activate, so async posts from a disposed source are dropped. + * 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 startDisposable: vscode.Disposable | undefined + private interval: ReturnType | undefined + private lastHash: string | undefined private epoch = 0 constructor( private readonly build: (id: string, ctx: PanelContext) => DiffSource, private readonly listAvailable: (ctx: PanelContext) => DiffSourceDescriptor[], private readonly post: (msg: unknown) => void, + private readonly messages: Messages = viewerMessages, ) {} setContext(ctx: PanelContext): void { @@ -32,22 +83,26 @@ export class SourceController { return this.activeId } - /** Dispose the active source and bump the epoch so in-flight posts are dropped. */ + get isPolling(): boolean { + return this.interval !== undefined + } + + /** Dispose the active source and bump the epoch so in-flight fetches are dropped. */ stop(): void { this.epoch++ - this.startDisposable?.dispose() - this.startDisposable = undefined - this.active?.dispose() + this.stopPolling() + this.active?.dispose?.() this.active = undefined this.activeId = undefined + this.lastHash = undefined } /** - * Build, initial-fetch, and start source `id` in the current context. - * Internally disposes any previously active source. Throws if the - * catalog can't build the id — callers should catch and log. + * 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 { + async activate(id: string, opts: ActivateOptions = {}): Promise { const ctx = this.ctx if (!ctx) return this.stop() @@ -57,86 +112,140 @@ export class SourceController { 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, - }) + this.send(this.messages.available?.(this.listAvailable(ctx), id)) + this.send(this.messages.capabilities?.(source.descriptor.capabilities)) - const sourcePost = this.guardedPost(epoch) - await source.initialFetch(sourcePost) - // Prevents source polling from starting after teardown or source swap. - if (this.epoch !== epoch || this.activeId !== id) { - if (this.active === source) source.dispose() - return - } - this.startDisposable = source.start?.(sourcePost) + if (opts.fetch === false) return + + const keepPolling = await this.runFetch(source, epoch, true) + // Prevents the polling interval from starting after teardown or swap. + if (this.epoch !== epoch || this.activeId !== id) return + if (opts.poll !== false && keepPolling) this.startPolling(source, epoch) + } + + /** + * Rebuild the active source with current catalog state. Used when an + * external setting changes (e.g. base branch override) and the source + * needs to refetch with the new params, but the source id stays the same. + * No-op when nothing is active. + */ + async reactivate(): Promise { + const id = this.activeId + if (!id) return + await this.activate(id) } async revertFile(file: string): Promise { const source = this.active - if (!source?.revertFile) { - this.post({ - type: "diffViewer.revertFileResult", - file, - status: "error", - message: "Revert is not supported for the current source", - }) + if (!source?.revert) { + this.send(this.messages.unsupportedRevert(source, file)) return } - const result = await source.revertFile(file).catch((err) => { + 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, - }) + this.send(this.messages.revertFileResult(source, file, result)) + // Push fresh diffs immediately after a successful revert so the webview + // doesn't have to wait for the next polling tick. + if (result.ok && this.epoch === epoch && this.active === source) { + await this.runFetch(source, epoch, false) + } + } + + /** Run an immediate fetch against the active source without changing polling state. */ + async refresh(): Promise { + const source = this.active + if (!source) return + const epoch = this.epoch + await this.runFetch(source, epoch, true) } /** * Lazy detail load for a single file. Forwards to the active source's - * `requestFile`. Posts `diff: null` when the source can't resolve the file + * `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?.requestFile) { - this.post({ type: "diffViewer.diffFile", file, diff: null }) + if (!source) { + this.send(this.messages.diffFile(undefined, file, null)) return } - const diff = await source.requestFile(file).catch(() => null) + if (!source.fetchFile) { + this.send(this.messages.diffFile(source, file, null)) + return + } + const diff = await source.fetchFile(file).catch(() => null) // Drop the response if the source has been disposed/swapped while we waited. if (this.epoch !== epoch) return - this.post({ type: "diffViewer.diffFile", file, diff }) + this.send(this.messages.diffFile(source, file, diff)) } dispose(): void { this.stop() } - private guardedPost(epoch: number): DiffSourcePost { - return (msg: DiffSourceMessage) => { - // Drops stale messages from sources whose lifecycle epoch has ended. - if (this.epoch !== epoch) return - if (msg.type === "diffs") { - this.post({ type: "diffViewer.diffs", diffs: msg.diffs }) - } else if (msg.type === "loading") { - this.post({ type: "diffViewer.loading", loading: msg.loading }) - } else if (msg.type === "error") { - this.post({ type: "diffViewer.loading", loading: false }) - } else if (msg.type === "notice") { - this.post({ type: "diffViewer.notice", notice: msg.notice }) + /** + * Run one fetch against the source and post results. Returns whether the + * controller should keep polling this source — false when the source + * requests a stop or the epoch has moved on. + */ + private async runFetch(source: DiffSource, epoch: number, initial: boolean): Promise { + if (initial) this.send(this.messages.loading?.(source, true)) + + try { + const result = await source.fetch() + if (this.epoch !== epoch) return false + + if (result.notice !== undefined) { + this.send(this.messages.notice?.(source, result.notice)) + } + + const hash = hashFileDiffs(result.diffs as never) + if (initial || hash !== this.lastHash) { + this.lastHash = hash + this.send(this.messages.diffs(source, result.diffs)) + } + + return !result.stopPolling + } catch (err) { + if (this.epoch !== epoch) return false + // Errors are swallowed for the webview (it just needs the loading + // indicator cleared below), but we always log so initial-fetch + // failures leave a trace in the Extension Host output — previously + // they were silent and invisible in production. + console.log("[Kilo New] SourceController.fetch error", { initial, err }) + return true + } finally { + if (initial && this.epoch === epoch) { + this.send(this.messages.loading?.(source, false)) } } } + + private send(msg: unknown): void { + if (msg === undefined) return + this.post(msg) + } + + private startPolling(source: DiffSource, epoch: number): void { + this.stopPolling() + this.interval = setInterval(async () => { + // Self-cancel when the tick reports the source is done + const keep = await this.runFetch(source, epoch, false) + if (!keep) this.stopPolling() + }, DIFF_POLL_INTERVAL_MS) + } + + private stopPolling(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + } } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index d0d2976397..2c29b3536f 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -1,26 +1,60 @@ +import * as vscode from "vscode" import type { KiloConnectionService } from "../../services/cli-backend" +import { GitOps } from "../../agent-manager/GitOps" +import { resolveLocalDiffTarget } from "../shared/target" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { BranchListItem } from "../../agent-manager/git-import" import type { PanelContext } from "../types" import type { DiffSource, DiffSourceDescriptor } from "./types" -import { WorktreeDiffSource, WORKSPACE_DESCRIPTOR, WORKSPACE_SOURCE_ID } from "./worktree" +import { createWorktreeDiffSource, WORKSPACE_DESCRIPTOR, WORKSPACE_SOURCE_ID } from "./worktree" import { SESSION_PREFIX, - SessionDiffSource, + createSessionDiffSource, sessionDescriptor, sessionSourceId, type SessionDiffFetch, type SnapshotEnabledCheck, } from "./session" +import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn" +import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged" +import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged" + +export interface WorkspaceBranchesResult { + branches: BranchListItem[] + defaultBranch: string + /** Resolved auto base (tracking → default), shown next to the "Default" option. */ + autoBase: string | undefined + /** Currently active base — the override when set, otherwise `autoBase`. */ + currentBase: string | undefined + /** True when no override is active and `currentBase === autoBase`. */ + isAuto: boolean + /** Currently checked-out branch (HEAD). Undefined when detached or unresolved. */ + currentBranch: string | undefined +} /** * Enumerates and constructs diff sources for a PanelContext. */ -export class DiffSourceCatalog { +export class DiffSourceCatalog implements vscode.Disposable { private readonly sessionFetch: SessionDiffFetch = async ({ sessionID, directory }) => { const client = this.connection.getClient() const { data } = await client.session.diff({ sessionID, directory }, { throwOnError: true }) return data ?? [] } + /** + * Turn diffs are stored on the user message itself (`summary.diffs`), not + * on the session-level snapshot. The `/session/:id/diff` endpoint ignores + * its `messageID` param today, so we fetch the message directly instead. + */ + private readonly turnFetch: TurnDiffFetch = async ({ sessionID, messageID, directory }) => { + const client = this.connection.getClient() + const { data } = await client.session.message({ sessionID, messageID, directory }, { throwOnError: true }) + const info = data?.info + if (!info || info.role !== "user") return [] + return info.summary?.diffs ?? [] + } + private readonly checkSnapshotsEnabled: SnapshotEnabledCheck = async (directory) => { const client = this.connection.getClient() const { data } = await client.config.get({ directory }, { throwOnError: true }) @@ -28,11 +62,22 @@ export class DiffSourceCatalog { return data?.snapshot !== false } + // Lazily created git ops for branch listing / auto base resolution. The + // worktree source has its own GitOps for diff operations; this one is + // owned by the catalog so it survives source swaps. + private branchGit: GitOps | undefined + private branchOutput: vscode.OutputChannel | undefined + constructor(private readonly connection: KiloConnectionService) {} listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { + if (ctx.hidePicker) return [] const out: DiffSourceDescriptor[] = [] - if (ctx.workspaceRoot) out.push(WORKSPACE_DESCRIPTOR) + if (ctx.workspaceRoot) { + out.push(WORKSPACE_DESCRIPTOR) + out.push(STAGED_DESCRIPTOR) + out.push(UNSTAGED_DESCRIPTOR) + } if (ctx.sessionId) out.push(sessionDescriptor(ctx.sessionId)) return out } @@ -45,14 +90,69 @@ export class DiffSourceCatalog { } build(id: string, ctx: PanelContext): DiffSource { - if (id === WORKSPACE_SOURCE_ID) return new WorktreeDiffSource(this.connection) + if (id === WORKSPACE_SOURCE_ID) { + return createWorktreeDiffSource(this.connection, { baseBranchOverride: ctx.baseBranchOverride }) + } + + if (id === STAGED_SOURCE_ID) return createStagedDiffSource() + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + + if (id.startsWith(TURN_PREFIX)) { + const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") + if (!sessionId || !messageId) { + throw new Error(`DiffSourceCatalog.build: malformed turn id "${id}" (expected turn::)`) + } + return createTurnDiffSource(sessionId, messageId, this.turnFetch, ctx.workspaceRoot) + } if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return new SessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) } + + async listWorkspaceBranches(override: string | undefined): Promise { + const root = getWorkspaceRoot() + if (!root) return undefined + + const git = this.ensureBranchGit() + const [{ branches, defaultBranch }, autoTarget, head] = await Promise.all([ + git.listBranches(root), + resolveLocalDiffTarget(git, this.branchLog, root), + git.currentBranch(root), + ]) + const autoBase = autoTarget?.baseBranch + const currentBase = override ?? autoBase + const currentBranch = head && head !== "HEAD" ? head : undefined + return { + branches, + defaultBranch, + autoBase, + currentBase, + isAuto: !override, + currentBranch, + } + } + + dispose(): void { + this.branchGit?.dispose() + this.branchGit = undefined + this.branchOutput?.dispose() + this.branchOutput = undefined + } + + private readonly branchLog = (...args: unknown[]) => { + if (!this.branchOutput) return + appendOutput(this.branchOutput, "DiffSourceCatalog", ...args) + } + + private ensureBranchGit(): GitOps { + if (this.branchGit) return this.branchGit + this.branchOutput = vscode.window.createOutputChannel("Kilo Diff: Branches") + this.branchGit = new GitOps({ log: this.branchLog }) + return this.branchGit + } } diff --git a/packages/kilo-vscode/src/diff/sources/git-status.ts b/packages/kilo-vscode/src/diff/sources/git-status.ts new file mode 100644 index 0000000000..f2463cf060 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/git-status.ts @@ -0,0 +1,144 @@ +// Shared parsing helpers for the staged / unstaged diff sources. Both run +// off `git diff` variants and need the same name-status + numstat stitching. + +import * as fs from "fs/promises" +import * as path from "path" +import type { GitOps } from "../../agent-manager/GitOps" +import { generatedLike } from "../../agent-manager/local-diff" +import type { DiffFile } from "../types" + +export { MAX_DETAIL_BYTES } from "../../agent-manager/local-diff" + +export type Status = "added" | "deleted" | "modified" + +export interface FileEntry { + file: string + status: Status + additions: number + deletions: number + tracked: boolean + stamp?: string +} + +/** Parse `git diff --name-status` output into entries (status code + path). */ +export function parseNameStatus(stdout: string): { file: string; status: Status }[] { + const out: { file: string; status: Status }[] = [] + for (const line of stdout.split("\n")) { + if (!line) continue + const parts = line.split("\t") + const code = parts[0] + const file = parts.slice(1).join("\t") + if (!code || !file) continue + out.push({ file, status: statusFromCode(code) }) + } + return out +} + +/** Parse `git diff --numstat` output into a per-file `{additions, deletions}` map. */ +export function parseNumstat(stdout: string): Map { + const map = new Map() + for (const line of stdout.split("\n")) { + if (!line.trim()) continue + const parts = line.split("\t") + if (parts.length < 3) continue + const additions = parts[0] === "-" ? 0 : parseInt(parts[0]!, 10) || 0 + const deletions = parts[1] === "-" ? 0 : parseInt(parts[1]!, 10) || 0 + const file = parts.slice(2).join("\t") + if (file) map.set(file, { additions, deletions }) + } + return map +} + +function statusFromCode(code: string): Status { + if (code.startsWith("A")) return "added" + if (code.startsWith("D")) return "deleted" + return "modified" +} + +/** + * Build the summarized DiffFile shape the viewer expects. `before`/`after` + * are left empty — the controller fetches detail lazily through `fetchFile`. + */ +export function summarize(entry: FileEntry): DiffFile { + return { + file: entry.file, + before: "", + after: "", + additions: entry.additions, + deletions: entry.deletions, + status: entry.status, + tracked: entry.tracked, + generatedLike: generatedLike(entry.file), + summarized: true, + // Synthetic stamp keyed on the stats we actually polled: any change to + // the file's diff produces new additions/deletions, which invalidates + // the webview-side cached detail via mergeWorktreeDiffs. Callers can + // supply a custom stamp (see `FileEntry.stamp`) when additions/deletions + // aren't a reliable change signal — notably untracked files. + stamp: entry.stamp ?? `${entry.status}:${entry.additions}:${entry.deletions}`, + } +} + +// Used to tell Git "read the file from the staging area" instead of from a commit. +export const INDEX_REF = "" + +/** + * `git show :` to dump file contents at a specific revision. + * Returns `""` on failure (binary, missing, etc.) — the caller decides whether + * to surface that as `summarized` or just empty. + */ +export async function showBlob(git: GitOps, dir: string, ref: string, file: string): Promise { + const result = await git.execGit(["show", `${ref}:${file}`], dir) + return result.code === 0 ? result.stdout : "" +} + +/** + * Read a working-tree file off disk, matching git's blob semantics for + * symlinks: when the entry is a symlink, return its target string (what git + * stores as the blob) rather than following it and reading the target file's + * contents. `""` on missing/unreadable. + * + * Following symlinks here would be both incorrect (mismatches the index + * "before" side from `git show :file`) and unsafe — an untracked symlink + * pointing at e.g. `~/.aws/credentials` would otherwise be surfaced in the + * diff viewer despite `resolveInside` (which is purely lexical). + */ +export async function readDisk(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return "" + const lstat = await fs.lstat(full).catch(() => undefined) + if (!lstat) return "" + if (lstat.isSymbolicLink()) return fs.readlink(full).catch(() => "") + if (!lstat.isFile()) return "" + return fs.readFile(full, "utf-8").catch(() => "") +} + +// Used to size-cap detail reads without materializing the blob. Mirrors +// local-diff.ts's `blobSize` helper. +export async function blobSize(git: GitOps, dir: string, ref: string, file: string): Promise { + const result = await git.execGit(["cat-file", "-s", `${ref}:${file}`], dir) + if (result.code !== 0) return 0 + return parseInt(result.stdout.trim(), 10) || 0 +} + +/** + * Size of the working-tree entry at `file`. Uses `lstat` so symlinks report + * the link's own size (length of the target string) instead of resolving to + * whatever the link points at — see `readDisk` for why. + */ +export async function fileSize(dir: string, file: string): Promise { + const full = resolveInside(dir, file) + if (!full) return 0 + const stat = await fs.lstat(full).catch(() => undefined) + return stat?.size ?? 0 +} + +// Rejects absolute paths and any `..` traversal that would escape `dir`. +// Returns the resolved path when safe, `undefined` otherwise. +export function resolveInside(dir: string, file: string): string | undefined { + if (path.isAbsolute(file)) return undefined + const full = path.resolve(dir, file) + const base = path.resolve(dir) + if (full !== base && !full.startsWith(base + path.sep)) return undefined + return full +} diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts index b92125e0dd..cf10f0e2a2 100644 --- a/packages/kilo-vscode/src/diff/sources/session.ts +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -1,10 +1,7 @@ -import * as vscode from "vscode" import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" -import type { DiffFile } from "../types" -import { hashFileDiffs } from "../shared/hash" -import { DIFF_POLL_INTERVAL_MS } from "../polling" -import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "./types" import { normalize, text } from "@kilocode/kilo-ui/session-diff" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" export type SessionDiffFetch = (params: { sessionID: string; directory?: string }) => Promise @@ -26,109 +23,59 @@ export function sessionDescriptor(sessionId: string): DiffSourceDescriptor { } /** - * Diff for the current session. Initial fetch + 2.5s polling with hash dedup + * 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 class SessionDiffSource implements DiffSource { - readonly descriptor: DiffSourceDescriptor +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 - private lastHash: string | undefined - private interval: ReturnType | undefined - private disposed = false + return { + descriptor: sessionDescriptor(sessionId), - private snapshotsDisabled = false + async fetch(): Promise { + if (snapshotsDisabled) { + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } + } - constructor( - private readonly sessionId: string, - private readonly fetch: SessionDiffFetch, - private readonly workspaceRoot?: string, - private readonly checkSnapshotsEnabled?: SnapshotEnabledCheck, - ) { - this.descriptor = sessionDescriptor(sessionId) - } - - async initialFetch(post: DiffSourcePost): Promise { - post({ type: "loading", loading: true }) - - try { - if (this.checkSnapshotsEnabled) { - const enabled = await this.checkSnapshotsEnabled(this.workspaceRoot) - if (this.disposed) return + if (checkSnapshotsEnabled) { + const enabled = await checkSnapshotsEnabled(workspaceRoot) if (!enabled) { - this.snapshotsDisabled = true - post({ type: "notice", notice: "snapshots-disabled" }) - post({ type: "diffs", diffs: [] }) - return + snapshotsDisabled = true + return { diffs: [], notice: "snapshots-disabled", stopPolling: true } } } - const diffs = await this.fetchDiffs() - if (this.disposed) return - this.lastHash = hashFileDiffs(diffs as never) - post({ type: "diffs", diffs }) - } catch (err) { - if (this.disposed) return - const message = err instanceof Error ? err.message : String(err) - post({ type: "error", message }) - } finally { - if (!this.disposed) post({ type: "loading", loading: false }) - } - } - - start(post: DiffSourcePost): vscode.Disposable { - this.stopPolling() - // Skip polling entirely when snapshots are disabled — nothing to fetch. - if (this.snapshotsDisabled) return new vscode.Disposable(() => {}) - this.interval = setInterval(() => { - void this.poll(post) - }, DIFF_POLL_INTERVAL_MS) - - return new vscode.Disposable(() => this.stopPolling()) - } - - dispose(): void { - this.disposed = true - this.stopPolling() - this.lastHash = undefined - } - - private async fetchDiffs(): Promise { - const raw = await this.fetch({ sessionID: this.sessionId, directory: this.workspaceRoot }) - return raw.map((r) => { - // Empty patch means binary or summarized (>256 KB) — normalize() can't - // parse it, so short-circuit to empty strings. - const view = r.patch === "" ? null : normalize(r) - return { - file: r.file, - before: view ? text(view, "deletions") : "", - after: view ? text(view, "additions") : "", - additions: r.additions, - deletions: r.deletions, - status: r.status, - tracked: true, - generatedLike: false, - summarized: r.patch === "", - } - }) - } - - private async poll(post: DiffSourcePost): Promise { - try { - const diffs = await this.fetchDiffs() - if (this.disposed) return - const hash = hashFileDiffs(diffs as never) - if (hash === this.lastHash) return - this.lastHash = hash - post({ type: "diffs", diffs }) - } catch (err) { - if (this.disposed) return - console.log("[Kilo New] SessionDiffSource.poll error", err) - } - } - - private stopPolling(): void { - if (this.interval) { - clearInterval(this.interval) - this.interval = undefined - } + const raw = await fetch({ sessionID: sessionId, directory: workspaceRoot }) + return { diffs: raw.map(toSessionDiffFile) } + }, + } +} + +/** + * Project a backend `SnapshotFileDiff` onto the `DiffFile` shape the viewer + * expects. Shared with `createTurnDiffSource` since both hit the same endpoint. + */ +export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { + // Empty patch means binary or summarized (>256 KB) — normalize() can't + // parse it, so short-circuit to empty strings. + const view = raw.patch === "" ? null : normalize(raw) + return { + file: raw.file, + before: view ? text(view, "deletions") : "", + after: view ? text(view, "additions") : "", + additions: raw.additions, + deletions: raw.deletions, + status: raw.status, + tracked: true, + generatedLike: false, + summarized: raw.patch === "", } } diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts new file mode 100644 index 0000000000..9af28badce --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -0,0 +1,145 @@ +import * as vscode from "vscode" +import { GitOps } from "../../agent-manager/GitOps" +import { generatedLike } from "../../agent-manager/local-diff" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import { + blobSize, + INDEX_REF, + MAX_DETAIL_BYTES, + parseNameStatus, + parseNumstat, + showBlob, + summarize, + type FileEntry, +} from "./git-status" + +export const STAGED_SOURCE_ID = "staged" + +export const STAGED_DESCRIPTOR: DiffSourceDescriptor = { + id: STAGED_SOURCE_ID, + type: "staged", + group: "Git", + capabilities: { revert: false, comments: true }, +} + +/** + * Diff between the git index and HEAD — what `git diff --cached` would show. + * Polls on the standard interval; revert isn't supported (use `git reset` from + * a real git client). Read-only view. + */ +export function createStagedDiffSource(): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) + const git = new GitOps({ log }) + + const root = (): string | undefined => getWorkspaceRoot() + + const listEntries = async (dir: string): Promise => { + const [nameStatus, numstat] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--cached", "--name-status", "--no-renames", "HEAD"], dir), + git.execGit(["-c", "core.quotepath=false", "diff", "--cached", "--numstat", "--no-renames", "HEAD"], dir), + ]) + if (nameStatus.code !== 0) { + log("git diff --cached --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) + return [] + } + const counts = parseNumstat(numstat.code === 0 ? numstat.stdout : "") + const items = parseNameStatus(nameStatus.stdout) + return items.map((item) => ({ + file: item.file, + status: item.status, + additions: counts.get(item.file)?.additions ?? 0, + deletions: counts.get(item.file)?.deletions ?? 0, + tracked: true, + })) + } + + return { + descriptor: STAGED_DESCRIPTOR, + + async fetch(): Promise { + const dir = root() + if (!dir) { + log("No workspace root") + return { diffs: [] } + } + const entries = await listEntries(dir) + log(`Staged diff: ${entries.length} file(s)`) + return { diffs: entries.map(summarize) } + }, + + async fetchFile(file: string): Promise { + const dir = root() + if (!dir || !file) return null + + // Resolve the entry for this single file so we know its status. Reading + // both refs blindly would still work, but knowing the status lets us + // skip impossible reads (e.g. HEAD: for an added file). + const entry = await fileEntry(git, dir, file, log) + if (!entry) return null + + const beforeBytes = entry.status === "added" ? 0 : await blobSize(git, dir, "HEAD", file) + const afterBytes = entry.status === "deleted" ? 0 : await blobSize(git, dir, INDEX_REF, file) + if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { + log("Staged detail skipped: file too large", { file, beforeBytes, afterBytes, cap: MAX_DETAIL_BYTES }) + return summarize(entry) + } + + // For added: HEAD has no blob. For deleted: index has no blob. + const before = entry.status === "added" ? "" : await showBlob(git, dir, "HEAD", file) + const after = entry.status === "deleted" ? "" : await showBlob(git, dir, INDEX_REF, file) + const summarized = before === "" && after === "" && entry.status === "modified" + return { + file, + before, + after, + additions: entry.additions, + deletions: entry.deletions, + status: entry.status, + tracked: true, + generatedLike: generatedLike(file), + summarized, + stamp: `${entry.status}:${entry.additions}:${entry.deletions}`, + } + }, + + dispose(): void { + git.dispose() + output.dispose() + }, + } +} + +async function fileEntry( + git: GitOps, + dir: string, + file: string, + log: (...args: unknown[]) => void, +): Promise { + const result = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--cached", "--name-status", "--no-renames", "HEAD", "--", file], + dir, + ) + if (result.code !== 0) { + log("Single-file staged status lookup failed", { file, stderr: result.stderr.trim() }) + return undefined + } + const items = parseNameStatus(result.stdout) + const item = items[0] + if (!item) return undefined + + const counts = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--cached", "--numstat", "--no-renames", "HEAD", "--", file], + dir, + ) + const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") + return { + file: item.file, + status: item.status, + additions: stats.get(item.file)?.additions ?? 0, + deletions: stats.get(item.file)?.deletions ?? 0, + tracked: true, + } +} diff --git a/packages/kilo-vscode/src/diff/sources/turn.ts b/packages/kilo-vscode/src/diff/sources/turn.ts new file mode 100644 index 0000000000..24b6b5e1bc --- /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 index 2ac5d01caf..6c30639135 100644 --- a/packages/kilo-vscode/src/diff/sources/types.ts +++ b/packages/kilo-vscode/src/diff/sources/types.ts @@ -1,4 +1,3 @@ -import type * as vscode from "vscode" import type { DiffFile } from "../types" export interface DiffSourceCapabilities { @@ -7,10 +6,13 @@ export interface DiffSourceCapabilities { } /** - * Closed enum of diff source kinds. Drives i18n key composition: - * `diffViewer.source..label` and `diffViewer.source..tooltip`. + * 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" +export type DiffSourceType = "workspace" | "session" | "turn" | "staged" | "unstaged" export interface DiffSourceDescriptor { /** Unique within a panel context. E.g. "workspace", "session:". */ @@ -28,35 +30,36 @@ export interface DiffSourceDescriptor { */ export type DiffSourceNotice = "snapshots-disabled" -export type DiffSourceMessage = - | { type: "diffs"; diffs: DiffFile[] } - | { type: "loading"; loading: boolean } - | { type: "error"; message: string } - | { type: "notice"; notice: DiffSourceNotice | undefined } - -export type DiffSourcePost = (msg: DiffSourceMessage) => void +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 produces file diffs for a given context (local workspace, - * session changes, a turn, a git ref...). The SourceController owns one - * active source at a time and swaps between them on user request. + * 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 - initialFetch(post: DiffSourcePost): Promise - - /** Start change detection (polling, SSE, watcher...). Dispose to stop. */ - start?(post: DiffSourcePost): vscode.Disposable - - revertFile?(file: string): Promise<{ ok: boolean; message: string }> + 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. + * Lazy detail load for a single file, for sources that emit summarized + * entries (no `before`/`after` content) so the webview can fetch full + * content on demand. */ - requestFile?(file: string): Promise + fetchFile?(file: string): Promise - dispose(): void + revert?(file: string): Promise<{ ok: boolean; message: string }> + + dispose?(): void } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts new file mode 100644 index 0000000000..26f1210b92 --- /dev/null +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -0,0 +1,220 @@ +import * as fs from "fs/promises" +import * as vscode from "vscode" +import { GitOps } from "../../agent-manager/GitOps" +import { generatedLike } from "../../agent-manager/local-diff" +import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import type { DiffFile } from "../types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import { + blobSize, + fileSize, + INDEX_REF, + MAX_DETAIL_BYTES, + parseNameStatus, + parseNumstat, + readDisk, + resolveInside, + showBlob, + summarize, + type FileEntry, +} from "./git-status" + +export const UNSTAGED_SOURCE_ID = "unstaged" + +export const UNSTAGED_DESCRIPTOR: DiffSourceDescriptor = { + id: UNSTAGED_SOURCE_ID, + type: "unstaged", + group: "Git", + capabilities: { revert: false, comments: true }, +} + +/** + * Diff between the working tree and the index — what `git diff` shows for + * tracked files, plus untracked files (treated as fully-added). Read-only; + * polls on the standard interval. + */ +export function createUnstagedDiffSource(): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) + const git = new GitOps({ log }) + + const root = (): string | undefined => getWorkspaceRoot() + + const listTracked = async (dir: string): Promise => { + const [nameStatus, numstat] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames"], dir), + git.execGit(["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames"], dir), + ]) + if (nameStatus.code !== 0) { + log("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) + return [] + } + const counts = parseNumstat(numstat.code === 0 ? numstat.stdout : "") + return parseNameStatus(nameStatus.stdout).map((item) => ({ + file: item.file, + status: item.status, + additions: counts.get(item.file)?.additions ?? 0, + deletions: counts.get(item.file)?.deletions ?? 0, + tracked: true, + })) + } + + const listUntracked = async (dir: string): Promise => { + const result = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir) + if (result.code !== 0) { + log("git ls-files --others failed", { code: result.code, stderr: result.stderr.trim() }) + return [] + } + const out: FileEntry[] = [] + for (const file of result.stdout.split("\n")) { + if (!file.trim()) continue + const full = resolveInside(dir, file) + if (!full) continue + // `lstat` so an untracked symlink listed by `ls-files --others` is + // recognized via the link itself rather than its target. `readDisk` + // returns the target string for symlinks (matching git's blob), so + // we never read whatever is on the other side. + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) continue + out.push({ + file, + status: "added", + additions: 0, + deletions: 0, + tracked: false, + // Untracked entries always have additions/deletions = 0 (numstat + // can't compute them without an index blob), so fold size+mtime + // into the stamp. Editing the file changes mtime → the webview + // cache invalidates and refetches detail. Without this the user + // sees stale before/after content while polling continues. + stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`, + }) + } + return out + } + + return { + descriptor: UNSTAGED_DESCRIPTOR, + + async fetch(): Promise { + const dir = root() + if (!dir) { + log("No workspace root") + return { diffs: [] } + } + const [tracked, untracked] = await Promise.all([listTracked(dir), listUntracked(dir)]) + // Drop any tracked entry that's also untracked (shouldn't happen but be + // defensive — git can race here when files are added concurrently). + const seen = new Set(tracked.map((t) => t.file)) + const merged = tracked.concat(untracked.filter((u) => !seen.has(u.file))) + log(`Unstaged diff: ${merged.length} file(s) (${tracked.length} tracked, ${untracked.length} untracked)`) + return { diffs: merged.map(summarize) } + }, + + async fetchFile(file: string): Promise { + const dir = root() + if (!dir || !file) return null + + const entry = await fileEntry(git, dir, file, log) + if (!entry) return null + + const beforeBytes = !entry.tracked || entry.status === "added" ? 0 : await blobSize(git, dir, INDEX_REF, file) + const afterBytes = entry.status === "deleted" ? 0 : await fileSize(dir, file) + if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { + log("Unstaged detail skipped: file too large", { file, beforeBytes, afterBytes, cap: MAX_DETAIL_BYTES }) + return summarize(entry) + } + + // Untracked: no index blob, after = disk content. + // Tracked added/modified/deleted: before = index blob (or "" for added), + // after = disk content (or "" for deleted). + const before = !entry.tracked || entry.status === "added" ? "" : await showBlob(git, dir, INDEX_REF, file) + const after = entry.status === "deleted" ? "" : await readDisk(dir, file) + const summarized = before === "" && after === "" && entry.status === "modified" + + // For untracked added files numstat doesn't return counts, so backfill + // from the disk content's line count. + const additions = !entry.tracked ? lineCount(after) : entry.additions + return { + file, + before, + after, + additions, + deletions: entry.deletions, + status: entry.status, + tracked: entry.tracked, + generatedLike: generatedLike(file), + summarized, + // Match the summary stamp so cache invalidation is consistent across + // summarize → fetchFile transitions. `entry.stamp` is set for + // untracked entries (size:mtime); tracked entries fall back to the + // numstat-derived stamp. + stamp: entry.stamp ?? `${entry.status}:${additions}:${entry.deletions}`, + } + }, + + dispose(): void { + git.dispose() + output.dispose() + }, + } +} + +async function fileEntry( + git: GitOps, + dir: string, + file: string, + log: (...args: unknown[]) => void, +): Promise { + // Tracked path: `git diff --name-status -- ` resolves status without + // reading content. If the file isn't tracked, fall through to untracked. + const tracked = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", "--", file], + dir, + ) + if (tracked.code === 0 && tracked.stdout.trim()) { + const item = parseNameStatus(tracked.stdout)[0] + if (item) { + const counts = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", "--", file], + dir, + ) + const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") + return { + file: item.file, + status: item.status, + additions: stats.get(item.file)?.additions ?? 0, + deletions: stats.get(item.file)?.deletions ?? 0, + tracked: true, + } + } + } + + // Untracked branch: confirm via fs.lstat that the file actually exists. + // `resolveInside` rejects absolute paths and `..` segments so a crafted + // webview message can't read outside the workspace; `lstat` (vs `stat`) + // ensures we don't follow symlinks into arbitrary filesystem locations. + const full = resolveInside(dir, file) + if (!full) { + log("Unstaged file rejected: outside workspace", { file }) + return undefined + } + const stat = await fs.lstat(full).catch(() => undefined) + if (!stat) { + log("Unstaged file not found", { file }) + return undefined + } + return { + file, + status: "added", + additions: 0, + deletions: 0, + tracked: false, + stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`, + } +} + +function lineCount(text: string): number { + if (!text) return 0 + return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length +} diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index c31ceaf924..fdb2ebe932 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -4,12 +4,10 @@ import { GitOps } from "../../agent-manager/GitOps" import { diffSummary, diffFile } from "../../agent-manager/local-diff" import type { WorktreeDiffEntry } from "../../agent-manager/types" import { WorktreeDiffClient, type DiffTarget } from "../shared/client" -import { hashFileDiffs } from "../shared/hash" import { resolveLocalDiffTarget } from "../shared/target" -import { DIFF_POLL_INTERVAL_MS } from "../polling" import { appendOutput, getWorkspaceRoot } from "../../review-utils" import type { DiffFile } from "../types" -import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "./types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" export const WORKSPACE_SOURCE_ID = "workspace" @@ -20,141 +18,125 @@ export const WORKSPACE_DESCRIPTOR: DiffSourceDescriptor = { capabilities: { revert: true, comments: true }, } +export interface WorktreeDiffSourceOptions { + /** + * When set, overrides the auto-resolved base branch. The HEAD side stays + * the current branch — only the comparison target changes. Reset on dispose. + */ + baseBranchOverride?: string +} + /** - * Diffs between the local working tree and the base branch. Polls a summary - * (one entry per changed file, no content) every {@link DIFF_POLL_INTERVAL_MS}, - * then loads `before`/`after`/`patch` per file on demand via {@link requestFile}. - * - * Mirrors the Agent Manager's `WorktreeDiffController` and runs entirely in - * the extension host (no `kilo serve` round-trip) + * 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 class WorktreeDiffSource implements DiffSource { - readonly descriptor = WORKSPACE_DESCRIPTOR +export function createWorktreeDiffSource( + connection: KiloConnectionService, + opts: WorktreeDiffSourceOptions = {}, +): DiffSource { + const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) + const git = new GitOps({ log }) - private readonly git: GitOps - private readonly output: vscode.OutputChannel - private target: DiffTarget | undefined - private lastHash: string | undefined - private interval: ReturnType | undefined - private post: DiffSourcePost | undefined + // 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 - constructor(private readonly connection: KiloConnectionService) { - this.git = new GitOps({ log: (...args) => this.log(...args) }) - this.output = vscode.window.createOutputChannel("Kilo Diff: Workspace") - } - - async initialFetch(post: DiffSourcePost): Promise { - this.post = post - post({ type: "loading", loading: true }) - - const target = await this.resolveTarget() - if (!target) { - post({ type: "diffs", diffs: [] }) - post({ type: "loading", loading: false }) - return + const resolveTarget = async (): Promise => { + if (target) return target + if (opts.baseBranchOverride) { + const root = getWorkspaceRoot() + if (!root) { + log("Local diff: no workspace root (override mode)") + return + } + const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + if (!resolved) { + log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) + } else { + target = { directory: root, baseBranch: resolved } + log(`Local diff: using override base=${resolved}`) + return target + } } - - this.target = target - await this.fetchAndPost(target, post, true) - post({ type: "loading", loading: false }) + target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + return target } - start(post: DiffSourcePost): vscode.Disposable { - this.post = post - this.stopPolling() - this.interval = setInterval(() => { - void this.poll(post) - }, DIFF_POLL_INTERVAL_MS) + return { + descriptor: WORKSPACE_DESCRIPTOR, - return new vscode.Disposable(() => this.stopPolling()) - } + async fetch(): Promise { + const current = await resolveTarget() + if (!current) return { diffs: [] } - async revertFile(file: string): Promise<{ ok: boolean; message: string }> { - const target = this.target ?? (await this.resolveTarget()) - if (!target) { - return { ok: false, message: "Could not resolve diff target" } - } - - try { - const client = this.connection.getClient() - const diff = new WorktreeDiffClient(client, this.git, (...args) => this.log(...args)) - const result = await diff.revertFile(target, file) - if (result.ok && this.post) void this.poll(this.post) - return result - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.log("Failed to revert file:", message) - return { ok: false, message } - } - } - - async requestFile(file: string): Promise { - if (!file) return null - const target = this.target ?? (await this.resolveTarget()) - if (!target) return null - this.target = target - - try { - const entry = await diffFile(this.git, target.directory, target.baseBranch, file, (...args) => this.log(...args)) - if (!entry) return null - return toDiffFile(entry) - } catch (err) { - this.log("Failed to fetch worktree diff file:", err) - return null - } - } - - dispose(): void { - this.stopPolling() - this.git.dispose() - this.output.dispose() - this.post = undefined - this.target = undefined - this.lastHash = undefined - } - - private async resolveTarget(): Promise { - return await resolveLocalDiffTarget(this.git, (...args) => this.log(...args), getWorkspaceRoot()) - } - - private async fetchAndPost(target: DiffTarget, post: DiffSourcePost, force: boolean): Promise { - try { - const entries = await diffSummary(this.git, target.directory, target.baseBranch, (...args) => this.log(...args)) + const entries = await diffSummary(git, current.directory, current.baseBranch, log) const diffs = entries.map(toDiffFile) - const hash = hashFileDiffs(diffs as never) - if (!force && hash === this.lastHash) return - this.lastHash = hash + log(`Diff: ${diffs.length} file(s)`) + return { diffs } + }, - this.log(`Diff: ${diffs.length} file(s)`) - post({ type: "diffs", diffs }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.log("Failed to fetch diff:", message) - if (force) post({ type: "error", message }) - } - } + async fetchFile(file: string): Promise { + if (!file) return null + const current = await resolveTarget() + if (!current) return null - private async poll(post: DiffSourcePost): Promise { - const target = this.target - if (!target) { - await this.initialFetch(post) - return - } - await this.fetchAndPost(target, post, false) - } + 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 + } + }, - private stopPolling(): void { - if (this.interval) { - clearInterval(this.interval) - this.interval = undefined - } - } + async revert(file: string): Promise<{ ok: boolean; message: string }> { + const current = await resolveTarget() + if (!current) return { ok: false, message: "Could not resolve diff target" } - private log(...args: unknown[]): void { - appendOutput(this.output, "WorktreeDiffSource", ...args) + 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 + }, } } +// Branches surfaced by `parseForEachRefOutput` come as short names (e.g. +// `feature` for `refs/remotes/origin/feature`), which `git merge-base` can't +// resolve when there's no local branch of the same name. Try the short name +// first, then `origin/` before giving up. +async function resolveOverrideRef( + git: GitOps, + dir: string, + name: string, + log: (...args: unknown[]) => void, +): Promise { + const direct = await git.execGit(["rev-parse", "--verify", "--quiet", name], dir) + if (direct.code === 0) return name + const remote = `origin/${name}` + const viaRemote = await git.execGit(["rev-parse", "--verify", "--quiet", remote], dir) + if (viaRemote.code === 0) { + log(`override "${name}" not a local ref, resolved to "${remote}"`) + return remote + } + return undefined +} + /** * Project a `WorktreeDiffEntry` from `local-diff.ts` onto the `DiffFile` shape * expected by the diff viewer. Drops `patch` (the webview rebuilds before/after diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 9850668212..59dd384f5a 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -3,6 +3,13 @@ export interface PanelContext { sessionId?: string /** Overrides the computed default source on open. */ initialSourceId?: string + /** + * Hides the source picker header in the diff viewer. Used for panels that + * open in a fixed view (e.g. a specific turn's diff) + */ + hidePicker?: boolean + /** User-picked base branch for the workspace source. Undefined = auto. */ + baseBranchOverride?: string } /** Mirrors `WorktreeFileDiff` in webview-ui/src/types/messages/agent-manager.ts. */ 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 0000000000..f53f3c854f --- /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 b00c64ecf9..8c2ae51cda 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -57,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()) @@ -72,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) @@ -208,6 +221,7 @@ export function activate(context: vscode.ExtensionContext) { ) const diffSourceCatalog = new DiffSourceCatalog(connectionService) + context.subscriptions.push(diffSourceCatalog) const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService, diffSourceCatalog, { sessionIdProvider: () => provider.getCurrentSessionId(), }) @@ -337,7 +351,7 @@ export function activate(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand( "kilo-code.new.showChanges", - (arg?: { sessionId?: string; initialSourceId?: string }) => { + (arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }) => { diffViewerProvider.openFromCommand(arg) }, ), diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 8aa963f215..caa1f565e1 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -607,30 +607,3 @@ export function isEventFromForeignProject(event: Event, expectedProjectID: strin } return false } - -/** - * Merge open-tab paths with backend file search results for the @ mention dropdown. - * - * Ordering: active file → other open tabs → backend results (all deduplicated). - * When a query is present, open tabs are filtered to only include matches. - * The `active` path (if provided) is placed first when it exists in `open`. - */ -export function mergeFileSearchResults(input: { - query: string - backend: string[] - open: Set - active?: string -}): string[] { - const norm = (p: string) => p.replaceAll("\\", "/") - const query = norm(input.query).trim().toLowerCase() - const open = new Set([...input.open].map(norm)) - const active = input.active ? norm(input.active) : undefined - const backend = input.backend.map(norm) - const ok = (p: string) => !query || p.toLowerCase().includes(query) - const tabs = - active && open.has(active) && ok(active) - ? [active, ...[...open].filter((p) => p !== active && ok(p))] - : [...open].filter(ok) - const seen = new Set(tabs) - return [...tabs, ...backend.filter((p) => !seen.has(p))] -} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts index 83bb074b64..5b4876f1ad 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts @@ -1,4 +1,4 @@ -export type FileSearchItem = { path: string; type: "file" | "folder" } +export type FileSearchItem = { path: string; type: "file" | "folder" | "opened-file" } const normalize = (p: string) => p.replaceAll("\\", "/") const trim = (p: string) => normalize(p).replace(/\/+$/, "") @@ -18,9 +18,20 @@ function rank(query: string, p: string): number { return 4 } -export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] { +export function mergeFileSearchItems(input: { + query: string + files: string[] + folders: string[] + open?: Set +}): FileSearchItem[] { const query = normalize(input.query).trim().toLowerCase() - const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const })) + const open = new Set([...(input.open ?? [])].map(normalize)) + const files = input.files.map((p) => { + const path = normalize(p) + return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const) } + }) + const pinned = files.filter((item) => item.type === "opened-file") + const rest = files.filter((item) => !open.has(item.path)) // Dedup folders against themselves; a file and a folder that share a stem are distinct entries. const seen = new Set() const folders = input.folders @@ -40,6 +51,6 @@ export function mergeFileSearchItems(input: { query: string; files: string[]; fo const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index) const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item) - const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item) - return [...boosted, ...files, ...rest] + const other = sorted.filter((x) => x.rank > 1).map((x) => x.item) + return [...pinned, ...boosted, ...rest, ...other] } diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-results.ts b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts new file mode 100644 index 0000000000..390fe8a7ea --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts @@ -0,0 +1,78 @@ +import fuzzysort from "fuzzysort" + +function base(p: string): string { + const clean = p.replace(/\/+$/, "") + return clean.split("/").pop() ?? clean +} + +function depth(p: string): number { + return p.split("/").length - 1 +} + +function score(query: string, p: string) { + const name = base(p) + const label = fuzzysort.single(query, name) + const path = fuzzysort.single(query, p) + return { + p, + label, + path, + depth: depth(p), + } +} + +function compare(a: ReturnType, b: ReturnType): number { + const alabel = a.label !== null + const blabel = b.label !== null + if (alabel !== blabel) return alabel ? -1 : 1 + + const ascore = a.label?.score ?? a.path?.score ?? 0 + const bscore = b.label?.score ?? b.path?.score ?? 0 + if (ascore !== bscore) return bscore - ascore + + const aname = base(a.p) + const bname = base(b.p) + if (aname.length !== bname.length) return aname.length - bname.length + if (a.depth !== b.depth) return a.depth - b.depth + if (a.p.length !== b.p.length) return a.p.length - b.p.length + return a.p.localeCompare(b.p) +} + +function rankOpen(query: string, paths: string[]): string[] { + if (!query || !paths.length) return paths + const scored: Array> = [] + for (const p of paths) { + const result = score(query, p) + if (result.path) scored.push(result) + } + return scored.sort(compare).map((x) => x.p) +} + +function rankBackend(query: string, paths: string[]): string[] { + if (!query || paths.length <= 1) return paths + return paths + .map((p) => score(query, p)) + .sort(compare) + .map((x) => x.p) +} + +export function mergeFileSearchResults(input: { + query: string + backend: string[] + open: Set + active?: string +}): string[] { + const norm = (p: string) => p.replaceAll("\\", "/") + const query = norm(input.query).trim().toLowerCase() + const open = new Set([...input.open].map(norm)) + const active = input.active ? norm(input.active) : undefined + const backend = input.backend.map(norm) + const matched = rankOpen(query, [...open]) + const tabs = (() => { + if (!active || !matched.includes(active)) return matched + return [active, ...matched.filter((p) => p !== active)] + })() + const seen = new Set(tabs) + const remaining = backend.filter((p) => !seen.has(p)) + return [...tabs, ...rankBackend(query, remaining)] +} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search.ts b/packages/kilo-vscode/src/kilo-provider/file-search.ts index dbac4cba7f..712ef68ff4 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search.ts @@ -1,7 +1,7 @@ import * as path from "path" import * as vscode from "vscode" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { mergeFileSearchResults } from "../kilo-provider-utils" +import { mergeFileSearchResults } from "./file-search-results" import { mergeFileSearchItems } from "./file-search-items" type Message = { @@ -42,7 +42,12 @@ export async function handleFileSearch(input: Input): Promise { const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined const result = mergeFileSearchResults({ query, backend: files, open, active }) - const items = mergeFileSearchItems({ query, files: result, folders }) + const items = mergeFileSearchItems({ + query, + files: result, + folders, + open: new Set(active ? [active, ...open] : open), + }) input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId }) }) } diff --git a/packages/kilo-vscode/src/kilo-provider/save-image.ts b/packages/kilo-vscode/src/kilo-provider/save-image.ts new file mode 100644 index 0000000000..6e24fa5380 --- /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 84cccf373b..dc4d54fe5b 100644 --- a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -7,13 +7,14 @@ interface Msg { baseBranch?: string branchName?: string sessionId?: string + turnId?: string } interface Ctx { post: (msg: unknown) => void openAgentManager: () => Thenable openAdvancedWorktree: () => Thenable - openChanges: (sessionId?: string) => Thenable + openChanges: (sessionId?: string, turnId?: string) => Thenable currentSessionId?: string createWorktree?: (baseBranch?: string, branchName?: string) => Promise continueInWorktree?: ( @@ -54,7 +55,7 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { } if (message.type === "openChanges") { - await ctx.openChanges(ctx.currentSessionId) + 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 1c5c3eafe4..b8fa13b3c0 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/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 3d8e408922..cae5ba1e0c 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -146,6 +146,7 @@ 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 @@ -178,6 +179,14 @@ function validateID( return null } +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 }), @@ -187,11 +196,14 @@ async function configs(ctx: ActionContext) { } async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfig) { - const { data: config } = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) + 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, features }) - ctx.postMessage({ type: "configUpdated", config, features }) + setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) } async function saveGlobal(ctx: ActionContext, config: Config) { @@ -241,11 +253,19 @@ async function enableConfigured(ctx: ActionContext, id: string, config: Config) await saveGlobal(ctx, { disabled_providers: disabled }) } -export async function connectProvider(ctx: ActionContext, requestId: string, providerID: string, apiKey: string) { +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 }) @@ -403,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/services/autocomplete/AutocompleteModel.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts deleted file mode 100644 index 020ae9651a..0000000000 --- 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 1beb1485ea..a272f67737 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 cce80476b8..0000000000 --- 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 cc1398cf57..1244b25b9e 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 9ee596bc23..4cc2eb0a53 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 328ba612eb..3971e785a1 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 01e51ac009..78c16079f5 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 6c6b2a1e0a..6a36ae3ab3 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 7821b65836..fc75473d7d 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 fa64a47745..0000000000 --- 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 b82d06d4ab..68b874a0b1 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 0000000000..8e5fcb429c --- /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 029d492129..9ec341406d 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 1fddf8b01e..765f1d6f4b 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/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts index 832cbc419a..79114ac578 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -10,9 +10,13 @@ export function registerCodeActions( agentManager?: AgentManagerProvider, ): void { const target = () => (agentManager?.isActive() ? agentManager : provider) + const reveal = async () => { + await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await provider.waitForReady() + } context.subscriptions.push( - vscode.commands.registerCommand("kilo-code.new.explainCode", () => { + vscode.commands.registerCommand("kilo-code.new.explainCode", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("EXPLAIN", { @@ -22,10 +26,11 @@ export function registerCodeActions( selectedText: ctx.selectedText, userInput: "", }) + await reveal() provider.postMessage({ type: "triggerTask", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.fixCode", () => { + vscode.commands.registerCommand("kilo-code.new.fixCode", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("FIX", { @@ -36,10 +41,11 @@ export function registerCodeActions( diagnostics: ctx.diagnostics, userInput: "", }) + await reveal() provider.postMessage({ type: "triggerTask", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.improveCode", () => { + vscode.commands.registerCommand("kilo-code.new.improveCode", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("IMPROVE", { @@ -49,10 +55,11 @@ export function registerCodeActions( selectedText: ctx.selectedText, userInput: "", }) + await reveal() provider.postMessage({ type: "triggerTask", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.addToContext", () => { + vscode.commands.registerCommand("kilo-code.new.addToContext", async () => { const ctx = getEditorContext() if (!ctx) return const prompt = createPrompt("ADD_TO_CONTEXT", { @@ -61,11 +68,19 @@ export function registerCodeActions( endLine: String(ctx.endLine), selectedText: ctx.selectedText, }) - target().postMessage({ type: "appendChatBoxMessage", text: prompt }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "appendChatBoxMessage", text: prompt }) }), - vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => { - target().postMessage({ type: "action", action: "focusInput" }) + vscode.commands.registerCommand("kilo-code.new.focusChatInput", async () => { + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "action", action: "focusInput" }) }), ) } diff --git a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts index b04a7db5a9..f0d2c89dc4 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts @@ -10,6 +10,10 @@ export function registerTerminalActions( agentManager?: AgentManagerProvider, ): void { const target = () => (agentManager?.isActive() ? agentManager : provider) + const reveal = async () => { + await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus") + await provider.waitForReady() + } context.subscriptions.push( vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", async (args: any) => { @@ -25,8 +29,12 @@ export function registerTerminalActions( terminalContent: content, userInput: "", }) - target().postMessage({ type: "appendChatBoxMessage", text: prompt }) - target().postMessage({ type: "action", action: "focusInput" }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "appendChatBoxMessage", text: prompt }) + view.postMessage({ type: "action", action: "focusInput" }) }), vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", async (args: any) => { @@ -42,7 +50,11 @@ export function registerTerminalActions( terminalContent: content, userInput: "", }) - target().postMessage({ type: "triggerTask", text: prompt }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "triggerTask", text: prompt }) }), vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", async (args: any) => { @@ -58,7 +70,11 @@ export function registerTerminalActions( terminalContent: content, userInput: "", }) - target().postMessage({ type: "triggerTask", text: prompt }) + const view = target() + if (view === provider) { + await reveal() + } + view.postMessage({ type: "triggerTask", text: prompt }) }), ) } diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts index bdd397327b..ecb8cd2e4a 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 2ed56fb027..d6ca56e838 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 410568378c..2c85e931f3 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 74c676a220..5993151d03 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 0000000000..fdd31318b1 --- /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 25a02adca2..b8f2110711 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -50,7 +50,10 @@ const mockVscode = { get: (_key: string, value?: T) => value, update: async () => {}, }), - asRelativePath: (pathOrUri: string) => pathOrUri, + asRelativePath: (pathOrUri: string | { fsPath?: string }) => { + const value = typeof pathOrUri === "string" ? pathOrUri : (pathOrUri.fsPath ?? "") + return value.startsWith("/repo/") ? value.slice("/repo/".length) : value + }, fs: { createDirectory: async () => {}, writeFile: async () => {}, @@ -95,6 +98,10 @@ const mockVscode = { registerCommand: () => ({ dispose: noop }), executeCommand: async () => {}, }, + languages: { + getDiagnostics: () => [], + registerCodeActionsProvider: () => ({ dispose: noop }), + }, CodeAction: class { command?: { command: string; title: string } isPreferred?: boolean 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 cd51acefe5..35690752dd 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -25,19 +25,25 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx"), path.join(ROOT, "webview-ui/agent-manager/MarkdownDiffView.tsx"), + path.join(ROOT, "webview-ui/agent-manager/MarkdownAnnotationLayer.tsx"), + path.join(ROOT, "webview-ui/agent-manager/markdown-comment-ranges.ts"), path.join(ROOT, "webview-ui/agent-manager/DiffEndMarker.tsx"), path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"), path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"), path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), - 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"), path.join(ROOT, "webview-ui/agent-manager/terminal/render.tsx"), path.join(ROOT, "webview-ui/diff-virtual/DiffVirtualApp.tsx"), + // Shared components that consume agent-manager CSS classes (e.g. am-dropdown, + // am-branch-item) used by both the agent manager and the diff viewer. + path.join(ROOT, "webview-ui/src/components/shared/BranchSelect.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/BaseBranchPicker.tsx"), ] const TSX_FILE = TSX_FILES[0]! const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") @@ -262,6 +268,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-diff-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts index 9eba96efb0..5a12d4aaa3 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test" import { mergeWorktreeDiffs } from "../../webview-ui/agent-manager/diff-state" -import { initialOpenFiles } from "../../webview-ui/agent-manager/diff-open-policy" +import { + EXTREME_DIFF_CHANGED_LINES, + allOpenFiles, + expandableOpenFiles, + initialOpenFiles, + toggleOpenFiles, +} from "../../webview-ui/agent-manager/diff-open-policy" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(overrides: Partial): WorktreeFileDiff { @@ -48,15 +54,46 @@ describe("agent manager diff state", () => { expect(result.stale).toEqual(new Set(["src/app.ts"])) }) - it("does not auto-open generated-like files or large diff sets", () => { + it("opens reviewable diffs initially", () => { expect( initialOpenFiles([ diff({ file: "src/app.ts", generatedLike: false, additions: 3 }), diff({ file: "node_modules/pkg/index.js", generatedLike: true, additions: 3 }), + diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), ]), ).toEqual(["src/app.ts"]) const many = Array.from({ length: 26 }, (_, i) => diff({ file: `src/${i}.ts` })) - expect(initialOpenFiles(many)).toEqual([]) + expect(initialOpenFiles(many)).toHaveLength(26) + }) + + it("expands only reviewable files from the bulk action", () => { + expect( + expandableOpenFiles([ + diff({ file: "src/app.ts", generatedLike: false, additions: 3 }), + diff({ file: "src/generated.ts", generatedLike: true, additions: 3 }), + diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), + ]), + ).toEqual(["src/app.ts"]) + }) + + it("toggles reviewable files based on whether every reviewable file is open", () => { + const diffs = [ + diff({ file: "src/app.ts" }), + diff({ file: "src/panel.ts" }), + diff({ file: "src/generated.ts", generatedLike: true }), + diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), + ] + + expect(allOpenFiles(diffs, [])).toBe(false) + expect(allOpenFiles(diffs, ["stale.ts"])).toBe(false) + expect(allOpenFiles(diffs, ["src/app.ts"])).toBe(false) + expect(allOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toBe(true) + expect(allOpenFiles(diffs, ["stale.ts", "src/app.ts", "src/panel.ts", "src/generated.ts"])).toBe(true) + + expect(toggleOpenFiles(diffs, [])).toEqual(["src/app.ts", "src/panel.ts"]) + expect(toggleOpenFiles(diffs, ["stale.ts"])).toEqual(["src/app.ts", "src/panel.ts"]) + expect(toggleOpenFiles(diffs, ["src/app.ts"])).toEqual(["src/app.ts", "src/panel.ts"]) + expect(toggleOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toEqual([]) }) }) 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 0000000000..1996041e91 --- /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 8c353eb2b1..16b433009b 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-session-source.test.ts b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts index b1c8cacb4e..3aa9be076d 100644 --- a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from "bun:test" import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" -import { SessionDiffSource, type SessionDiffFetch, type SnapshotEnabledCheck } from "../../src/diff/sources/session" -import type { DiffSourceMessage } from "../../src/diff/sources/types" +import { + createSessionDiffSource, + sessionDescriptor, + type SessionDiffFetch, + type SnapshotEnabledCheck, +} from "../../src/diff/sources/session" type FetchCall = { sessionID: string; directory?: string } @@ -15,11 +19,6 @@ function recording(result: SnapshotFileDiff[] | Error): { fetch: SessionDiffFetc return { fetch, calls } } -function collect(): { post: (msg: DiffSourceMessage) => void; messages: DiffSourceMessage[] } { - const messages: DiffSourceMessage[] = [] - return { post: (msg) => messages.push(msg), messages } -} - const modifiedPatch = [ "diff --git a/foo.ts b/foo.ts", "--- a/foo.ts", @@ -28,22 +27,18 @@ const modifiedPatch = [ " keep", "-old", "+new", + "", ].join("\n") -describe("SessionDiffSource.initialFetch", () => { - it("posts loading/diffs/loading for an empty session", async () => { +describe("createSessionDiffSource.fetch", () => { + it("returns empty diffs for an empty session", async () => { const { fetch, calls } = recording([]) - const source = new SessionDiffSource("s1", fetch, "/repo") - const { post, messages } = collect() + const source = createSessionDiffSource("s1", fetch, "/repo") - await source.initialFetch(post) + const result = await source.fetch() expect(calls).toEqual([{ sessionID: "s1", directory: "/repo" }]) - expect(messages).toEqual([ - { type: "loading", loading: true }, - { type: "diffs", diffs: [] }, - { type: "loading", loading: false }, - ]) + expect(result).toEqual({ diffs: [] }) }) it("converts patches into before/after diffs", async () => { @@ -64,19 +59,16 @@ describe("SessionDiffSource.initialFetch", () => { }, ] const { fetch } = recording(raw) - const source = new SessionDiffSource("s2", fetch, "/repo") - const { post, messages } = collect() + const source = createSessionDiffSource("s2", fetch, "/repo") - await source.initialFetch(post) + const result = await source.fetch() - const diffsMsg = messages.find((m) => m.type === "diffs") - if (diffsMsg?.type !== "diffs") throw new Error("expected diffs message") - expect(diffsMsg.diffs).toHaveLength(2) + expect(result.diffs).toHaveLength(2) - const foo = diffsMsg.diffs[0]! + const foo = result.diffs[0]! expect(foo.file).toBe("foo.ts") - expect(foo.before).toBe("keep\nold") - expect(foo.after).toBe("keep\nnew") + 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") @@ -84,94 +76,80 @@ describe("SessionDiffSource.initialFetch", () => { expect(foo.generatedLike).toBe(false) expect(foo.summarized).toBe(false) - const big = diffsMsg.diffs[1]! + const big = result.diffs[1]! expect(big.summarized).toBe(true) expect(big.before).toBe("") expect(big.after).toBe("") }) - it("reports an error when the fetch throws", async () => { + it("propagates errors from the underlying fetch", async () => { const { fetch } = recording(new Error("network down")) - const source = new SessionDiffSource("s3", fetch) - const { post, messages } = collect() + const source = createSessionDiffSource("s3", fetch) - await source.initialFetch(post) - - expect(messages).toEqual([ - { type: "loading", loading: true }, - { type: "error", message: "network down" }, - { type: "loading", loading: false }, - ]) + 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 = new SessionDiffSource("s4", fetch) - const { post } = collect() + const source = createSessionDiffSource("s4", fetch) - await source.initialFetch(post) + await source.fetch() expect(calls).toEqual([{ sessionID: "s4", directory: undefined }]) }) }) -describe("SessionDiffSource lifecycle", () => { - it("dispose does not throw", () => { - const { fetch } = recording([]) - const source = new SessionDiffSource("s6", fetch) - source.dispose() - }) - - it("descriptor id encodes the session id", () => { - const { fetch } = recording([]) - const source = new SessionDiffSource("abc", fetch) +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("posts the snapshots-disabled notice and skips fetch when the check returns false", async () => { + 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 = new SessionDiffSource("s-disabled", fetch, "/repo", checkSnapshotsEnabled) - const { post, messages } = collect() + const source = createSessionDiffSource("s-disabled", fetch, "/repo", checkSnapshotsEnabled) - await source.initialFetch(post) + const result = await source.fetch() expect(calls).toEqual([]) - expect(messages).toEqual([ - { type: "loading", loading: true }, - { type: "notice", notice: "snapshots-disabled" }, - { type: "diffs", diffs: [] }, - { type: "loading", loading: false }, - ]) + 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 = new SessionDiffSource("s-enabled", fetch, "/repo", checkSnapshotsEnabled) - const { post, messages } = collect() + const source = createSessionDiffSource("s-enabled", fetch, "/repo", checkSnapshotsEnabled) - await source.initialFetch(post) + const result = await source.fetch() expect(calls).toEqual([{ sessionID: "s-enabled", directory: "/repo" }]) - expect(messages.some((m) => m.type === "notice")).toBe(false) - expect(messages.filter((m) => m.type === "diffs")).toHaveLength(1) - }) - - it("start() is a no-op when snapshots are disabled", async () => { - const { fetch } = recording([]) - const checkSnapshotsEnabled: SnapshotEnabledCheck = async () => false - const source = new SessionDiffSource("s-disabled-2", fetch, "/repo", checkSnapshotsEnabled) - const { post } = collect() - - await source.initialFetch(post) - const disposable = source.start(post) - expect(typeof disposable.dispose).toBe("function") - // Disposing must not throw even though no interval was scheduled. - disposable.dispose() + 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 index cd37bb906f..6466df3d1a 100644 --- a/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-source-catalog.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from "bun:test" import type { KiloConnectionService } from "../../src/services/cli-backend" import { DiffSourceCatalog } from "../../src/diff/sources/catalog" -import { SessionDiffSource, sessionDescriptor } from "../../src/diff/sources/session" -import { WORKSPACE_DESCRIPTOR, WorktreeDiffSource } from "../../src/diff/sources/worktree" +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 constructors, so we never exercise -// any of its methods in these tests. +// 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 { @@ -14,14 +14,14 @@ function makeCatalog(): DiffSourceCatalog { } describe("DiffSourceCatalog.listAvailable", () => { - it("returns workspace + session when both are available", () => { + it("returns workspace + staged + unstaged + session when both are available", () => { const out = makeCatalog().listAvailable({ workspaceRoot: "/repo", sessionId: "s1" }) - expect(out.map((d) => d.id)).toEqual(["workspace", "session:s1"]) + expect(out.map((d) => d.id)).toEqual(["workspace", "staged", "unstaged", "session:s1"]) }) - it("returns only workspace when sessionId is missing", () => { + it("returns workspace + staged + unstaged when sessionId is missing", () => { const out = makeCatalog().listAvailable({ workspaceRoot: "/repo" }) - expect(out.map((d) => d.id)).toEqual(["workspace"]) + expect(out.map((d) => d.id)).toEqual(["workspace", "staged", "unstaged"]) }) it("returns only session when workspaceRoot is missing", () => { @@ -33,6 +33,11 @@ describe("DiffSourceCatalog.listAvailable", () => { 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", () => { @@ -67,18 +72,34 @@ describe("DiffSourceCatalog.defaultSourceId", () => { }) describe("DiffSourceCatalog.build", () => { - it("builds a WorktreeDiffSource for 'workspace'", () => { + it("builds a workspace source for 'workspace'", () => { const src = makeCatalog().build("workspace", { workspaceRoot: "/repo" }) - expect(src).toBeInstanceOf(WorktreeDiffSource) expect(src.descriptor.id).toBe("workspace") - src.dispose() + expect(src.descriptor.type).toBe("workspace") + expect(src.revert).toBeDefined() + expect(src.fetchFile).toBeDefined() + src.dispose?.() }) - it("builds a SessionDiffSource for 'session:'", () => { + it("builds a session source for 'session:'", () => { const src = makeCatalog().build("session:s1", { workspaceRoot: "/repo", sessionId: "s1" }) - expect(src).toBeInstanceOf(SessionDiffSource) expect(src.descriptor.id).toBe("session:s1") - src.dispose() + 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", () => { @@ -101,3 +122,10 @@ describe("descriptor types", () => { expect(sessionDescriptor("s1").type).toBe("session") }) }) + +describe("DiffSourceCatalog.dispose", () => { + it("disposes without throwing when no branch resources were created", () => { + const cat = makeCatalog() + expect(() => cat.dispose()).not.toThrow() + }) +}) 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 0000000000..55e5055d07 --- /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 0000000000..67d18caadb --- /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/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index a35bb14822..2846dd030f 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -5,6 +5,7 @@ import { buildTextAfterMentionSelect, buildFileAttachments, buildMentionResults, + filterMentionResults, } from "../../webview-ui/src/hooks/file-mention-utils" describe("AT_PATTERN", () => { @@ -71,6 +72,21 @@ describe("buildMentionResults", () => { const result = buildMentionResults("src", [{ path: "src", type: "folder" }]) expect(result).toEqual([{ type: "folder", value: "src" }]) }) + + it("preserves opened file result type", () => { + const result = buildMentionResults("src", [{ path: "src/index.ts", type: "opened-file" }]) + expect(result).toEqual([{ type: "opened-file", value: "src/index.ts" }]) + }) +}) + +describe("filterMentionResults", () => { + it("keeps matching file results for the latest query", () => { + const result = filterMentionResults("gi", [ + { type: "file", value: "README.md" }, + { type: "file", value: "src/git.ts" }, + ]) + expect(result).toEqual([{ type: "file", value: "src/git.ts" }]) + }) }) describe("syncMentionedPaths", () => { diff --git a/packages/kilo-vscode/tests/unit/file-search-items.test.ts b/packages/kilo-vscode/tests/unit/file-search-items.test.ts new file mode 100644 index 0000000000..9b2f9af929 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-search-items.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items" + +describe("mergeFileSearchItems", () => { + it("puts exact folder matches before file matches", () => { + const result = mergeFileSearchItems({ + query: "script", + files: ["script/hooks", "script/release", "script/beta.ts"], + folders: ["script/", "script/run-script/"], + }) + expect(result).toEqual([ + { path: "script/", type: "folder" }, + { path: "script/hooks", type: "file" }, + { path: "script/release", type: "file" }, + { path: "script/beta.ts", type: "file" }, + { path: "script/run-script/", type: "folder" }, + ]) + }) + + it("keeps file ordering before non-prefix folder matches", () => { + const result = mergeFileSearchItems({ + query: "test", + files: ["src/test.ts"], + folders: ["src/latest/"], + }) + expect(result).toEqual([ + { path: "src/test.ts", type: "file" }, + { path: "src/latest/", type: "folder" }, + ]) + }) + + it("normalizes Windows separators for matching and output", () => { + const result = mergeFileSearchItems({ + query: "kilo-vscode", + files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"], + folders: ["packages\\kilo-vscode\\"], + }) + expect(result).toEqual([ + { path: "packages/kilo-vscode/", type: "folder" }, + { path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" }, + ]) + }) + + it("keeps active and open file results before prefix folder matches", () => { + const result = mergeFileSearchItems({ + query: "e", + files: ["sdks/vscode/src/extension.ts"], + folders: ["packages/extensions/", "packages/example/", "packages/core/src/effect/"], + open: new Set(["sdks/vscode/src/extension.ts"]), + }) + expect(result).toEqual([ + { path: "sdks/vscode/src/extension.ts", type: "opened-file" }, + { path: "packages/extensions/", type: "folder" }, + { path: "packages/example/", type: "folder" }, + { path: "packages/core/src/effect/", type: "folder" }, + ]) + }) + + it("keeps opened files as a distinct priority group before non-open files", () => { + const result = mergeFileSearchItems({ + query: "test", + files: ["src/test.ts", "src/test-helper.ts"], + folders: ["test/"], + open: new Set(["src/test-helper.ts"]), + }) + expect(result).toEqual([ + { path: "src/test-helper.ts", type: "opened-file" }, + { path: "test/", type: "folder" }, + { path: "src/test.ts", type: "file" }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/file-search-results.test.ts b/packages/kilo-vscode/tests/unit/file-search-results.test.ts new file mode 100644 index 0000000000..b1505d65ff --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-search-results.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test" +import { mergeFileSearchResults } from "../../src/kilo-provider/file-search-results" + +describe("mergeFileSearchResults", () => { + it("returns backend results when no open files", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts"], + open: new Set(), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("places open files before backend results", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts", "src/c.ts"], + open: new Set(["src/c.ts", "src/d.ts"]), + }) + expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"]) + }) + + it("places active file first among open files", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts"], + open: new Set(["src/b.ts", "src/c.ts"]), + active: "src/c.ts", + }) + expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]) + }) + + it("ignores active file when it is not in open set", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts"], + open: new Set(["src/b.ts"]), + active: "src/x.ts", + }) + expect(result).toEqual(["src/b.ts", "src/a.ts"]) + }) + + it("deduplicates open files from backend results", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts"], + open: new Set(["src/a.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("filters open files by query", () => { + const result = mergeFileSearchResults({ + query: "config", + backend: ["src/config.ts", "src/util.ts"], + open: new Set(["src/index.ts", "src/config.ts", "README.md"]), + }) + expect(result).toEqual(["src/config.ts", "src/util.ts"]) + }) + + it("query filtering is case-insensitive", () => { + const result = mergeFileSearchResults({ + query: "READ", + backend: [], + open: new Set(["README.md", "src/index.ts"]), + }) + expect(result).toEqual(["README.md"]) + }) + + it("shows all open files on empty query", () => { + const result = mergeFileSearchResults({ + query: "", + backend: [], + open: new Set(["src/a.ts", "src/b.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("shows all open files on whitespace-only query", () => { + const result = mergeFileSearchResults({ + query: " ", + backend: ["src/x.ts"], + open: new Set(["src/a.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/x.ts"]) + }) + + it("handles forward-slash paths (Windows-normalized)", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/utils/path.ts"], + open: new Set(["src/utils/path.ts", "src/index.ts"]), + active: "src/utils/path.ts", + }) + expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) + }) + + it("normalizes backslash paths before filtering and deduping", () => { + const result = mergeFileSearchResults({ + query: "utils/path", + backend: ["src\\utils\\path.ts"], + open: new Set(["src/utils/path.ts"]), + active: "src\\utils\\path.ts", + }) + expect(result).toEqual(["src/utils/path.ts"]) + }) + + it("includes open tabs that fuzzy-match but are not substring matches", () => { + const result = mergeFileSearchResults({ + query: "authn", + backend: [], + open: new Set(["authentication.ts", "unrelated.ts"]), + }) + expect(result).toContain("authentication.ts") + expect(result).not.toContain("unrelated.ts") + }) + + it("ranks open tabs by fuzzy match quality, not insertion order", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: [], + open: new Set(["long-authentication-module.ts", "auth.ts"]), + }) + expect(result[0]).toBe("auth.ts") + }) + + it("ranks open tabs by filename before directory matches", () => { + const result = mergeFileSearchResults({ + query: "provider", + backend: [], + open: new Set(["src/provider/auth.ts", "src/provider.ts"]), + }) + expect(result).toEqual(["src/provider.ts", "src/provider/auth.ts"]) + }) + + it("boosts backend results where query matches the basename over full-path matches", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: ["src/authentication-module.ts", "packages/a/b/c/d/auth.ts"], + open: new Set(), + }) + expect(result.indexOf("packages/a/b/c/d/auth.ts")).toBeLessThan(result.indexOf("src/authentication-module.ts")) + }) + + it("uses path depth as tiebreaker when basename scores are equal", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: ["a/b/c/d/e/auth-service.ts", "src/auth-service.ts"], + open: new Set(), + }) + expect(result.indexOf("src/auth-service.ts")).toBeLessThan(result.indexOf("a/b/c/d/e/auth-service.ts")) + }) + + it("uses filename length as tiebreaker before path depth", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: ["src/authentication-module.ts", "a/b/c/auth.ts"], + open: new Set(), + }) + expect(result[0]).toBe("a/b/c/auth.ts") + }) + + it("preserves camel-case scoring for acronym-style queries", () => { + const result = mergeFileSearchResults({ + query: "amprov", + backend: [ + "packages/kilo-docs/pages/contributing/architecture/onboarding-improvements.md", + "packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts", + ], + open: new Set(), + }) + expect(result[0]).toBe("packages/kilo-vscode/src/agent-manager/AgentManagerProvider.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 c1f8386e2b..a8b1981daa 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 0000000000..00965b8e2b --- /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-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 06641023b9..d54327ad3e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -8,12 +8,10 @@ import { isEventFromForeignProject, mapCloudSessionMessageToWebviewMessage, MessageConfirmation, - mergeFileSearchResults, getErrorMessage, getConfigErrorDetails, type ProviderInfo, } from "../../src/kilo-provider-utils" -import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items" import type { CloudSessionMessage } from "../../src/services/cli-backend/types" import type { Session, @@ -607,152 +605,6 @@ describe("mapCloudSessionMessage", () => { }) }) -describe("mergeFileSearchItems", () => { - it("puts exact folder matches before file matches", () => { - const result = mergeFileSearchItems({ - query: "script", - files: ["script/hooks", "script/release", "script/beta.ts"], - folders: ["script/", "script/run-script/"], - }) - expect(result).toEqual([ - { path: "script/", type: "folder" }, - { path: "script/hooks", type: "file" }, - { path: "script/release", type: "file" }, - { path: "script/beta.ts", type: "file" }, - { path: "script/run-script/", type: "folder" }, - ]) - }) - - it("keeps file ordering before non-prefix folder matches", () => { - const result = mergeFileSearchItems({ - query: "test", - files: ["src/test.ts"], - folders: ["src/latest/"], - }) - expect(result).toEqual([ - { path: "src/test.ts", type: "file" }, - { path: "src/latest/", type: "folder" }, - ]) - }) - - it("normalizes Windows separators for matching and output", () => { - const result = mergeFileSearchItems({ - query: "kilo-vscode", - files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"], - folders: ["packages\\kilo-vscode\\"], - }) - expect(result).toEqual([ - { path: "packages/kilo-vscode/", type: "folder" }, - { path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" }, - ]) - }) -}) - -describe("mergeFileSearchResults", () => { - it("returns backend results when no open files", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts", "src/b.ts"], - open: new Set(), - }) - expect(result).toEqual(["src/a.ts", "src/b.ts"]) - }) - - it("places open files before backend results", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts", "src/b.ts", "src/c.ts"], - open: new Set(["src/c.ts", "src/d.ts"]), - }) - expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"]) - }) - - it("places active file first among open files", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts"], - open: new Set(["src/b.ts", "src/c.ts"]), - active: "src/c.ts", - }) - expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]) - }) - - it("ignores active file when it is not in open set", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts"], - open: new Set(["src/b.ts"]), - active: "src/x.ts", - }) - expect(result).toEqual(["src/b.ts", "src/a.ts"]) - }) - - it("deduplicates open files from backend results", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts", "src/b.ts"], - open: new Set(["src/a.ts"]), - }) - expect(result).toEqual(["src/a.ts", "src/b.ts"]) - }) - - it("filters open files by query", () => { - const result = mergeFileSearchResults({ - query: "config", - backend: ["src/config.ts", "src/util.ts"], - open: new Set(["src/index.ts", "src/config.ts", "README.md"]), - }) - expect(result).toEqual(["src/config.ts", "src/util.ts"]) - }) - - it("query filtering is case-insensitive", () => { - const result = mergeFileSearchResults({ - query: "READ", - backend: [], - open: new Set(["README.md", "src/index.ts"]), - }) - expect(result).toEqual(["README.md"]) - }) - - it("shows all open files on empty query", () => { - const result = mergeFileSearchResults({ - query: "", - backend: [], - open: new Set(["src/a.ts", "src/b.ts"]), - }) - expect(result).toEqual(["src/a.ts", "src/b.ts"]) - }) - - it("shows all open files on whitespace-only query", () => { - const result = mergeFileSearchResults({ - query: " ", - backend: ["src/x.ts"], - open: new Set(["src/a.ts"]), - }) - expect(result).toEqual(["src/a.ts", "src/x.ts"]) - }) - - it("handles forward-slash paths (Windows-normalized)", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/utils/path.ts"], - open: new Set(["src/utils/path.ts", "src/index.ts"]), - active: "src/utils/path.ts", - }) - expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) - }) - - it("normalizes backslash paths before filtering and deduping", () => { - const result = mergeFileSearchResults({ - query: "utils/path", - backend: ["src\\utils\\path.ts"], - open: new Set(["src/utils/path.ts"]), - active: "src\\utils\\path.ts", - }) - expect(result).toEqual(["src/utils/path.ts"]) - }) -}) - describe("getErrorMessage", () => { it("extracts message from an Error instance", () => { expect(getErrorMessage(new Error("boom"))).toBe("boom") 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 2db786d39d..727c6a9b2e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -136,9 +136,31 @@ describe("Edit tool diff-first click contract (source)", () => { const editBlockMatch = src.match(/ToolRegistry\.register\(\{\s*name:\s*"edit"[\s\S]*?(?=ToolRegistry\.register\(|$)/) 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/) + it("edit tool renders from filediff.patch and falls back to tool input", () => { + expect(editBlock).toContain("normalize(diff)") + expect(editBlock).toMatch(/props\.input\.oldString\s*\?\?\s*""/) + expect(editBlock).toMatch(/props\.input\.newString\s*\?\?\s*""/) + }) +}) + +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("normalize(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("normalize({") + expect(patchBlock).toContain("file: file.relativePath") + expect(patchBlock).toContain('mode="diff"') }) }) diff --git a/packages/kilo-vscode/tests/unit/promotion-handoff.test.ts b/packages/kilo-vscode/tests/unit/promotion-handoff.test.ts new file mode 100644 index 0000000000..401b3418e3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/promotion-handoff.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, mock } from "bun:test" +import { handoffText, recordPromotionHandoff } from "../../src/agent-manager/promotion-handoff" + +describe("promotion handoff", () => { + it("describes the new worktree location", () => { + const text = handoffText({ directory: "/repo/.kilo/worktrees/feature", branch: "feature/test" }) + + expect(text).toContain("This session was moved to a git worktree.") + expect(text).toContain("Use this as the current working directory: /repo/.kilo/worktrees/feature") + expect(text).toContain("The worktree branch is: feature/test") + }) + + it("records a hidden no-reply handoff in the worktree instance", async () => { + const promptAsync = mock(async () => ({})) + const client = { session: { promptAsync } } + + await recordPromotionHandoff({ + client: client as never, + sessionId: "session-1", + directory: "/repo/.kilo/worktrees/feature", + branch: "feature/test", + }) + + expect(promptAsync).toHaveBeenCalledWith( + { + sessionID: "session-1", + directory: "/repo/.kilo/worktrees/feature", + noReply: true, + parts: [ + { + type: "text", + text: handoffText({ directory: "/repo/.kilo/worktrees/feature", branch: "feature/test" }), + synthetic: true, + }, + ], + }, + { throwOnError: true }, + ) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index dc1d97f27c..375a44efdc 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -1,11 +1,11 @@ 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: [] }, 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 }>, @@ -18,7 +18,10 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }, merged 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 } }, @@ -117,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() diff --git a/packages/kilo-vscode/tests/unit/register-code-actions.test.ts b/packages/kilo-vscode/tests/unit/register-code-actions.test.ts new file mode 100644 index 0000000000..f6354a03cc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/register-code-actions.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { registerCodeActions } from "../../src/services/code-actions/register-code-actions" + +type Command = (...args: unknown[]) => unknown + +type Api = typeof vscode & { + commands: { + registerCommand: (command: string, callback: Command) => { dispose(): void } + executeCommand: (...args: unknown[]) => Promise + } + languages: { + getDiagnostics: () => Array<{ range: { intersection: () => unknown } }> + } + window: typeof vscode.window & { activeTextEditor?: unknown } +} + +const api = vscode as Api +const original = { + register: api.commands.registerCommand, + execute: api.commands.executeCommand, + editor: api.window.activeTextEditor, + diagnostics: api.languages.getDiagnostics, +} + +function setup(active = false) { + const commands = new Map() + const executed: unknown[][] = [] + const events: string[] = [] + const posts: unknown[] = [] + const waits: string[] = [] + const context = { subscriptions: [] as Array<{ dispose(): void }> } as vscode.ExtensionContext + const provider = { + postMessage: (msg: unknown) => { + events.push("post") + posts.push(msg) + }, + waitForReady: async () => { + events.push("wait") + waits.push("provider") + }, + } + const agent = { + isActive: () => active, + postMessage: (msg: unknown) => { + events.push("post") + posts.push(msg) + }, + } + + api.commands.registerCommand = (command, callback) => { + commands.set(command, callback) + return { dispose: () => undefined } + } + api.commands.executeCommand = async (...args) => { + events.push("focus") + executed.push(args) + } + api.languages.getDiagnostics = () => [] + api.window.activeTextEditor = { + selection: { + isEmpty: false, + start: { line: 2 }, + end: { line: 4 }, + }, + document: { + uri: vscode.Uri.file("/repo/src/file.ts"), + getText: () => "const value = 1", + }, + } + + registerCodeActions(context, provider as never, agent as never) + + return { commands, events, executed, posts, waits } +} + +afterEach(() => { + api.commands.registerCommand = original.register + api.commands.executeCommand = original.execute + api.window.activeTextEditor = original.editor + api.languages.getDiagnostics = original.diagnostics +}) + +describe("registerCodeActions", () => { + it("reveals the sidebar before adding selected code to context", async () => { + const state = setup() + + await state.commands.get("kilo-code.new.addToContext")?.() + + expect(state.events).toEqual(["focus", "wait", "post"]) + expect(state.executed).toEqual([["kilo-code.SidebarProvider.focus"]]) + expect(state.waits).toEqual(["provider"]) + expect(state.posts).toEqual([ + { + type: "appendChatBoxMessage", + text: "src/file.ts:3-5\n```\nconst value = 1\n```", + }, + ]) + }) + + it("adds selected code to the active Agent Manager without revealing the sidebar", async () => { + const state = setup(true) + + await state.commands.get("kilo-code.new.addToContext")?.() + + expect(state.events).toEqual(["post"]) + expect(state.executed).toEqual([]) + expect(state.waits).toEqual([]) + expect(state.posts).toEqual([ + { + type: "appendChatBoxMessage", + text: "src/file.ts:3-5\n```\nconst value = 1\n```", + }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/register-terminal-actions.test.ts b/packages/kilo-vscode/tests/unit/register-terminal-actions.test.ts new file mode 100644 index 0000000000..f599f9c4ae --- /dev/null +++ b/packages/kilo-vscode/tests/unit/register-terminal-actions.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { registerTerminalActions } from "../../src/services/code-actions/register-terminal-actions" + +type Command = (...args: unknown[]) => unknown + +type Api = typeof vscode & { + commands: { + registerCommand: (command: string, callback: Command) => { dispose(): void } + executeCommand: (...args: unknown[]) => Promise + } +} + +const api = vscode as Api +const original = { + register: api.commands.registerCommand, + execute: api.commands.executeCommand, +} + +function setup(active = false) { + const commands = new Map() + const executed: unknown[][] = [] + const events: string[] = [] + const posts: unknown[] = [] + const waits: string[] = [] + const context = { subscriptions: [] as Array<{ dispose(): void }> } as vscode.ExtensionContext + const provider = { + postMessage: (msg: unknown) => { + events.push("post") + posts.push(msg) + }, + waitForReady: async () => { + events.push("wait") + waits.push("provider") + }, + } + const agent = { + isActive: () => active, + postMessage: (msg: unknown) => { + events.push("post") + posts.push(msg) + }, + } + + api.commands.registerCommand = (command, callback) => { + commands.set(command, callback) + return { dispose: () => undefined } + } + api.commands.executeCommand = async (...args) => { + events.push("focus") + executed.push(args) + } + + registerTerminalActions(context, provider as never, agent as never) + + return { commands, events, executed, posts, waits } +} + +afterEach(() => { + api.commands.registerCommand = original.register + api.commands.executeCommand = original.execute +}) + +describe("registerTerminalActions", () => { + it("reveals the sidebar before adding terminal output to context", async () => { + const state = setup() + + await state.commands.get("kilo-code.new.terminalAddToContext")?.({ selection: "bun test" }) + + expect(state.events).toEqual(["focus", "wait", "post", "post"]) + expect(state.executed).toEqual([["kilo-code.SidebarProvider.focus"]]) + expect(state.waits).toEqual(["provider"]) + expect(state.posts).toEqual([ + { + type: "appendChatBoxMessage", + text: "\nTerminal output:\n```\nbun test\n```", + }, + { type: "action", action: "focusInput" }, + ]) + }) + + it("adds terminal output to the active Agent Manager without revealing the sidebar", async () => { + const state = setup(true) + + await state.commands.get("kilo-code.new.terminalAddToContext")?.({ selection: "bun test" }) + + expect(state.events).toEqual(["post", "post"]) + expect(state.executed).toEqual([]) + expect(state.waits).toEqual([]) + expect(state.posts).toEqual([ + { + type: "appendChatBoxMessage", + text: "\nTerminal output:\n```\nbun test\n```", + }, + { type: "action", action: "focusInput" }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index bc31456698..247da56c47 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -7,6 +7,7 @@ import { getFilename, type ReviewComment, } from "../../webview-ui/agent-manager/review-comments" +import { markdownCommentBlocks } from "../../webview-ui/agent-manager/markdown-comment-ranges" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(file: string, before: string, after: string): WorktreeFileDiff { @@ -186,6 +187,55 @@ describe("extractLines", () => { }) }) +// ── markdownCommentBlocks ─────────────────────────────────────────────────── + +describe("markdownCommentBlocks", () => { + it("keeps fenced code blocks as one selectable range", () => { + const result = markdownCommentBlocks("# Demo\n\n```ts\nconst value = 1\nconsole.log(value)\n```\n\nAfter") + expect(result).toContainEqual({ type: "block", start: 3, end: 6 }) + }) + + it("maps table comments to rendered rows, not separator lines", () => { + const result = markdownCommentBlocks("| Area | Status |\n|---|---|\n| Tables | Pass |\n| Lists | Pass |") + expect(result).toEqual([ + { + type: "table", + start: 1, + end: 4, + rows: [ + { start: 1, end: 1 }, + { start: 3, end: 3 }, + { start: 4, end: 4 }, + ], + }, + ]) + }) + + it("maps list comments to whole list items", () => { + const result = markdownCommentBlocks("- First\n continued\n- [x] Second\n- Third") + expect(result).toEqual([ + { + type: "list", + start: 1, + end: 4, + items: [ + { start: 1, end: 2 }, + { start: 3, end: 3 }, + { start: 4, end: 4 }, + ], + }, + ]) + }) + + it("keeps blockquotes and paragraphs as rendered blocks", () => { + const result = markdownCommentBlocks("> Quote\n> Continued\n\nParagraph line one\nparagraph line two") + expect(result).toEqual([ + { type: "block", start: 1, end: 2 }, + { type: "block", start: 4, end: 5 }, + ]) + }) +}) + // ── getDirectory / getFilename ────────────────────────────────────────────── describe("getDirectory", () => { diff --git a/packages/kilo-vscode/tests/unit/section-helpers.test.ts b/packages/kilo-vscode/tests/unit/section-helpers.test.ts index 597e5bca09..09b456e3e2 100644 --- a/packages/kilo-vscode/tests/unit/section-helpers.test.ts +++ b/packages/kilo-vscode/tests/unit/section-helpers.test.ts @@ -33,14 +33,14 @@ describe("buildTopLevelItems", () => { expect(result.every((r) => r.kind === "worktree")).toBe(true) }) - it("interleaves sections and worktrees per order", () => { + it("places ungrouped worktrees before sections", () => { const s1 = sec("s1", 0) const w1 = wt("w1") const s2 = sec("s2", 1) const result = buildTopLevelItems([s1, s2], [w1], [w1], ["s1", "w1", "s2"]) expect(result).toHaveLength(3) - expect(result[0]).toEqual({ kind: "section", section: s1 }) - expect(result[1]).toEqual({ kind: "worktree", wt: w1 }) + expect(result[0]).toEqual({ kind: "worktree", wt: w1 }) + expect(result[1]).toEqual({ kind: "section", section: s1 }) expect(result[2]).toEqual({ kind: "section", section: s2 }) }) @@ -52,11 +52,10 @@ describe("buildTopLevelItems", () => { // Only s1 is in the order array const result = buildTopLevelItems([s1, s2], [w1, w2], [w1, w2], ["s1", "w1"]) expect(result).toHaveLength(4) - expect(result[0]).toEqual({ kind: "section", section: s1 }) - expect(result[1]).toEqual({ kind: "worktree", wt: w1 }) - // unordered items appended - expect(result[2]).toEqual({ kind: "section", section: s2 }) - expect(result[3]).toEqual({ kind: "worktree", wt: w2 }) + expect(result[0]).toEqual({ kind: "worktree", wt: w1 }) + expect(result[1]).toEqual({ kind: "worktree", wt: w2 }) + expect(result[2]).toEqual({ kind: "section", section: s1 }) + expect(result[3]).toEqual({ kind: "section", section: s2 }) }) it("skips duplicate ids in order array", () => { @@ -72,8 +71,8 @@ describe("buildTopLevelItems", () => { const w2 = wt("w2") const result = buildTopLevelItems([s1], [w2], [w1, w2], ["w1", "s1", "w2"]) expect(result).toEqual([ - { kind: "section", section: s1 }, { kind: "worktree", wt: w2 }, + { kind: "section", section: s1 }, ]) }) }) @@ -86,6 +85,12 @@ describe("completeSidebarOrder", () => { expect(completeSidebarOrder([s1], [w1, w2], ["w2", "s1"])).toEqual(["w2", "s1", "w1"]) }) + it("normalizes ungrouped worktrees above sections", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1") + expect(completeSidebarOrder([s1], [w1], ["s1", "w1"])).toEqual(["w1", "s1"]) + }) + it("drops stale ids and skips duplicates", () => { const s1 = sec("s1", 0) const w1 = wt("w1") @@ -160,7 +165,7 @@ describe("buildSidebarOrder", () => { ]) }) - it("includes section worktrees in visual order", () => { + it("keeps ungrouped worktrees above section worktrees", () => { const s1 = sec("s1", 0) const w1 = wt("w1", { sectionId: "s1" }) const w2 = wt("w2", { sectionId: "s1" }) @@ -171,9 +176,9 @@ describe("buildSidebarOrder", () => { const result = buildSidebarOrder(items, sorted, [s1], members, []) expect(result).toEqual([ { type: "local", id: "local" }, + { type: "wt", id: "w3" }, { type: "wt", id: "w1" }, { type: "wt", id: "w2" }, - { type: "wt", id: "w3" }, ]) }) @@ -191,7 +196,7 @@ describe("buildSidebarOrder", () => { ]) }) - it("respects section order between sections and ungrouped worktrees", () => { + it("respects section order after ungrouped worktrees", () => { const s1 = sec("s1", 0) const s2 = sec("s2", 1) const w1 = wt("w1", { sectionId: "s1" }) @@ -205,7 +210,7 @@ describe("buildSidebarOrder", () => { return [] } const result = buildSidebarOrder(items, sorted, [s1, s2], members, []) - expect(result.map((r) => r.id)).toEqual(["local", "w1", "w2", "w3"]) + expect(result.map((r) => r.id)).toEqual(["local", "w2", "w1", "w3"]) }) it("appends unassigned sessions after worktrees", () => { 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 13ccd532f9..bb08e44b82 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 8c53fa8821..64454c9e51 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/slim-metadata.test.ts b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts index 7435dddb2e..0dce0fe5da 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 index 509a7e5c19..f9a49cd2cb 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "bun:test" -import * as vscode from "vscode" import { SourceController } from "../../src/diff/SourceController" -import type { DiffSource, DiffSourceDescriptor, DiffSourcePost } from "../../src/diff/sources/types" +import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../../src/diff/sources/types" const WORKSPACE_DESC: DiffSourceDescriptor = { id: "workspace", @@ -17,10 +16,6 @@ const SESSION_DESC: DiffSourceDescriptor = { capabilities: { revert: false, comments: true }, } -function disposable(onDispose: () => void = () => {}): vscode.Disposable { - return new vscode.Disposable(onDispose) -} - function make(sources: Record, descriptors?: DiffSourceDescriptor[]) { const posted: unknown[] = [] const controller = new SourceController( @@ -41,25 +36,21 @@ const byType = (posted: unknown[], type: string) => }) describe("SourceController.activate", () => { - it("builds, fetches, and starts the source", async () => { - let starts = 0 + it("builds, fetches, and posts available sources + capabilities + diffs", async () => { + let fetches = 0 const source: DiffSource = { descriptor: SESSION_DESC, - async initialFetch(post) { - post({ type: "diffs", diffs: [] }) + async fetch(): Promise { + fetches++ + return { diffs: [] } }, - start() { - starts++ - return disposable() - }, - dispose() {}, } const { controller, posted } = make({ "session:s1": source }, [WORKSPACE_DESC, SESSION_DESC]) controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) await controller.activate("session:s1") - expect(starts).toBe(1) + expect(fetches).toBe(1) expect(controller.currentId).toBe("session:s1") const available = byType(posted, "setAvailableSources") @@ -70,32 +61,53 @@ describe("SourceController.activate", () => { 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 - let workspaceSubscriptionDisposed = false const workspace: DiffSource = { descriptor: WORKSPACE_DESC, - async initialFetch() {}, - start() { - return disposable(() => { - workspaceSubscriptionDisposed = true - }) + async fetch() { + return { diffs: [] } }, dispose() { workspaceDisposed++ }, } - let sessionStarts = 0 + let sessionFetches = 0 const session: DiffSource = { descriptor: SESSION_DESC, - async initialFetch() {}, - start() { - sessionStarts++ - return disposable() + async fetch() { + sessionFetches++ + return { diffs: [] } }, - dispose() {}, } const { controller } = make({ workspace, "session:s1": session }) @@ -104,31 +116,28 @@ describe("SourceController.activate", () => { await controller.activate("session:s1") expect(workspaceDisposed).toBe(1) - expect(workspaceSubscriptionDisposed).toBe(true) - expect(sessionStarts).toBe(1) + expect(sessionFetches).toBe(1) expect(controller.currentId).toBe("session:s1") + + controller.stop() }) - it("does not start polling if the controller is stopped during initialFetch", async () => { + it("drops a fetch result and disposes the source when stopped mid-fetch", async () => { let release: () => void = () => {} - let fetched = 0 - let started = 0 + let fetches = 0 let disposed = 0 const session: DiffSource = { descriptor: SESSION_DESC, - async initialFetch() { - fetched++ + async fetch() { + fetches++ await new Promise((r) => (release = r)) - }, - start() { - started++ - return disposable() + return { diffs: [] } }, dispose() { disposed++ }, } - const { controller } = make({ "session:s1": session }) + const { controller, posted } = make({ "session:s1": session }) controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) const activation = controller.activate("session:s1") @@ -136,57 +145,51 @@ describe("SourceController.activate", () => { release() await activation - expect(fetched).toBe(1) - expect(started).toBe(0) + 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 stale posts from a source that was swapped out", async () => { - let capturedPost: DiffSourcePost | undefined + it("drops a fetch result from a source that has been swapped out", async () => { + let release: () => void = () => {} const workspace: DiffSource = { descriptor: WORKSPACE_DESC, - async initialFetch(post) { - capturedPost = post + async fetch() { + await new Promise((r) => (release = r)) + return { diffs: [{ file: "stale.ts" } as never] } }, - start() { - return disposable() - }, - dispose() {}, } const session: DiffSource = { descriptor: SESSION_DESC, - async initialFetch(post) { - post({ type: "diffs", diffs: [] }) + async fetch() { + return { diffs: [] } }, - start() { - return disposable() - }, - dispose() {}, } const { controller, posted } = make({ workspace, "session:s1": session }) controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) - await controller.activate("workspace") + const first = controller.activate("workspace") await controller.activate("session:s1") + release() + await first - posted.length = 0 - capturedPost?.({ type: "diffs", diffs: [{ file: "stale.ts" } as never] }) + 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([]) - expect(byType(posted, "diffViewer.diffs")).toEqual([]) + controller.stop() }) }) describe("SourceController.stop", () => { - it("disposes the active source and its start subscription", async () => { + it("disposes the active source", async () => { let disposed = 0 - let subscriptionDisposed = false const session: DiffSource = { descriptor: SESSION_DESC, - async initialFetch() {}, - start() { - return disposable(() => { - subscriptionDisposed = true - }) + async fetch() { + return { diffs: [] } }, dispose() { disposed++ @@ -200,7 +203,12 @@ describe("SourceController.stop", () => { controller.stop() expect(disposed).toBe(1) - expect(subscriptionDisposed).toBe(true) + expect(controller.currentId).toBeUndefined() + }) + + it("is a no-op when no source is active", () => { + const { controller } = make({}) + controller.stop() expect(controller.currentId).toBeUndefined() }) }) @@ -209,11 +217,9 @@ describe("SourceController.revertFile", () => { it("posts error when the active source does not support revert", async () => { const session: DiffSource = { descriptor: SESSION_DESC, - async initialFetch() {}, - start() { - return disposable() + async fetch() { + return { diffs: [] } }, - dispose() {}, } const { controller, posted } = make({ "session:s1": session }) @@ -226,46 +232,51 @@ describe("SourceController.revertFile", () => { 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", async () => { - const calls: string[] = [] + 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 initialFetch() {}, - start() { - return disposable() + async fetch() { + fetches++ + return { diffs: [] } }, - async revertFile(file) { - calls.push(file) + async revert(file) { + reverts.push(file) return { ok: true, message: "Reverted" } }, - dispose() {}, } 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(calls).toEqual(["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 initialFetch() {}, - start() { - return disposable() + async fetch() { + return { diffs: [] } }, - async revertFile() { + async revert() { throw new Error("boom") }, - dispose() {}, } const { controller, posted } = make({ workspace }) @@ -277,5 +288,135 @@ describe("SourceController.revertFile", () => { 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() + }) +}) + +describe("SourceController.reactivate", () => { + it("rebuilds the active source via the build factory and refetches", async () => { + let builds = 0 + let fetches = 0 + const factory = (): DiffSource => { + builds++ + return { + descriptor: WORKSPACE_DESC, + async fetch() { + fetches++ + return { diffs: [], stopPolling: true } + }, + } + } + const posted: unknown[] = [] + const controller = new SourceController( + () => factory(), + () => [WORKSPACE_DESC], + (m) => posted.push(m), + ) + controller.setContext({ workspaceRoot: "/repo" }) + await controller.activate("workspace") + expect(builds).toBe(1) + expect(fetches).toBe(1) + + await controller.reactivate() + expect(builds).toBe(2) + expect(fetches).toBe(2) + + controller.stop() + }) + + it("is a no-op when nothing is active", async () => { + const posted: unknown[] = [] + const controller = new SourceController( + () => { + throw new Error("should not build") + }, + () => [], + (m) => posted.push(m), + ) + await controller.reactivate() + expect(posted).toEqual([]) + }) +}) + +describe("SourceController.refresh", () => { + it("runs a one-shot active source fetch without restarting polling", async () => { + let fetches = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + fetches++ + return { diffs: [{ file: `file-${fetches}.ts` } as never] } + }, + } + const { controller, posted } = make({ "session:s1": source }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("session:s1", { poll: false }) + posted.length = 0 + + await controller.refresh() + + expect(fetches).toBe(2) + const diffs = byType(posted, "diffViewer.diffs") + expect(diffs).toHaveLength(1) + expect(diffs[0]!.diffs).toEqual([{ file: "file-2.ts" }]) + expect(byType(posted, "diffViewer.loading").map((m) => m.loading)).toEqual([true, false]) + + controller.stop() }) }) diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts new file mode 100644 index 0000000000..af846de7b7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { useFileMention } from "../../webview-ui/src/hooks/useFileMention" +import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe("useFileMention", () => { + it("keeps previous file results visible while the next search is pending", async () => { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: (handler: (message: ExtensionMessage) => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.onInput("@e", 2) + await wait(170) + + const first = posted.at(-1) + expect(first?.type).toBe("requestFileSearch") + expect(first).toMatchObject({ query: "e", requestId: "file-search-1" }) + + for (const handler of handlers) { + handler({ + type: "fileSearchResult", + requestId: "file-search-1", + dir: "/repo", + paths: ["sdks/vscode/src/extension.ts"], + items: [{ path: "sdks/vscode/src/extension.ts", type: "opened-file" }], + }) + } + + expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }]) + + mention.onInput("@ex", 3) + + expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }]) + + dispose.fn?.() + }) + + it("does not keep stale file results visible for unrelated queries", async () => { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: (handler: (message: ExtensionMessage) => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.onInput("@read", 5) + await wait(170) + + for (const handler of handlers) { + handler({ + type: "fileSearchResult", + requestId: "file-search-1", + dir: "/repo", + paths: ["README.md"], + items: [{ path: "README.md", type: "file" }], + }) + } + + mention.onInput("@zz", 3) + + expect(mention.mentionResults()).toEqual([]) + + dispose.fn?.() + }) + + it("filters visible results synchronously while a new search is pending", async () => { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: (handler: (message: ExtensionMessage) => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.onInput("@g", 2) + await wait(170) + + for (const handler of handlers) { + handler({ + type: "fileSearchResult", + requestId: "file-search-1", + dir: "/repo", + paths: ["README.md", "src/git.ts"], + items: [ + { path: "README.md", type: "file" }, + { path: "src/git.ts", type: "file" }, + ], + }) + } + + mention.onInput("@gi", 3) + + expect(mention.mentionResults()).toEqual([{ type: "file", value: "src/git.ts" }]) + + dispose.fn?.() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 96d0737182..76f831fa87 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-sections.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts index e77052d2de..ae74e6c4f6 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts @@ -201,6 +201,14 @@ describe("WorktreeStateManager sections", () => { expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id]) }) + it("keeps ungrouped worktrees above moved sections", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const a = mgr.addSection("A", null) + mgr.setWorktreeOrder([a.id, wt.id]) + + expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id]) + }) + it("is a no-op at boundaries", () => { const a = mgr.addSection("A", null) const b = mgr.addSection("B", null) @@ -320,5 +328,20 @@ describe("WorktreeStateManager sections", () => { await loaded.load() expect(loaded.getWorktreeOrder()).toContain(wt.id) }) + + it("normalizes ungrouped worktrees above sections on load", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("S", null) + await mgr.flush() + await mgr.save() + const file = path.join(root, ".kilo", "agent-manager.json") + const data = JSON.parse(fs.readFileSync(file, "utf-8")) + data.worktreeOrder = [sec.id, wt.id] + fs.writeFileSync(file, JSON.stringify(data)) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toEqual([wt.id, sec.id]) + }) }) }) diff --git a/packages/kilo-vscode/tsconfig.json b/packages/kilo-vscode/tsconfig.json index bc2972234d..868845e9f1 100644 --- a/packages/kilo-vscode/tsconfig.json +++ b/packages/kilo-vscode/tsconfig.json @@ -3,10 +3,11 @@ "module": "ESNext", "moduleResolution": "bundler", "target": "ES2022", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "sourceMap": true, "rootDir": "src", "strict": true /* enable all strict type-checking options */, + "skipLibCheck": true, "types": ["node", "vscode", "mocha"] /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 974ea6962e..105073bb56 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, @@ -76,13 +76,15 @@ import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" import { ConfigProvider } from "../src/context/config" import { DisplayProvider } from "../src/context/display" +import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-models" import { NotificationsProvider } from "../src/context/notifications" +import { FeedbackProvider } from "../src/context/feedback" import { 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,13 +93,15 @@ 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 { BranchSelect } from "./BranchSelect" +import { CurrentTabsMenu, createCurrentTabItems, focusCurrentTab } from "./CurrentTabsMenu" +import { BranchSelect } from "../src/components/shared/BranchSelect" import { WorktreeItem } from "./WorktreeItem" import SectionHeader from "./SectionHeader" import { randomColor } from "./section-colors" @@ -116,9 +120,9 @@ 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 { @@ -172,70 +176,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 @@ -654,6 +594,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) @@ -897,6 +844,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()) @@ -999,24 +948,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 = () => { @@ -1121,6 +1053,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() @@ -1536,44 +1470,39 @@ const AgentManagerContent: Component = () => { } }) + const selectedDiffSessionId = () => { + const sel = selection() + if (sel === LOCAL) return LOCAL + if (!sel) return undefined + + const current = session.currentSessionID() + if (current) { + const item = managedSessions().find((entry) => entry.id === current) + if (item?.worktreeId === sel) return current + } + + return managedSessions().find((entry) => entry.worktreeId === sel)?.id + } + + const currentDiffSessionId = createMemo(selectedDiffSessionId) + // Start/stop diff watch when panel opens/closes, review tab opens, or session changes createEffect(() => { const panel = diffOpen() const review = reviewActive() - const sel = selection() - const id = session.currentSessionID() - if (panel) { - if (sel === LOCAL) { - // For local tab, diff against unpushed changes using LOCAL sentinel - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL }) - return - } else if (id) { - const ms = managedSessions().find((s) => s.id === id) - if (ms?.worktreeId) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) - return - } - } - vscode.postMessage({ type: "agentManager.stopDiffWatch" }) - return - } - if (review) { - // Review tab is open but no specific session — use local sentinel for local, - // or any session in the selected worktree. - if (sel === LOCAL) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL }) + + if (panel || review) { + const id = currentDiffSessionId() + if (id) { + vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) return } - if (sel) { - const managed = managedSessions().find((ms) => ms.worktreeId === sel) - if (managed) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: managed.id }) - return - } - } vscode.postMessage({ type: "agentManager.stopDiffWatch" }) + setDiffLoading(false) return } + + setDiffLoading(false) vscode.postMessage({ type: "agentManager.stopDiffWatch" }) }) @@ -1603,6 +1532,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) } @@ -1628,20 +1558,6 @@ const AgentManagerContent: Component = () => { return [] }) - const currentDiffSessionId = createMemo(() => { - const sel = selection() - if (sel === LOCAL) return LOCAL - - const current = session.currentSessionID() - if (current) { - const item = managedSessions().find((entry) => entry.id === current) - if (sel && item?.worktreeId === sel) return current - } - - if (!sel) return undefined - return managedSessions().find((entry) => entry.worktreeId === sel)?.id - }) - const diffSessionKey = createMemo(() => { const sel = selection() if (sel === LOCAL) return `local:${LOCAL}` @@ -1968,6 +1884,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) { @@ -1996,6 +1913,7 @@ const AgentManagerContent: Component = () => { const handleTabMouseDown = (sessionId: string, e: MouseEvent) => { if (e.button === 1) { e.preventDefault() + e.stopPropagation() handleCloseTab(sessionId) } } @@ -2024,6 +1942,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, @@ -2054,7 +1973,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) @@ -2106,6 +2024,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 = () => { @@ -2696,56 +2645,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 @@ -3158,6 +3122,7 @@ export const AgentManagerApp: Component = () => { + @@ -3167,17 +3132,21 @@ export const AgentManagerApp: Component = () => { - - - - - - - - - - - + + + + + + + + + + + + + + + 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 0000000000..7dd919fb36 --- /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/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 261ebad498..21e95375cf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -21,10 +21,17 @@ import { type AnnotationLabels, type AnnotationMeta, } from "./review-annotations" -import { LONG_DIFF_MARKER_FILE_COUNT, initialOpenFiles, isLargeDiffFile } from "./diff-open-policy" +import { + LONG_DIFF_MARKER_FILE_COUNT, + allOpenFiles, + initialOpenFiles, + isLargeDiffFile, + toggleOpenFiles, +} from "./diff-open-policy" import { DiffEndMarker } from "./DiffEndMarker" import { treeOrder } from "./file-tree-utils" import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" +import { diffToken } from "./diff-state" // --- Data model --- @@ -66,13 +73,16 @@ export const DiffPanel: Component = (props) => { delete: t("common.delete"), }) const [open, setOpen] = createSignal([]) - const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number } | null>(null) + const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>( + null, + ) const [editing, setEditing] = createSignal(null) let nextId = 0 - // Tracks the session key for which auto-open has already run. When the - // key changes (different worktree) we re-expand. Within the same key, + // Tracks the session key for which initial open state has already run. When the + // key changes (different worktree) we expand reviewable files. Within the same key, // only pruning happens so the user's manual collapse state is preserved. let initializedKey: string | undefined + const requested = new Map() // Reorder diffs to match the file-tree's depth-first visual order so // scrolling through the accordion matches the tree grouping. @@ -130,9 +140,9 @@ export const DiffPanel: Component = (props) => { focusRoot() } - // Unified auto-open effect: tracks both sessionKey and diffs in a single effect + // Unified open-state effect: tracks both sessionKey and diffs in a single effect // to eliminate the race condition between the old separate sessionKey-reset and - // diffs-watch effects. Uses the session key to decide when auto-expand is needed + // diffs-watch effects. Uses the session key to decide when initialization is needed // vs when we just prune stale entries from the open list. createEffect( on( @@ -164,16 +174,33 @@ export const DiffPanel: Component = (props) => { ), ) + createEffect( + on( + () => props.sessionKey, + () => { + requested.clear() + }, + ), + ) + createEffect( on( () => [open(), props.diffs] as const, ([next]) => { + const files = new Set(next) + for (const file of requested.keys()) { + if (!files.has(file)) requested.delete(file) + } + if (!props.onRequestDiff) return const loading = props.loadingFiles ?? new Set() for (const file of next) { if (loading.has(file)) continue const diff = props.diffs.find((item) => item.file === file) if (!diff || diff.summarized !== true) continue - props.onRequestDiff?.(file) + const value = diffToken(diff) + if (requested.get(file) === value) continue + requested.set(file, value) + props.onRequestDiff(file) } }, { defer: true }, @@ -240,6 +267,11 @@ export const DiffPanel: Component = (props) => { if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null + return + } + if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { + setDraft(null) + draftMeta = null } }, ), @@ -287,7 +319,7 @@ export const DiffPanel: Component = (props) => { if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { - setDraft({ file, side, line: range.start }) + setDraft({ file, side, line: range.start, endLine: range.end }) }) } @@ -314,8 +346,7 @@ export const DiffPanel: Component = (props) => { } const handleExpandAll = () => { - const allOpen = open().length === props.diffs.length - setOpen(allOpen ? [] : props.diffs.map((d) => d.file)) + setOpen(toggleOpenFiles(props.diffs, open())) } const totals = createMemo(() => ({ @@ -325,6 +356,9 @@ export const DiffPanel: Component = (props) => { large: props.diffs.filter((diff) => isLargeDiffFile(diff)).length, collapsed: Math.max(props.diffs.length - open().length, 0), })) + const allOpen = createMemo(() => allOpenFiles(props.diffs, open())) + const openLabel = () => (allOpen() ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")) + const openIcon = () => (allOpen() ? "files-collapse" : "files-expand") return (
@@ -366,23 +400,12 @@ export const DiffPanel: Component = (props) => {
0}> - + @@ -559,7 +582,17 @@ export const DiffPanel: Component = (props) => { /> } > - + handleGutterClick(diff.file, result)} + onLineNumberClick={(event) => { + if (event.annotationSide === "deletions") return + props.onOpenFile?.(diff.file, event.lineNumber) + }} + /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index f722b7aa68..596fa54f46 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -29,9 +29,16 @@ import { type AnnotationLabels, type AnnotationMeta, } from "./review-annotations" -import { LONG_DIFF_MARKER_FILE_COUNT, initialOpenFiles, isLargeDiffFile } from "./diff-open-policy" +import { + LONG_DIFF_MARKER_FILE_COUNT, + allOpenFiles, + initialOpenFiles, + isLargeDiffFile, + toggleOpenFiles, +} from "./diff-open-policy" import { DiffEndMarker } from "./DiffEndMarker" import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" +import { diffToken } from "./diff-state" type DiffStyle = "unified" | "split" @@ -76,16 +83,19 @@ export const FullScreenDiffView: Component = (props) => delete: t("common.delete"), }) const [open, setOpen] = createSignal([]) - const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number } | null>(null) + const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>( + null, + ) const [editing, setEditing] = createSignal(null) const [activeFile, setActiveFile] = createSignal(null) const [treeWidth, setTreeWidth] = createSignal(240) let nextId = 0 let draftMeta: AnnotationMeta | null = null - // Tracks the session key for which auto-open has already run. When the - // key changes (different worktree) we re-expand. Within the same key, + // Tracks the session key for which initial open state has already run. When the + // key changes (different worktree) we expand reviewable files. Within the same key, // only pruning happens so the user's manual collapse state is preserved. let initializedKey: string | undefined + const requested = new Map() let rootRef: HTMLDivElement | undefined let scrollRef: HTMLDivElement | undefined let syncFrame: number | undefined @@ -133,9 +143,9 @@ export const FullScreenDiffView: Component = (props) => focusRoot() } - // Unified auto-open effect: tracks both sessionKey and diffs in a single effect + // Unified open-state effect: tracks both sessionKey and diffs in a single effect // to eliminate the race condition between the old separate sessionKey-reset and - // diffs-watch effects. Uses the session key to decide when auto-expand is needed + // diffs-watch effects. Uses the session key to decide when initialization is needed // vs when we just prune stale entries from the open list. createEffect( on( @@ -174,16 +184,33 @@ export const FullScreenDiffView: Component = (props) => ), ) + createEffect( + on( + () => props.sessionKey, + () => { + requested.clear() + }, + ), + ) + createEffect( on( () => [open(), props.diffs] as const, ([next]) => { + const files = new Set(next) + for (const file of requested.keys()) { + if (!files.has(file)) requested.delete(file) + } + if (!props.onRequestDiff) return const loading = props.loadingFiles ?? new Set() for (const file of next) { if (loading.has(file)) continue const diff = props.diffs.find((item) => item.file === file) if (!diff || diff.summarized !== true) continue - props.onRequestDiff?.(file) + const value = diffToken(diff) + if (requested.get(file) === value) continue + requested.set(file, value) + props.onRequestDiff(file) } }, { defer: true }, @@ -255,6 +282,11 @@ export const FullScreenDiffView: Component = (props) => if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null + return + } + if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { + setDraft(null) + draftMeta = null } }, ), @@ -296,7 +328,7 @@ export const FullScreenDiffView: Component = (props) => if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { - setDraft({ file, side, line: range.start }) + setDraft({ file, side, line: range.start, endLine: range.end }) }) } @@ -342,8 +374,7 @@ export const FullScreenDiffView: Component = (props) => } const handleExpandAll = () => { - const allOpen = open().length === props.diffs.length - setOpen(allOpen ? [] : props.diffs.map((d) => d.file)) + setOpen(toggleOpenFiles(props.diffs, open())) } const syncActiveFileFromScroll = () => { @@ -406,6 +437,8 @@ export const FullScreenDiffView: Component = (props) => large: props.diffs.filter((diff) => isLargeDiffFile(diff)).length, collapsed: Math.max(props.diffs.length - open().length, 0), })) + const allOpen = createMemo(() => allOpenFiles(props.diffs, open())) + const openLabel = () => (allOpen() ? t("ui.sessionReview.collapseAll") : t("ui.sessionReview.expandAll")) return (
= (props) =>
0 && props.canComment !== false}> = (props) => /> } > - + handleGutterClick(diff.file, result)} + onLineNumberClick={(event) => { + if (event.annotationSide === "deletions") return + props.onOpenFile?.(diff.file, event.lineNumber) + }} + /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MarkdownAnnotationLayer.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MarkdownAnnotationLayer.tsx new file mode 100644 index 0000000000..bdd04b2b52 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/MarkdownAnnotationLayer.tsx @@ -0,0 +1,221 @@ +import { type Component, createEffect, createMemo, onCleanup } from "solid-js" +import type { AnnotationSide, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs" +import { markdownCommentBlocks, type MarkdownRange } from "./markdown-comment-ranges" +import type { AnnotationMeta } from "./review-annotations" + +type Insert = "after" | "list" | "table" + +type Anchor = MarkdownRange & { + line: number + element: HTMLElement + insert: Insert +} + +export interface MarkdownAnnotationLayerProps { + pane: () => HTMLElement | undefined + root: () => HTMLElement | undefined + text: string + side: AnnotationSide + annotations: DiffLineAnnotation[] + renderAnnotation: ((annotation: DiffLineAnnotation) => HTMLElement | undefined) | undefined + enableGutterUtility: boolean + onGutterUtilityClick: ((range: SelectedLineRange) => void) | undefined + onLineNumberClick: ((event: { annotationSide: AnnotationSide; lineNumber: number }) => void) | undefined +} + +function children(root: HTMLElement): HTMLElement[] { + return Array.from(root.children).filter((child): child is HTMLElement => { + if (!(child instanceof HTMLElement)) return false + if (child.classList.contains("am-markdown-inline-annotations")) return false + if (child.classList.contains("am-markdown-list-annotation")) return false + if (child.classList.contains("am-markdown-table-annotation")) return false + return true + }) +} + +function listItems(root: HTMLElement): HTMLElement[] { + return Array.from(root.children).filter((child): child is HTMLElement => { + if (!(child instanceof HTMLElement)) return false + if (child.tagName !== "LI") return false + return !child.classList.contains("am-markdown-list-annotation") + }) +} + +function tableRows(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll("tr")).filter((row) => { + return !row.classList.contains("am-markdown-table-annotation") + }) +} + +function anchors(root: HTMLElement, source: ReturnType): Anchor[] { + const rendered = children(root) + const result: Anchor[] = [] + let index = 0 + + for (const block of source) { + const element = rendered[index] + if (!element) break + index += 1 + + if (block.type === "table" && element.tagName === "TABLE") { + const rows = tableRows(element) + for (const [row, range] of block.rows.entries()) { + const target = rows[row] + if (target) result.push({ ...range, line: range.start, element: target, insert: "table" }) + } + continue + } + + if (block.type === "list" && (element.tagName === "UL" || element.tagName === "OL")) { + const items = listItems(element) + for (const [item, range] of block.items.entries()) { + const target = items[item] + if (target) result.push({ ...range, line: range.start, element: target, insert: "list" }) + } + continue + } + + result.push({ start: block.start, end: block.end, line: block.start, element, insert: "after" }) + } + + return result +} + +function matches(annotation: DiffLineAnnotation, anchor: Anchor, side: AnnotationSide): boolean { + if (annotation.side !== side) return false + if (annotation.lineNumber < anchor.start) return false + if (annotation.lineNumber > anchor.end) return false + return true +} + +function removeInserted(root: HTMLElement, layer: HTMLElement): void { + layer.replaceChildren() + root + .querySelectorAll(".am-markdown-inline-annotations, .am-markdown-list-annotation, .am-markdown-table-annotation") + .forEach((node) => node.remove()) +} + +function insertHost(anchor: Anchor, host: HTMLElement): void { + if (anchor.insert === "table" && anchor.element instanceof HTMLTableRowElement) { + const row = document.createElement("tr") + row.className = "am-markdown-table-annotation" + const cell = document.createElement("td") + cell.colSpan = Math.max(1, anchor.element.cells.length) + cell.appendChild(host) + row.appendChild(cell) + anchor.element.parentNode?.insertBefore(row, anchor.element.nextSibling) + return + } + + if (anchor.insert === "list") { + const item = document.createElement("li") + item.className = "am-markdown-list-annotation" + item.appendChild(host) + anchor.element.parentNode?.insertBefore(item, anchor.element.nextSibling) + return + } + + anchor.element.parentNode?.insertBefore(host, anchor.element.nextSibling) +} + +export const MarkdownAnnotationLayer: Component = (props) => { + let layer: HTMLDivElement | undefined + let observer: MutationObserver | undefined + let frame: number | undefined + const ranges = createMemo(() => markdownCommentBlocks(props.text)) + + const schedule = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(render) + } + + const render = () => { + frame = undefined + const pane = props.pane() + const root = props.root() + if (!pane || !root || !layer) return + + observer?.disconnect() + removeInserted(root, layer) + + const list = anchors(root, ranges()) + for (const anchor of list) { + const annotations = props.annotations.filter((annotation) => matches(annotation, anchor, props.side)) + if (annotations.length > 0) { + const host = document.createElement("div") + host.className = "am-markdown-inline-annotations" + for (const annotation of annotations) { + const element = props.renderAnnotation?.(annotation) + if (element) host.appendChild(element) + } + insertHost(anchor, host) + } + } + + const paneBox = pane.getBoundingClientRect() + for (const anchor of list) { + const box = anchor.element.getBoundingClientRect() + const row = document.createElement("div") + row.className = "am-markdown-target" + row.style.top = `${box.top - paneBox.top}px` + row.style.height = `${Math.max(20, box.height)}px` + + if (props.enableGutterUtility) { + const button = document.createElement("button") + button.className = "am-markdown-comment-button" + button.type = "button" + button.title = `Comment on line ${anchor.line}` + button.setAttribute("aria-label", `Comment on line ${anchor.line}`) + button.textContent = "+" + button.addEventListener("click", (event) => { + event.stopPropagation() + props.onGutterUtilityClick?.({ side: props.side, start: anchor.start, end: anchor.end }) + }) + row.appendChild(button) + } + + const line = document.createElement("button") + line.className = "am-markdown-line-number" + line.type = "button" + line.textContent = `${anchor.line}` + line.setAttribute("aria-label", `Open line ${anchor.line}`) + line.addEventListener("click", (event) => { + event.stopPropagation() + props.onLineNumberClick?.({ annotationSide: props.side, lineNumber: anchor.line }) + }) + row.appendChild(line) + layer.appendChild(row) + } + + observer?.observe(root, { childList: true, subtree: true }) + } + + createEffect(() => { + props.text + props.side + props.annotations + props.enableGutterUtility + props.onGutterUtilityClick + props.onLineNumberClick + props.pane() + props.root() + schedule() + }) + + createEffect(() => { + const root = props.root() + if (!root) return + observer?.disconnect() + observer = new MutationObserver(schedule) + observer.observe(root, { childList: true, subtree: true }) + }) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + observer?.disconnect() + const root = props.root() + if (root && layer) removeInserted(root, layer) + }) + + return
+} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MarkdownDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MarkdownDiffView.tsx index 544b2f04fc..3627809ccd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MarkdownDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MarkdownDiffView.tsx @@ -1,5 +1,8 @@ import { type Component, Show } from "solid-js" +import type { AnnotationSide, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs" import { Markdown } from "@kilocode/kilo-ui/markdown" +import { MarkdownAnnotationLayer } from "./MarkdownAnnotationLayer" +import type { AnnotationMeta } from "./review-annotations" interface MarkdownDiffFile { file: string @@ -10,36 +13,106 @@ interface MarkdownDiffFile { interface MarkdownDiffViewProps { diff: MarkdownDiffFile + annotations?: DiffLineAnnotation[] + renderAnnotation?: (annotation: DiffLineAnnotation) => HTMLElement | undefined + enableGutterUtility?: boolean + onGutterUtilityClick?: (range: SelectedLineRange) => void + onLineNumberClick?: (event: { annotationSide: AnnotationSide; lineNumber: number }) => void } export function isMarkdownFile(file: string): boolean { return /\.(md|mdx|markdown)$/i.test(file) } +interface PaneProps { + title?: string + text: string + side: AnnotationSide + cache: string + annotations: DiffLineAnnotation[] + renderAnnotation: ((annotation: DiffLineAnnotation) => HTMLElement | undefined) | undefined + enableGutterUtility: boolean + onGutterUtilityClick: ((range: SelectedLineRange) => void) | undefined + onLineNumberClick: ((event: { annotationSide: AnnotationSide; lineNumber: number }) => void) | undefined +} + +const MarkdownPane: Component = (props) => { + let pane: HTMLElement | undefined + let body: HTMLDivElement | undefined + const interactive = () => + props.enableGutterUtility || props.onLineNumberClick !== undefined || props.annotations.length > 0 + const root = () => body?.querySelector('[data-component="markdown"]') ?? undefined + + return ( +
+ {(title) =>
{title()}
}
+
+ +
+ + pane} + root={root} + text={props.text} + side={props.side} + annotations={props.annotations} + renderAnnotation={props.renderAnnotation} + enableGutterUtility={props.enableGutterUtility} + onGutterUtilityClick={props.onGutterUtilityClick} + onLineNumberClick={props.onLineNumberClick} + /> + +
+ ) +} + export const MarkdownDiffView: Component = (props) => { const before = () => (props.diff.status === "added" ? "" : props.diff.before) const after = () => (props.diff.status === "deleted" ? "" : props.diff.after) const split = () => before().length > 0 && after().length > 0 && before() !== after() + const side = () => (after().length > 0 ? "additions" : "deletions") as AnnotationSide + const annotations = () => props.annotations ?? [] return (
- - + } > <> -
-
Before
- -
-
-
After
- -
+ +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 3e54451f4e..5f3e40399e 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 "../src/components/shared/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 99f91f7f95..0e967a3705 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 { @@ -1636,6 +1859,7 @@ body.am-wt-dragging-active * { } .am-markdown-pane { + position: relative; min-width: 0; padding: 16px 20px 24px; background: var(--vscode-editor-background, var(--surface-base)); @@ -1643,6 +1867,10 @@ body.am-wt-dragging-active * { overflow-x: auto; } +.am-markdown-pane[data-comments="true"] { + padding: 12px 20px 20px 64px; +} + .am-markdown-pane-title { margin: -4px 0 12px; color: var(--text-weak); @@ -1652,10 +1880,96 @@ body.am-wt-dragging-active * { letter-spacing: 0.04em; } +.am-markdown-pane[data-comments="true"] .am-markdown-pane-title { + margin: 0 0 12px; +} + .am-markdown-pane [data-component="markdown"] { max-width: 900px; } +.am-markdown-gutter-layer { + position: absolute; + inset: 0 auto 0 0; + width: 56px; + pointer-events: none; +} + +.am-markdown-target { + position: absolute; + left: 0; + width: 56px; + display: flex; + justify-content: flex-end; + gap: 4px; + padding-top: 2px; + box-sizing: border-box; + color: var(--text-weak); + font-family: var(--vscode-editor-font-family, monospace); + font-size: var(--font-size-small); + user-select: none; + pointer-events: auto; +} + +.am-markdown-comment-button, +.am-markdown-line-number { + height: 20px; + border: none; + background: transparent; + color: inherit; + font: inherit; + line-height: 20px; +} + +.am-markdown-comment-button { + width: 18px; + padding: 0; + border-radius: 4px; + cursor: pointer; + opacity: 0; +} + +.am-markdown-target:hover .am-markdown-comment-button, +.am-markdown-comment-button:focus-visible { + opacity: 1; +} + +.am-markdown-comment-button:hover, +.am-markdown-comment-button:focus-visible { + color: var(--text-base); + background: var(--surface-interactive-base); +} + +.am-markdown-line-number { + min-width: 24px; + padding: 0 4px; + text-align: right; + cursor: pointer; +} + +.am-markdown-line-number:hover, +.am-markdown-line-number:focus-visible { + color: var(--text-base); +} + +.am-markdown-body { + min-width: 0; +} + +.am-markdown-inline-annotations { + min-width: 0; +} + +.am-markdown-list-annotation { + list-style: none; + margin: 0 0 8px; +} + +.am-markdown-table-annotation td { + padding: 0; + border: none; +} + @media (max-width: 840px) { .am-markdown-diff[data-split="true"] { grid-template-columns: minmax(0, 1fr); @@ -2868,7 +3182,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 +3356,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/diff-open-policy.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-open-policy.ts index 2b6512a11b..dc6f22d955 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-open-policy.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-open-policy.ts @@ -1,21 +1,28 @@ import type { WorktreeFileDiff } from "../src/types/messages" export const LONG_DIFF_MARKER_FILE_COUNT = 50 -const AUTO_OPEN_FILE_COUNT = 25 -const AUTO_OPEN_LIMIT = 8 -const LARGE_FILE_CHANGED_LINES = 400 +export const EXTREME_DIFF_CHANGED_LINES = 2_000 export function isLargeDiffFile(diff: WorktreeFileDiff): boolean { - return diff.additions + diff.deletions > LARGE_FILE_CHANGED_LINES + return diff.additions + diff.deletions > EXTREME_DIFF_CHANGED_LINES +} + +export function expandableOpenFiles(diffs: WorktreeFileDiff[]): string[] { + return diffs.filter((diff) => !isLargeDiffFile(diff) && diff.generatedLike !== true).map((diff) => diff.file) } export function initialOpenFiles(diffs: WorktreeFileDiff[]): string[] { - if (diffs.length === 0) return [] - if (diffs.length > AUTO_OPEN_FILE_COUNT) return [] - - const files = diffs - .filter((diff) => !isLargeDiffFile(diff) && diff.generatedLike !== true) - .slice(0, AUTO_OPEN_LIMIT) - .map((diff) => diff.file) - return files + return expandableOpenFiles(diffs) +} + +export function allOpenFiles(diffs: WorktreeFileDiff[], open: string[]): boolean { + const targets = expandableOpenFiles(diffs) + if (targets.length === 0) return false + const files = new Set(open) + return targets.every((file) => files.has(file)) +} + +export function toggleOpenFiles(diffs: WorktreeFileDiff[], open: string[]): string[] { + if (allOpenFiles(diffs, open)) return [] + return expandableOpenFiles(diffs) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts index 1ffd6bd347..05a9657172 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts @@ -13,6 +13,11 @@ export function sameDiffMeta(left: WorktreeFileDiff, right: WorktreeFileDiff) { ) } +export function diffToken(diff: WorktreeFileDiff) { + const parts = [diff.status ?? "", diff.additions, diff.deletions, diff.tracked ?? "", diff.generatedLike ?? ""] + return diff.stamp ?? parts.join(":") +} + export interface MergeResult { diffs: WorktreeFileDiff[] /** Files whose metadata changed while we preserved cached content. 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 8fe7cc66ef..449dcb4e95 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 0403f4757c..0e0e36a5cc 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 83d07a4300..098aeb8060 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 b922b7ad48..9a53d313ab 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 056b3365ea..658958fc0f 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 e564072f3a..6144f66854 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 55fe7198a8..5ee42b117b 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 d61da449af..66e3e2a7bc 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 4035c4ac95..4b1843043b 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 3fa6207bd9..e2cc019eb1 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 6d71eb72fb..09f47b76e5 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 bd0fe91965..9966510f9a 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 d27b43d4c3..14511d9458 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 c963c944c6..d6f8d9a808 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 506f6ba0fa..43260a7aff 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 1dac84b599..e0863aa3f5 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 d2d196c660..e6e338e425 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 c125c2e385..26b4c666f8 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 4fcdc1f355..e1b9335685 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/markdown-comment-ranges.ts b/packages/kilo-vscode/webview-ui/agent-manager/markdown-comment-ranges.ts new file mode 100644 index 0000000000..92aea59a23 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/markdown-comment-ranges.ts @@ -0,0 +1,75 @@ +import { marked, type Token, type Tokens } from "marked" + +export type MarkdownRange = { + start: number + end: number +} + +export type MarkdownBlock = + | { type: "block"; start: number; end: number } + | { type: "list"; start: number; end: number; items: MarkdownRange[] } + | { type: "table"; start: number; end: number; rows: MarkdownRange[] } + +type Cursor = { + line: number + offset: number +} + +function advance(pos: Cursor, raw: string): Cursor { + const matches = raw.match(/\n/g) + return { line: pos.line + (matches?.length ?? 0), offset: pos.offset + raw.length } +} + +function span(pos: Cursor, raw: string): MarkdownRange { + const next = advance(pos, raw) + const trailing = raw.endsWith("\n") ? 1 : 0 + return { start: pos.line, end: Math.max(pos.line, next.line - trailing) } +} + +function lineCount(raw: string): number { + return Math.max(1, raw.replace(/\n+$/, "").split("\n").length) +} + +function list(token: Tokens.List, range: MarkdownRange): MarkdownBlock { + let line = range.start + const items = token.items.map((item) => { + const size = lineCount(item.raw) + const next = { start: line, end: line + size - 1 } + line += size + return next + }) + return { type: "list", ...range, items } +} + +function table(raw: string, range: MarkdownRange): MarkdownBlock { + const rows = raw + .replace(/\n+$/, "") + .split("\n") + .map((_, index) => range.start + index) + .filter((line, index) => index !== 1 && line <= range.end) + .map((line) => ({ start: line, end: line })) + return { type: "table", ...range, rows } +} + +function block(token: Token, range: MarkdownRange): MarkdownBlock { + if (token.type === "list") return list(token as Tokens.List, range) + if (token.type === "table") return table(token.raw, range) + return { type: "block", ...range } +} + +export function markdownCommentBlocks(text: string): MarkdownBlock[] { + const tokens = marked.lexer(text) + const result: MarkdownBlock[] = [] + let pos = { line: 1, offset: 0 } + + for (const token of tokens) { + const raw = token.raw ?? "" + const index = text.indexOf(raw, pos.offset) + if (index > pos.offset) pos = advance(pos, text.slice(pos.offset, index)) + const range = span(pos, raw) + if (token.type !== "space") result.push(block(token, range)) + pos = advance(pos, raw) + } + + return result +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts index 97055b096b..3271f3ad85 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts @@ -14,12 +14,15 @@ export interface AnnotationLabels { delete: string } +// A draft is the active unsaved inline comment composer opened from the gutter. +// It becomes a normal comment only after the user submits the textarea. export interface AnnotationMeta { type: "comment" | "draft" comment: ReviewComment | null file: string side: AnnotationSide line: number + endLine?: number editing?: boolean } @@ -76,7 +79,7 @@ export function buildFileAnnotations( file: string, fileComments: ReviewComment[], edit: string | null, - draft: { file: string; side: AnnotationSide; line: number } | null, + draft: { file: string; side: AnnotationSide; line: number; endLine?: number } | null, draftMeta: AnnotationMeta | null, ): { annotations: DiffLineAnnotation[]; draftMeta: AnnotationMeta | null } { const result: DiffLineAnnotation[] = fileComments.map((c) => ({ @@ -93,8 +96,21 @@ export function buildFileAnnotations( })) if (draft && draft.file === file) { - if (!draftMeta || draftMeta.file !== draft.file || draftMeta.side !== draft.side || draftMeta.line !== draft.line) { - draftMeta = { type: "draft", comment: null, file: draft.file, side: draft.side, line: draft.line } + if ( + !draftMeta || + draftMeta.file !== draft.file || + draftMeta.side !== draft.side || + draftMeta.line !== draft.line || + draftMeta.endLine !== draft.endLine + ) { + draftMeta = { + type: "draft", + comment: null, + file: draft.file, + side: draft.side, + line: draft.line, + endLine: draft.endLine, + } } result.push({ side: draft.side, lineNumber: draft.line, metadata: draftMeta }) } @@ -146,7 +162,7 @@ export function buildReviewAnnotation( if (!text) return const diff = handlers.diffs.find((item) => item.file === meta.file) const content = meta.side === "deletions" ? (diff?.before ?? "") : (diff?.after ?? "") - const selected = extractLines(content, meta.line, meta.line) + const selected = extractLines(content, meta.line, meta.endLine ?? meta.line) handlers.addComment(meta.file, meta.side, meta.line, text, selected) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts index 3b14ffeaf4..82ae1dcd8d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -11,6 +11,8 @@ export type SidebarItem = { type: "local" | "wt" | "session"; id: string } /** Build a canonical sidebar order containing section IDs and every worktree ID. */ export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[], order: string[]): string[] { const valid = new Set([...secs.map((sec) => sec.id), ...all.map((wt) => wt.id)]) + const secIds = new Set(secs.map((sec) => sec.id)) + const wtMap = new Map(all.map((wt) => [wt.id, wt])) const result: string[] = [] const seen = new Set() const add = (id: string) => { @@ -21,7 +23,18 @@ export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[], for (const id of order) add(id) for (const sec of secs) add(sec.id) for (const wt of all) add(wt.id) - return result + if (secs.length === 0) return result + return [ + ...result.filter((id) => { + const wt = wtMap.get(id) + return wt && !wt.sectionId + }), + ...result.filter((id) => secIds.has(id)), + ...result.filter((id) => { + const wt = wtMap.get(id) + return wt?.sectionId + }), + ] } /** Check if this worktree is part of a multi-version group. */ @@ -42,8 +55,7 @@ export const isGroupEnd = (wt: WorktreeState, idx: number, list: WorktreeState[] } /** - * Build the interleaved list of sections and ungrouped worktrees - * ordered by sidebarWorktreeOrder. + * Build the top-level list with ungrouped worktrees before sections. */ export function buildTopLevelItems( secs: SectionState[], @@ -54,34 +66,18 @@ export function buildTopLevelItems( if (secs.length === 0) { return all.map((wt) => ({ kind: "worktree" as const, wt })) } - const secMap = new Map(secs.map((s) => [s.id, s])) - const wtMap = new Map(ungrouped.map((wt) => [wt.id, wt])) - const result: TopLevelItem[] = [] - const placed = new Set() - - for (const id of order) { - if (placed.has(id)) continue - placed.add(id) - const sec = secMap.get(id) - if (sec) { - result.push({ kind: "section", section: sec }) - continue - } - const wt = wtMap.get(id) - if (wt) result.push({ kind: "worktree", wt }) - } - for (const sec of secs) { - if (!placed.has(sec.id)) result.push({ kind: "section", section: sec }) - } - for (const wt of ungrouped) { - if (!placed.has(wt.id)) result.push({ kind: "worktree", wt }) - } - return result + const rank = new Map(order.map((id, idx) => [id, idx] as const)) + const sort = (items: T[]) => + [...items].sort((a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)) + return [ + ...sort(ungrouped).map((wt) => ({ kind: "worktree" as const, wt })), + ...sort(secs).map((section) => ({ kind: "section" as const, section })), + ] } /** * Build the flat visual order of all sidebar items matching what the user sees. - * LOCAL is always first, then worktrees in visual order (respecting section layout and + * LOCAL is always first, then worktrees in visual order (ungrouped first, then sections, * skipping collapsed sections), then unassigned sessions. */ export function buildSidebarOrder( 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 7238060c76..a42ac1bead 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 @@ -69,14 +71,26 @@ export const SortableTab: Component<{ keybind={props.keybind ?? ""} placement="bottom" inactive={props.active} + openDelay={0} >
+ + + + + {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 { { @@ -172,7 +174,7 @@ export function renderNewTabButton(deps: NewTabButtonDeps): JSX.Element { return (
- + , 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 0000000000..9508905558 --- /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 8cfbb16735..86d8fc7eda 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx @@ -50,15 +50,24 @@ export const SortableTerminalTab: Component<{ keybind={props.keybind ?? ""} placement="bottom" inactive={props.active} + openDelay={0} >
- + + + {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/BaseBranchPicker.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx new file mode 100644 index 0000000000..953c9c26b9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx @@ -0,0 +1,145 @@ +import { type Component, createMemo, createSignal, Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect" +import type { BranchInfo } from "../src/types/messages" +import { useLanguage } from "../src/context/language" + +interface BaseBranchPickerProps { + branches: BranchInfo[] + loading: boolean + defaultBranch: string + autoBase: string | undefined + currentBase: string | undefined + isAuto: boolean + /** Currently checked-out branch (HEAD). Hidden when undefined. */ + currentBranch: string | undefined + onSelect: (branch: string | undefined) => void +} + +/** + * Compact branch picker for the diff viewer header. Lets the user override + * the base branch the workspace source diffs against. Selecting "Default" + * clears the override and falls back to the auto-resolved tracking/default + * branch. + */ +export const BaseBranchPicker: Component = (props) => { + const { t } = useLanguage() + const [open, setOpen] = createSignal(false) + const [search, setSearch] = createSignal("") + const [highlight, setHighlight] = createSignal(0) + + const filtered = createMemo(() => { + const q = search().trim().toLowerCase() + if (!q) return props.branches + return props.branches.filter((b) => b.name.toLowerCase().includes(q)) + }) + + const close = () => { + setOpen(false) + setSearch("") + setHighlight(0) + } + + const choose = (branch: string | undefined) => { + props.onSelect(branch) + close() + } + + const triggerLabel = () => props.currentBase ?? t("diffViewer.baseBranch.none") + + // Keyboard nav: ArrowDown/ArrowUp moves highlight; index -1 is the auto + // option; 0..n-1 is the branch list. + const onKeyDown = (e: KeyboardEvent) => { + const items = filtered() + const max = items.length - 1 + if (e.key === "ArrowDown") { + e.preventDefault() + const cur = highlight() + setHighlight(cur === -1 ? 0 : Math.min(cur + 1, max)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + const cur = highlight() + setHighlight(cur <= 0 ? -1 : cur - 1) + } else if (e.key === "Enter") { + e.preventDefault() + if (highlight() === -1) { + choose(undefined) + return + } + const selected = items[highlight()] + if (selected) choose(selected.name) + } else if (e.key === "Escape") { + e.preventDefault() + close() + } + } + + return ( + + + + {props.currentBranch} + + + + { + setOpen(value) + if (!value) { + setSearch("") + setHighlight(0) + } + }} + placement="bottom-start" + flip + trigger={ + + } + > + { + setSearch(v) + setHighlight(0) + }} + onSelect={(b) => choose(b.name)} + onSearchKeyDown={onKeyDown} + selected={props.currentBase} + highlighted={highlight() >= 0 ? highlight() : undefined} + onHighlight={setHighlight} + defaultName={props.defaultBranch} + searchPlaceholder={t("diffViewer.baseBranch.search")} + emptyLabel={t("diffViewer.baseBranch.empty")} + loadingLabel={t("diffViewer.baseBranch.loading")} + defaultLabel={t("diffViewer.baseBranch.default")} + remoteLabel={t("diffViewer.baseBranch.remote")} + autoOption={{ + label: t("diffViewer.baseBranch.auto"), + hint: props.autoBase, + active: props.isAuto, + highlighted: highlight() === -1, + onSelect: () => choose(undefined), + }} + /> + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx index 82d438f7b1..fcf18dd117 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffPickerHeader.tsx @@ -1,4 +1,4 @@ -import type { Component } from "solid-js" +import type { Component, JSXElement } from "solid-js" import { createSignal, Show } from "solid-js" import { Select } from "@kilocode/kilo-ui/select" import { Tooltip } from "@kilocode/kilo-ui/tooltip" @@ -9,6 +9,12 @@ interface DiffPickerHeaderProps { descriptors: DiffSourceDescriptor[] currentId: string | undefined onSelect: (id: string) => void + /** + * Optional accessory rendered to the right of the source select. Used to + * mount the base branch picker only when the active source is `workspace` + * (the parent decides; the header just hosts). + */ + accessory?: JSXElement } const GROUP_KEYS: Record = { @@ -76,6 +82,7 @@ export const DiffPickerHeader: Component = (props) => { }} + {props.accessory}
) } diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index dedc564dbb..aaea7961b8 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -16,10 +16,11 @@ 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 { BranchInfo, 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" +import { BaseBranchPicker } from "./BaseBranchPicker" const NOTICE_KEYS: Record = { "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", @@ -43,6 +44,20 @@ const DiffViewerContent: Component = () => { const [currentSourceId, setCurrentSourceId] = createSignal(undefined) const [capabilities, setCapabilities] = createSignal(undefined) const [notice, setNotice] = createSignal(undefined) + const [branches, setBranches] = createSignal([]) + const [defaultBranch, setDefaultBranch] = createSignal("") + const [autoBase, setAutoBase] = createSignal(undefined) + const [currentBase, setCurrentBase] = createSignal(undefined) + const [isAuto, setIsAuto] = createSignal(true) + const [currentBranch, setCurrentBranch] = createSignal(undefined) + const [branchesLoading, setBranchesLoading] = createSignal(false) + + const isWorkspaceSource = () => { + const id = currentSourceId() + if (!id) return false + const desc = availableSources().find((d) => d.id === id) + return desc?.type === "workspace" + } const noticeText = () => { const n = notice() @@ -132,6 +147,17 @@ const DiffViewerContent: Component = () => { setNotice(msg.notice) return } + + if (msg.type === "diffViewer.branches") { + setBranches(msg.branches) + setDefaultBranch(msg.defaultBranch) + setAutoBase(msg.autoBase) + setCurrentBase(msg.currentBase) + setIsAuto(msg.isAuto) + setCurrentBranch(msg.currentBranch) + setBranchesLoading(false) + return + } }) const selectSource = (id: string) => { @@ -154,6 +180,23 @@ const DiffViewerContent: Component = () => { }), ) + // Fetch branches whenever the active source becomes the workspace one. The + // extension owns the override state so we ask on every transition rather + // than caching here. + createEffect(() => { + if (!isWorkspaceSource()) return + setBranchesLoading(true) + post({ type: "diffViewer.requestBranches" }) + }) + + const onBaseBranchSelect = (branch: string | undefined) => { + // Optimistically reflect the new selection so the trigger label updates + // immediately; the extension echoes back authoritative state next. + setCurrentBase(branch ?? autoBase()) + setIsAuto(branch === undefined) + post({ type: "diffViewer.setBaseBranch", branch }) + } + const handler = (event: MessageEvent) => { const msg = event.data if (msg?.type !== "appendReviewComments" || !Array.isArray(msg.comments)) return @@ -169,7 +212,25 @@ const DiffViewerContent: Component = () => { return ( <> 0}> - + + + + } + />
diff --git a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx index af5f6d77bd..6a8614630e 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 { normalize } 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" @@ -23,8 +23,6 @@ type DiffStyle = "unified" | "split" interface DiffVirtualFile { file: string patch?: string - before?: string - after?: string additions: number deletions: number } @@ -63,15 +61,10 @@ const DiffVirtualContent: Component = () => { return f.split("/").slice(0, -1).join("/") } - const resolved = createMemo(() => { + const view = createMemo(() => { const d = diff() - 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") } - } - return { before: "", after: "" } + if (!d?.patch) return + return normalize(d) }) return ( @@ -120,17 +113,15 @@ const DiffVirtualContent: Component = () => {
- - } - > - + + {(v) => ( + } + > + + + )}
diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 8665a763fa..70e596921a 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -33,6 +33,8 @@ registerVscodeToolOverrides() import HistoryView from "./components/history/HistoryView" import { MigrationWizard } from "./components/migration" // legacy-migration import { NotificationsProvider } from "./context/notifications" +import { FeedbackProvider } from "./context/feedback" +import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" import "./styles/chat.css" @@ -134,7 +136,7 @@ export const DataBridge: Component<{ children: any }> = (props) => { vscode.postMessage({ type: "openFile", filePath, line, column }) } - const openDiff = (diff: { file: string; before: string; after: string; additions: number; deletions: number }) => { + const openDiff = (diff: { file: string; patch?: string; additions: number; deletions: number }) => { vscode.postMessage({ type: "openDiffVirtual", diff, initialDiffStyle: "split" }) } @@ -178,6 +180,27 @@ export const LanguageBridge: Component<{ children: any }> = (props) => { ) } +type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> + +export const MermaidDownloadBridge: Component = () => { + const vscode = useVSCode() + + onMount(() => { + const save = (event: Event) => { + const detail = (event as MermaidImageEvent).detail + if (!detail?.dataUrl || !detail.filename) return + event.preventDefault() + vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename }) + } + window.addEventListener("kilo:save-image", save) + onCleanup(() => { + window.removeEventListener("kilo:save-image", save) + }) + }) + + return null +} + // Inner app component that uses the contexts const AppContent: Component = () => { const [currentView, setCurrentView] = createSignal("newTask") @@ -342,6 +365,7 @@ const App: Component = () => { + @@ -352,13 +376,17 @@ const App: Component = () => { - - - - - - - + + + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 67780750a0..998d610d57 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -10,6 +10,7 @@ import { Component, For, Show, createMemo } from "solid-js" import { Dynamic } from "solid-js/web" import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part" +import type { MessageFeedbackControls } from "@kilocode/kilo-ui/message-part" import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, @@ -63,6 +64,7 @@ function matchToolRequest = (props) => { message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} reasoningAutoCollapse={display.reasoningAutoCollapse()} + feedback={props.feedback} animate={ part.type === "tool" && ((part as unknown as ToolPart).state?.status === "pending" || diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx index fb836489fe..d5f8743809 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx @@ -3,7 +3,7 @@ import { Diff } from "@kilocode/kilo-ui/diff" import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import { parsePatch } from "diff" +import { normalize } from "@kilocode/kilo-ui/session-diff" import type { PermissionFileDiff } from "../../types/messages" import { useVSCode } from "../../context/vscode" @@ -11,29 +11,6 @@ interface PermissionDiffProps { filediff: PermissionFileDiff } -function patchText(patch: string) { - const parsed = parsePatch(patch)[0] - if (!parsed) return { before: "", after: "" } - - const before: string[] = [] - const after: string[] = [] - for (const hunk of parsed.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("-")) { - before.push(line.slice(1)) - continue - } - if (line.startsWith("+")) { - after.push(line.slice(1)) - continue - } - before.push(line.slice(1)) - after.push(line.slice(1)) - } - } - return { before: before.join("\n"), after: after.join("\n") } -} - export const PermissionDiff: Component = (props) => { const vscode = useVSCode() const filename = createMemo(() => { @@ -47,23 +24,16 @@ export const PermissionDiff: Component = (props) => { return parts.slice(0, -1).join("/") }) - const resolved = createMemo(() => { + const view = createMemo(() => { const fd = props.filediff - if (fd.before !== undefined || fd.after !== undefined) return { before: fd.before ?? "", after: fd.after ?? "" } - if (fd.patch) return patchText(fd.patch) - return { before: "", after: "" } - }) - - const empty = createMemo(() => { - const diff = resolved() - return diff.before === "" && diff.after === "" + if (!fd.patch) return + return normalize(fd) }) const openInTab = () => { - const { before, after } = resolved() vscode.postMessage({ type: "openDiffVirtual", - diff: { ...props.filediff, before, after }, + diff: props.filediff, initialDiffStyle: "unified", }) } @@ -103,14 +73,10 @@ export const PermissionDiff: Component = (props) => {
Diff preview unavailable for this file.
} > - + {(v) => }
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 616ef563ec..9e9da21929 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -9,7 +9,7 @@ * The command buttons (Deny / Run) control the current command. */ -import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { DockPrompt } from "@kilocode/kilo-ui/dock-prompt" import { Icon } from "@kilocode/kilo-ui/icon" @@ -111,48 +111,78 @@ export const PermissionDock: Component<{ const focusPrompt = () => requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) + const submit = (response: "once" | "reject") => { + if (props.responding) return + const { approved, denied } = collectRules() + props.onDecide(response, approved, denied) + focusPrompt() + } + + const element = (e: KeyboardEvent) => (e.target instanceof Element ? e.target : undefined) + + const control = (target: Element | undefined) => + !!target?.closest( + "button, input, select, textarea, a[href], [contenteditable='true'], [role='button'], [role='menu'], [role='menuitem'], [role='listbox'], [role='option'], [role='combobox'], [role='textbox']", + ) + + const plain = (e: KeyboardEvent) => + e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey && !e.isComposing + + const skip = (e: KeyboardEvent, target: Element | undefined) => { + const local = !!target?.closest("[data-component='permission-shortcuts']") + const prompt = !!target?.closest("textarea.prompt-input") + const modal = !!target?.closest("[data-component='dialog'], [data-component='dropdown-menu-content']") + if (local) return e.key === "Enter" + if (modal) return true + if (prompt) return false + return control(target) + } + + const handle = (e: KeyboardEvent, response: "once" | "reject") => { + e.preventDefault() + e.stopPropagation() + submit(response) + } + const onRoot = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - - // Escape always denies — even from focused buttons — and stopPropagation - // prevents ChatView's global Escape handler from calling session.abort(). if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - if (props.responding) return - const { approved, denied } = collectRules() - props.onDecide("reject", approved, denied) - focusPrompt() - return - } - - // Enter approves, but only when focus is on the dock wrapper itself. - // Skip buttons, inputs, and textareas so Enter activates the focused - // control (e.g. toggle/expand) instead of approving the permission. - if (tag === "BUTTON" || tag === "INPUT" || tag === "TEXTAREA") return - if (e.key === "Enter") { - e.preventDefault() - e.stopPropagation() - if (props.responding) return - const { approved, denied } = collectRules() - props.onDecide("once", approved, denied) - focusPrompt() + handle(e, "reject") return } } - // Keep keyboard shortcuts when the webview already has focus, but do not - // steal focus from the editor, terminal, or other VS Code surfaces. + const onKey = (e: KeyboardEvent) => { + if (!document.hasFocus()) return + if (e.defaultPrevented) return + if (root.getClientRects().length === 0) return + + const target = element(e) + + // Preserve normal Enter behavior for controls inside the permission card, + // while allowing the shortcut when the prompt input still has focus. + if (skip(e, target)) return + + if (e.key === "Escape") { + handle(e, "reject") + return + } + + if (plain(e)) { + handle(e, "once") + return + } + } + + // Listen only while this permission is rendered. This keeps shortcuts working + // when the prompt input owns focus without forcing focus onto the dock. createEffect(() => { void props.request.id - requestAnimationFrame(() => { - if (!document.hasFocus()) return - root?.focus() - }) + document.addEventListener("keydown", onKey, true) + onCleanup(() => document.removeEventListener("keydown", onKey, true)) }) return ( -
+
= (props) => { const data = useData() const i18n = useI18n() - const fileComponent = useFileComponent() const server = useServer() const session = useSession() const language = useLanguage() + const vscode = useVSCode() + const feedback = useFeedback() const emptyParts: SDKPart[] = [] const emptyDiffs: SnapshotFileDiff[] = [] @@ -106,18 +91,7 @@ export const VscodeSessionTurn: Component = (props) => { .reverse() }) - const [open, setOpen] = createSignal(false) - const [expanded, setExpanded] = createSignal([]) - - createEffect( - on( - open, - (value, prev) => { - if (!value && prev) setExpanded([]) - }, - { defer: true }, - ), - ) + const openChanges = () => vscode.postMessage({ type: "openChanges", turnId: message().id }) // Copy part ID — the last text part from the last assistant message. // Synthetic parts (e.g. "Initializing snapshot…" from the slow-repo guard) @@ -179,103 +153,50 @@ export const VscodeSessionTurn: Component = (props) => { 0}>
- {(msg) => } + {(amsg) => ( + + feedback.rate({ + messageID: amsg.id, + sessionID: amsg.sessionID, + parentMessageID: amsg.parentID, + providerID: amsg.providerID, + modelID: amsg.modelID, + variant: (amsg as SDKAssistantMessage & { variant?: string }).variant, + next, + }), + }} + /> + )}
- {/* Diff summary — shown after completion */} + {/* Diff summary — shown after completion. Click opens the changes view. */} 0 && server.gitInstalled()}>
- - -
-
- {i18n.t("ui.sessionReview.change.modified")}{" "} - - {diffs().length} {i18n.t(diffs().length === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
- - -
-
-
-
- - -
- setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - requestAnimationFrame(() => { - if (active()) setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - - - -
- - - - {`\u2066${getDirectory(diff.file)}\u2069`} - - - {getFilename(diff.file)} - -
- - - - - - -
-
-
-
- - -
- -
-
-
-
- ) - }} -
-
-
-
-
-
+
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/permission-diff-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/permission-diff-utils.ts index 3fdff90eff..f4b9c1ff80 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/permission-diff-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/permission-diff-utils.ts @@ -25,8 +25,6 @@ function clean(diff: unknown): PermissionFileDiff | undefined { return { file, ...(text(item.patch) !== undefined ? { patch: text(item.patch) } : {}), - ...(text(item.before) !== undefined ? { before: text(item.before) } : {}), - ...(text(item.after) !== undefined ? { after: text(item.after) } : {}), additions: num(item.additions), deletions: num(item.deletions), } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx index 4c33f7be32..60fa97e37e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx @@ -5,7 +5,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useConfig } from "../../context/config" -import type { ConnectionState, ExtensionMessage } from "../../types/messages" +import type { Config, ConnectionState, ExtensionMessage } from "../../types/messages" import { buildExport, parseImport, MAX_IMPORT_SIZE } from "./settings-io" export interface AboutKiloCodeTabProps { @@ -18,7 +18,7 @@ export interface AboutKiloCodeTabProps { const AboutKiloCodeTab: Component = (props) => { const language = useLanguage() const vscode = useVSCode() - const { updateConfig } = useConfig() + const { updateConfig, updateGlobalConfig } = useConfig() const [importing, setImporting] = createSignal(false) const [exporting, setExporting] = createSignal(false) let epoch = 0 @@ -27,6 +27,23 @@ const AboutKiloCodeTab: Component = (props) => { vscode.postMessage({ type: "openExternal", url }) } + const importConfig = (config: Config) => { + const enabled = config.indexing?.enabled + if (enabled === undefined) { + updateConfig(config) + return + } + + const indexing = { ...config.indexing } + delete indexing.enabled + const next = { ...config } + if (Object.keys(indexing).length > 0) next.indexing = indexing + else delete next.indexing + + updateConfig(next) + updateGlobalConfig({ indexing: { enabled } }) + } + // Listen for globalConfigLoaded response const handler = (event: MessageEvent) => { const msg = event.data as ExtensionMessage @@ -91,7 +108,7 @@ const AboutKiloCodeTab: Component = (props) => { title: language.t("settings.aboutKiloCode.importSettings.newerVersion"), }) } - updateConfig(result.config) + importConfig(result.config) showToast({ variant: "success", title: language.t("settings.aboutKiloCode.importSettings.success"), diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AutocompleteTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AutocompleteTab.tsx index 072c5e0a92..6bc66c1b58 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AutocompleteTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AutocompleteTab.tsx @@ -1,37 +1,21 @@ -import { Component, createSignal, onCleanup } from "solid-js" +import { Component } from "solid-js" import { Switch } from "@kilocode/kilo-ui/switch" import { Card } from "@kilocode/kilo-ui/card" -import { useVSCode } from "../../context/vscode" +import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" -import type { ExtensionMessage } from "../../types/messages" import SettingsRow from "./SettingsRow" -const AutocompleteTab: Component = () => { - const vscode = useVSCode() +const AutocompleteTab: Component<{ onNavigateToModels?: () => void }> = (props) => { + const { settings, updateSetting } = useConfig() const language = useLanguage() - const [enableAutoTrigger, setEnableAutoTrigger] = createSignal(true) - const [enableSmartInlineTaskKeybinding, setEnableSmartInlineTaskKeybinding] = createSignal(false) - const [enableChatAutocomplete, setEnableChatAutocomplete] = createSignal(false) + const enabled = (key: string, fallback: boolean) => Boolean(settings()[key] ?? fallback) - const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { - if (message.type !== "autocompleteSettingsLoaded") { - return - } - setEnableAutoTrigger(message.settings.enableAutoTrigger) - setEnableSmartInlineTaskKeybinding(message.settings.enableSmartInlineTaskKeybinding) - setEnableChatAutocomplete(message.settings.enableChatAutocomplete) - }) - - onCleanup(unsubscribe) - - vscode.postMessage({ type: "requestAutocompleteSettings" }) - - const updateSetting = ( + const save = ( key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete", value: boolean, ) => { - vscode.postMessage({ type: "updateAutocompleteSetting", key, value }) + updateSetting(`autocomplete.${key}`, value) } return ( @@ -42,8 +26,8 @@ const AutocompleteTab: Component = () => { description={language.t("settings.autocomplete.autoTrigger.description")} > updateSetting("enableAutoTrigger", checked)} + checked={enabled("autocomplete.enableAutoTrigger", true)} + onChange={(checked) => save("enableAutoTrigger", checked)} hideLabel > {language.t("settings.autocomplete.autoTrigger.title")} @@ -55,8 +39,8 @@ const AutocompleteTab: Component = () => { description={language.t("settings.autocomplete.smartKeybinding.description")} > updateSetting("enableSmartInlineTaskKeybinding", checked)} + checked={enabled("autocomplete.enableSmartInlineTaskKeybinding", false)} + onChange={(checked) => save("enableSmartInlineTaskKeybinding", checked)} hideLabel > {language.t("settings.autocomplete.smartKeybinding.title")} @@ -69,14 +53,38 @@ const AutocompleteTab: Component = () => { last > updateSetting("enableChatAutocomplete", checked)} + checked={enabled("autocomplete.enableChatAutocomplete", false)} + onChange={(checked) => save("enableChatAutocomplete", checked)} hideLabel > {language.t("settings.autocomplete.chatAutocomplete.title")} +

+ { + e.preventDefault() + props.onNavigateToModels?.() + }} + > + {language.t("settings.autocomplete.modelsHint")} + +

) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx index 2846bec0dd..60cd1199fb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx @@ -1,18 +1,29 @@ import { Component, For, Show, createMemo, createSignal } from "solid-js" import { Card } from "@kilocode/kilo-ui/card" +import { + formatKiloEmbeddingModelLabel, + getKiloEmbeddingModel, + normalizeKiloEmbeddingModelId, +} from "@kilocode/kilo-indexing/embedding-models" import { Select } from "@kilocode/kilo-ui/select" import { Switch } from "@kilocode/kilo-ui/switch" import { TextField } from "@kilocode/kilo-ui/text-field" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useConfig } from "../../context/config" import { formatIndexingLabel, useIndexing } from "../../context/indexing" +import { useKiloEmbeddingModels } from "../../context/kilo-embedding-models" import { useLanguage } from "../../context/language" +import { useProvider } from "../../context/provider" +import { useServer } from "../../context/server" import type { IndexingConfig, IndexingProvider as ProviderId } from "../../types/messages" +import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model" import SettingsRow from "./SettingsRow" type Option = { value: string; label: string } type TuningKey = "searchMinScore" | "searchMaxResults" | "embeddingBatchSize" | "scannerMaxBatchRetries" -const providers: { value: ProviderId; label: string }[] = [ +const allProviders: { value: ProviderId; label: string }[] = [ + { value: "kilo", label: "Kilo" }, { value: "openai", label: "OpenAI" }, { value: "ollama", label: "Ollama (local)" }, { value: "openai-compatible", label: "OpenAI-Compatible" }, @@ -37,6 +48,7 @@ const tuning: Array<{ key: TuningKey; label: string; placeholder: string }> = [ ] function providerFields(provider: ProviderId | undefined): Array<{ key: string; label: string; placeholder: string }> { + if (provider === "kilo") return [] if (provider === "openai") return [{ key: "apiKey", label: "API Key", placeholder: "sk-..." }] if (provider === "ollama") return [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }] if (provider === "openai-compatible") { @@ -65,21 +77,90 @@ function providerFields(provider: ProviderId | undefined): Array<{ key: string; } const IndexingTab: Component = () => { - const { config, updateConfig } = useConfig() + const { config, globalConfig, updateConfig, updateGlobalConfig } = useConfig() const indexing = useIndexing() + const embeds = useKiloEmbeddingModels() const language = useLanguage() + const provider = useProvider() + const server = useServer() const [providerDrafts, setProviderDrafts] = createSignal>({}) const [storeDrafts, setStoreDrafts] = createSignal>({}) const [tuningDrafts, setTuningDrafts] = createSignal>({}) const cfg = createMemo(() => config().indexing ?? {}) + const globalCfg = createMemo(() => globalConfig().indexing ?? {}) + const globalOn = createMemo(() => globalCfg().enabled === true) const updateIndexing = (partial: IndexingConfig) => { updateConfig({ indexing: { ...cfg(), ...partial } }) } - const provider = () => cfg().provider const vectorStore = () => cfg().vectorStore ?? "qdrant" + const kiloDefault = () => embeds.catalog().defaultModel + const kiloModels = createMemo(() => + embeds.catalog().models.map((model) => ({ + value: model.id, + label: formatKiloEmbeddingModelLabel(model), + })), + ) + const knownKiloModel = (model: string | undefined) => getKiloEmbeddingModel(model, embeds.catalog())?.id + const kiloAvailable = () => !!server.profileData() || provider.authStates()[KILO_PROVIDER_ID] !== undefined + const selectedProvider = () => cfg().provider ?? (kiloAvailable() ? "kilo" : undefined) + const providers = createMemo(() => + allProviders.filter((item) => item.value !== "kilo" || kiloAvailable() || selectedProvider() === "kilo"), + ) + const fields = createMemo(() => providerFields(selectedProvider())) + + const saveProvider = (next: ProviderId | undefined) => { + if (next === "kilo") { + const model = knownKiloModel(cfg().model) ?? (kiloDefault() || undefined) + updateIndexing({ + provider: next, + model, + dimension: undefined, + }) + return + } + updateIndexing({ provider: next, model: undefined, dimension: undefined }) + } + + const saveEnabled = (enabled: boolean) => { + if (enabled && !cfg().provider && kiloAvailable()) { + updateIndexing({ + enabled, + provider: "kilo", + model: knownKiloModel(cfg().model) ?? kiloDefault(), + }) + return + } + updateIndexing({ enabled }) + } + + const saveGlobalEnabled = (enabled: boolean) => { + if (enabled && !globalCfg().provider && !cfg().provider && kiloAvailable()) { + updateGlobalConfig({ + indexing: { + enabled, + provider: "kilo", + model: knownKiloModel(cfg().model) ?? kiloDefault(), + }, + }) + return + } + updateGlobalConfig({ indexing: { enabled } }) + } + + const saveModel = (value: string) => { + const trimmed = value.trim() + if (!trimmed) { + updateIndexing({ model: undefined }) + return + } + updateIndexing({ + model: + selectedProvider() === "kilo" ? (normalizeKiloEmbeddingModelId(trimmed, embeds.catalog()) ?? trimmed) : trimmed, + }) + } const providerValue = (group: string, key: string) => { const draftKey = `${group}.${key}` @@ -142,17 +223,27 @@ const IndexingTab: Component = () => { + + {language.t("settings.indexing.globalEnable.title")} + + + - updateIndexing({ enabled: checked })} - hideLabel + - {language.t("settings.indexing.enable.title")} - + + {language.t("settings.indexing.projectEnable.title")} + + @@ -162,31 +253,51 @@ const IndexingTab: Component = () => { description={language.t("settings.indexing.provider.description")} > item.value === knownKiloModel(cfg().model))} + value={(item) => item.value} + label={(item) => item.label} + onSelect={(item) => updateIndexing({ model: item?.value ?? kiloDefault(), dimension: undefined })} + variant="secondary" + size="small" + triggerVariant="settings" + placeholder="Custom model" + /> + + + updateIndexing({ model: value.trim() || undefined })} + placeholder={selectedProvider() === "kilo" ? kiloDefault() || "provider/model" : "text-embedding-3-small"} + onChange={saveModel} /> { onChange={(value) => saveNumber("dimension", value, { integer: true, min: 1 })} /> - + + + + + + 0 ? selectedProvider() : undefined} keyed> {(group) => { const fields = providerFields(group) - const label = providers.find((item) => item.value === group)?.label ?? group + const label = allProviders.find((item) => item.value === group)?.label ?? group return ( {(field, index) => ( diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index 745e533ddf..c961e2d943 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -1,31 +1,20 @@ -import { Component, For, createMemo, createSignal, onCleanup } from "solid-js" +import { Component, For, createMemo } from "solid-js" import { Card } from "@kilocode/kilo-ui/card" -import { Select } from "@kilocode/kilo-ui/select" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import { useSession } from "../../context/session" -import { useVSCode } from "../../context/vscode" import { parseModelString } from "../../../../src/shared/provider-model" -import { AUTOCOMPLETE_MODELS, DEFAULT_AUTOCOMPLETE_MODEL } from "../../../../src/shared/autocomplete-models" +import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../../../src/shared/autocomplete-models" import { ModelSelectorBase } from "../shared/ModelSelector" import SettingsRow from "./SettingsRow" -import type { ExtensionMessage } from "../../types/messages" +import { AUTOCOMPLETE_PROVIDER_ID, AUTOCOMPLETE_SELECTOR_MODELS } from "./autocomplete-model-selector" const ModelsTab: Component = () => { - const { config, updateConfig } = useConfig() + const { config, settings, updateConfig, updateSetting } = useConfig() const language = useLanguage() const session = useSession() - const vscode = useVSCode() - const [autocompleteModel, setAutocompleteModel] = createSignal(DEFAULT_AUTOCOMPLETE_MODEL.id) - - const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { - if (message.type === "autocompleteSettingsLoaded") { - setAutocompleteModel(message.settings.model) - } - }) - onCleanup(unsubscribe) - vscode.postMessage({ type: "requestAutocompleteSettings" }) + const autocompleteModel = () => String(settings()["autocomplete.model"] ?? DEFAULT_AUTOCOMPLETE_MODEL.id) function handleModelSelect(configKey: "model" | "small_model") { return (providerID: string, modelID: string) => { @@ -49,6 +38,11 @@ const ModelsTab: Component = () => { } } + function handleAutocompleteModelSelect(providerID: string, modelID: string) { + if (providerID !== AUTOCOMPLETE_PROVIDER_ID || !modelID) return + updateSetting("autocomplete.model", modelID) + } + return (
@@ -82,18 +76,12 @@ const ModelsTab: Component = () => { description={language.t("settings.autocomplete.model.description")} last > - item.value === fields[prompt.key]) + : undefined + } + value={(item) => item.value} + label={(item) => optionText(prompt, item)} + onSelect={(item) => setFields(prompt.key, item?.value ?? "")} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + + {state.error} + + +
+ + + )} +
+ +
+ {state.error} +
+
-
@@ -396,7 +541,7 @@ const ProviderConnectDialog: Component = (props) =>
{formatError(state.error ?? state.failed, language.t("common.requestFailed"))}
-
diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 023f49e55a..c693b517ed 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -240,7 +240,7 @@ const Settings: Component = (props) => {

{language.t("settings.autocomplete.title")}

- + onTabChange("models")} />

{language.t("settings.notifications.title")}

diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts b/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts new file mode 100644 index 0000000000..9dd6e87996 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts @@ -0,0 +1,12 @@ +import { AUTOCOMPLETE_MODELS } from "../../../../src/shared/autocomplete-models" +import type { EnrichedModel } from "../../context/provider" + +export const AUTOCOMPLETE_PROVIDER_ID = "kilo" +export const AUTOCOMPLETE_PROVIDER_NAME = "Kilo Gateway" + +export const AUTOCOMPLETE_SELECTOR_MODELS: EnrichedModel[] = AUTOCOMPLETE_MODELS.map((m) => ({ + id: m.id, + name: m.label, + providerID: AUTOCOMPLETE_PROVIDER_ID, + providerName: AUTOCOMPLETE_PROVIDER_NAME, +})) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/BranchSelect.tsx similarity index 76% rename from packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx rename to packages/kilo-vscode/webview-ui/src/components/shared/BranchSelect.tsx index c4580234a7..be120693e7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/BranchSelect.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/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 { BranchInfo } from "../src/types/messages" +import { type Component, For, Show, type JSXElement, type ParentProps } from "solid-js" +import type { BranchInfo } from "../../types/messages" import { Icon } from "@kilocode/kilo-ui/icon" import { Spinner } from "@kilocode/kilo-ui/spinner" -import { formatRelativeDate } from "../src/utils/date" +import { formatRelativeDate } from "../../utils/date" +import { DeferredPopover } from "./DeferredPopover" interface AutoOption { label: string @@ -33,6 +34,42 @@ interface BranchSelectProps { autoOption?: AutoOption } +type Placement = "top-start" | "bottom-start" + +interface BranchSelectPopoverProps extends ParentProps { + open: boolean + onOpenChange: (open: boolean) => void + trigger: JSXElement + /** + * Where the popover opens relative to the trigger. Defaults to `top-start` + * (the agent manager pattern: trigger sits near the bottom of its panel). + * Pass `bottom-start` for triggers near the top of a view (e.g. the diff + * viewer header). + */ + placement?: Placement + /** + * Whether the popover should flip to the opposite side when there's not + * enough room. Defaults to `false` to match the agent manager behavior. + */ + flip?: boolean +} + +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/src/components/shared/DeferredPopover.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/DeferredPopover.tsx new file mode 100644 index 0000000000..2277c90f4b --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/shared/DeferredPopover.tsx @@ -0,0 +1,54 @@ +import { createEffect, createSignal, onCleanup, splitProps, type ValidComponent } from "solid-js" +import { Popover as Base, type PopoverProps } from "@kilocode/kilo-ui/popover" + +export type { PopoverProps } from "@kilocode/kilo-ui/popover" + +export interface DeferredPopoverProps extends PopoverProps { + deferDismiss?: boolean +} + +export function DeferredPopover(props: DeferredPopoverProps) { + const [local, rest] = splitProps(props, ["open", "defaultOpen", "onOpenChange", "deferDismiss"]) + const [open, setOpen] = createSignal(local.defaultOpen ?? false) + const [ready, setReady] = createSignal(true) + const defer = () => local.deferDismiss ?? false + + const controlled = () => local.open !== undefined + const opened = () => { + if (controlled()) return local.open ?? false + return open() + } + + const change = (next: boolean) => { + if (defer() && !next && !ready()) return + if (local.onOpenChange) local.onOpenChange(next) + if (controlled()) return + setOpen(next) + } + + createEffect(() => { + if (!defer()) return + if (!opened()) { + setReady(true) + return + } + + setReady(false) + + const frame = { + id: requestAnimationFrame(() => { + frame.id = requestAnimationFrame(() => { + frame.id = 0 + setReady(true) + }) + }), + } + + onCleanup(() => { + setReady(true) + if (frame.id) cancelAnimationFrame(frame.id) + }) + }) + + return +} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx index adbb5c8a7f..4e4524ff5d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx @@ -34,6 +34,8 @@ export interface ModeSwitcherBaseProps { value: string /** Called when the user picks an agent */ onSelect: (name: string) => void + /** Delay outside dismissal while the popover opens inside a dialog. */ + deferDismiss?: boolean } export const ModeSwitcherBase: Component = (props) => { @@ -103,6 +105,7 @@ export const ModeSwitcherBase: Component = (props) => { expanded={false} placement="top-start" minHeight={100} + deferDismiss={props.deferDismiss} open={open()} onOpenChange={onOpen} triggerAs={Button} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 0cbfe31fea..6011b7a304 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -90,6 +90,12 @@ export interface ModelSelectorBaseProps { clearLabel?: string /** Include the kilo-auto/small model in the list — defaults to false */ includeAutoSmall?: boolean + /** Override the provider catalog for constrained selectors. */ + models?: EnrichedModel[] + /** Show favorites group and favorite buttons — defaults to true. */ + favorites?: boolean + /** Delay outside dismissal while the popover opens inside a dialog. */ + deferDismiss?: boolean } export const ModelSelectorBase: Component = (props) => { @@ -98,7 +104,11 @@ export const ModelSelectorBase: Component = (props) => { // Session context is optional — ModelSelectorBase is also used in Settings // where SessionProvider may not be mounted. const session = useContext(SessionContext) - const activeModel = () => findModel(props.value) + const activeModel = () => { + const items = props.models + if (items) return items.find((m) => m.providerID === props.value?.providerID && m.id === props.value?.modelID) + return findModel(props.value) + } const [open, setOpen] = createSignal(false) const [expanded, setExpanded] = createSignal(false) @@ -153,6 +163,7 @@ export const ModelSelectorBase: Component = (props) => { // Only show models from Kilo Gateway or connected providers. // kilo-auto/small is excluded unless includeAutoSmall is explicitly true. const visibleModels = createMemo(() => { + if (props.models) return props.models const c = connected() return models().filter((m) => { if (!props.includeAutoSmall && isSmall(m)) return false @@ -184,11 +195,13 @@ export const ModelSelectorBase: Component = (props) => { // Live set of favorited keys — drives star icon visual state (filled vs outline). // Toggling never changes the list structure, so no items jump. const favoriteKeys = createMemo(() => { + if (props.favorites === false) return new Set() if (!session) return new Set() return new Set(session.favoriteModels().map((f) => modelKey(f.providerID, f.modelID))) }) const favoriteModels = createMemo(() => { + if (props.favorites === false) return [] if (!session || debouncedSearch()) return [] const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m])) const list = session @@ -523,6 +536,7 @@ export const ModelSelectorBase: Component = (props) => { preferredExpandedHeight={800} minHeight={200} placement={props.placement ?? "top-start"} + deferDismiss={props.deferDismiss} open={open()} onOpenChange={setOpen} triggerAs={Button} @@ -674,7 +688,7 @@ export const ModelSelectorBase: Component = (props) => { {model.providerName}
- + +
+ + ), +} + +export const FullScreenDiffBulkActionCollapseAllButton: Story = { + name: "Full-screen Diff — collapse all button", + render: () => ( + +
+ Full-screen diff action + +
+
+ ), +} + // --------------------------------------------------------------------------- // FullScreenDiffView // --------------------------------------------------------------------------- @@ -465,7 +536,7 @@ const MockTab = (props: { title: string; active?: boolean }) => (
{props.title} - +
@@ -480,9 +551,11 @@ const MockReviewTab = (props: { active?: boolean }) => (
- + + + Review - +
@@ -496,15 +569,19 @@ export const TabBarMultipleTabs: Story = {
-
- - - +
+
+ + + +
+
+ + + +
- - -