diff --git a/.gitignore b/.gitignore index 6bd9245c08b..a8bee54501a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,10 @@ node_modules .worktrees .sst .env -.idea +.idea/* +!.idea/gradle.xml +!.idea/.run +.idea/*.iml .vscode/* !.vscode/extensions.json !.vscode/launch.json diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000000..f9fe116bbd6 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.run/Run JetBrains Plugin.run.xml b/.run/Run JetBrains Plugin.run.xml new file mode 100644 index 00000000000..ac959382073 --- /dev/null +++ b/.run/Run JetBrains Plugin.run.xml @@ -0,0 +1,27 @@ + + + + + + + true + true + false + false + false + false + false + + + \ No newline at end of file diff --git a/README.md b/README.md index 9aee29c4892..e77e3fbbf7f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

- VS Code Marketplace - X (Twitter) - Substack Blog - Discord - Reddit + VS Code Marketplace + X (Twitter) + Substack Blog + Discord + Reddit

# 🚀 Kilo @@ -41,7 +41,7 @@ 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. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: -[![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig) +Watch the video ## Get Started with the CLI diff --git a/bun.lock b/bun.lock index c7c85cebbe6..870af0cd652 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -112,7 +112,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -142,7 +142,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -171,7 +171,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/openai": "2.0.101", @@ -206,7 +206,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.1.12", + "version": "7.1.20", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -214,9 +214,12 @@ "typescript": "catalog:", }, }, + "packages/kilo-jetbrains": { + "name": "@kilocode/kilo-jetbrains", + }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -236,7 +239,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -271,7 +274,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -324,7 +327,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.1.12", + "version": "7.1.20", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -401,8 +404,10 @@ "opentui-spinner": "0.0.6", "partial-json": "0.1.7", "remeda": "catalog:", + "rotating-file-stream": "3.2.9", "simple-git": "3.31.1", "solid-js": "catalog:", + "stream-chat": "9.38.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "turndown": "7.2.0", @@ -446,7 +451,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -460,14 +465,14 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.1.12", + "version": "7.1.20", "devDependencies": { "@types/bun": "catalog:", }, }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.1.12", + "version": "7.1.20", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -478,7 +483,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.1.12", + "version": "7.1.20", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -501,7 +506,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -548,7 +553,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.1.12", + "version": "7.1.20", "dependencies": { "zod": "catalog:", }, @@ -1211,6 +1216,8 @@ "@kilocode/kilo-i18n": ["@kilocode/kilo-i18n@workspace:packages/kilo-i18n"], + "@kilocode/kilo-jetbrains": ["@kilocode/kilo-jetbrains@workspace:packages/kilo-jetbrains"], + "@kilocode/kilo-telemetry": ["@kilocode/kilo-telemetry@workspace:packages/kilo-telemetry"], "@kilocode/kilo-ui": ["@kilocode/kilo-ui@workspace:packages/kilo-ui"], @@ -1983,6 +1990,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], @@ -3261,6 +3270,8 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], + "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -3797,6 +3808,8 @@ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "rotating-file-stream": ["rotating-file-stream@3.2.9", "", {}, "sha512-i9i0KkHh12ryl4xtELg+0gyoFre2PJ9RcQQLzquWsiqygyYsrZLckrqqYrthhnJZGZb4g+KUHtcoWYVq34gaug=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -3977,6 +3990,8 @@ "storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="], + "stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="], + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/flake.nix b/flake.nix index ed9a99e8a6e..1fa4fae94e1 100644 --- a/flake.nix +++ b/flake.nix @@ -150,28 +150,54 @@ ''; in pkgs.mkShell { - packages = with pkgs; [ - bun - nodejs_20 - pkg-config - openssl - git - gh - playwright-driver.browsers - vsce - unzip - gnutar - gzip - patchelf - ripgrep - kilo-dev - kilo-install-bin - kilo-bin - ]; + packages = + with pkgs; + [ + bun + nodejs_20 + pkg-config + openssl + git + gh + playwright-driver.browsers + vsce + unzip + gnutar + gzip + patchelf + ripgrep + jetbrains.jdk + jdk21 + kilo-dev + kilo-install-bin + kilo-bin + ] + ++ lib.optionals stdenv.isLinux [ + libX11 + libXext + libXrender + libXtst + libXi + fontconfig + freetype + ]; shellHook = '' export KILO_ROOT="$PWD" export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}" export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + '' + + pkgs.lib.optionalString pkgs.stdenv.isLinux '' + export LD_LIBRARY_PATH="${ + pkgs.lib.makeLibraryPath [ + pkgs.libX11 + pkgs.libXext + pkgs.libXrender + pkgs.libXtst + pkgs.libXi + pkgs.fontconfig + pkgs.freetype + ] + }:$LD_LIBRARY_PATH" ''; }; }); diff --git a/nix/hashes.json b/nix/hashes.json index 80015c18486..fffa19cd003 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-YjB3YfaA79nztpZoYEAlFBakS//G/Guprlqk4BYFgOA=", - "aarch64-linux": "sha256-ru470M8RVwyOPNGr6I4VfApQRHhYFFMPjOBneTRBJtc=", - "aarch64-darwin": "sha256-M6LIPBHnYH9AtIRAKyW44VN71Kb28nztzFteCkL0588=", - "x86_64-darwin": "sha256-HURoot79BFHYwoEp0XqVdI6cRdV7KGvmRRqOf+hUa9Q=" + "x86_64-linux": "sha256-4yd8H4UN7YIhdIQ607UoxSqwsQEKqUWkOifjQ2PEvn0=", + "aarch64-linux": "sha256-EuzzDc/j63eRV8QEFgDmQRIsiSq4ZPr9RJSdB9dH+kI=", + "aarch64-darwin": "sha256-BLCZubMC/n06HsUeO3FsvREwfsmjtiTBseSMDPPZNYM=", + "x86_64-darwin": "sha256-gU1uhExpYJhhVHbspCwCry/DwUiuddRvYl72RZqWzNo=" } } diff --git a/package.json b/package.json index 832994311a2..904a5a290d6 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.1.12", + "version": "7.1.20", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 79e2d4a7f5f..5a118458382 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.1.12", + "version": "7.1.20", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index aed0249011e..fda4582038e 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.1.12", + "version": "7.1.20", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b9b67e28063..d13399bae9e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.1.12", + "version": "7.1.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index f9f644386dd..522d8ecbbb0 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.1.12" +version = "7.1.20" 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.1.12/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.20/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.1.12/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.20/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.12/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.20/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.1.12/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.20/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.1.12/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.20/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/components/Tabs.tsx b/packages/kilo-docs/components/Tabs.tsx index 6cc69abda61..ecffe35dc6d 100644 --- a/packages/kilo-docs/components/Tabs.tsx +++ b/packages/kilo-docs/components/Tabs.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect, Children, isValidElement, ReactNode, ReactElement } from "react" +const TAB_SYNC_EVENT = "kilo-tab-select" + interface TabProps { label: string children: ReactNode @@ -39,13 +41,26 @@ export function Tabs({ children }: TabsProps) { setActiveIndex(indexFromHash()) const onHashChange = () => setActiveIndex(indexFromHash()) window.addEventListener("hashchange", onHashChange) - return () => window.removeEventListener("hashchange", onHashChange) + + const onSync = (e: Event) => { + const label = (e as CustomEvent).detail + const found = tabs.findIndex((tab) => tab.props.label === label) + if (found >= 0) setActiveIndex(found) + } + window.addEventListener(TAB_SYNC_EVENT, onSync) + + return () => { + window.removeEventListener("hashchange", onHashChange) + window.removeEventListener(TAB_SYNC_EVENT, onSync) + } }, []) const selectTab = (index: number) => { setActiveIndex(index) - const slug = slugify(tabs[index].props.label) + const label = tabs[index].props.label + const slug = slugify(label) history.replaceState(null, "", `#${slug}`) + window.dispatchEvent(new CustomEvent(TAB_SYNC_EVENT, { detail: label })) } return ( diff --git a/packages/kilo-docs/components/TopNav.tsx b/packages/kilo-docs/components/TopNav.tsx index 3d458227c40..b0b2b15127d 100644 --- a/packages/kilo-docs/components/TopNav.tsx +++ b/packages/kilo-docs/components/TopNav.tsx @@ -336,8 +336,9 @@ export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobil {/* Announcement banner */}

- We've replatformed our extensions on the new Kilo CLI. - Contribute at Kilo-Org/kilocode. + The all-new Kilo Code extension is here, rebuilt on the{" "} + Kilo CLI for speed, flexibility, and continued access to 500+ models via the Kilo Gateway + →

diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index e5d621b250f..69a90dc7786 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -5,7 +5,11 @@ export const CodeWithAiNav: NavSection[] = [ title: "Platforms", links: [ { href: "/code-with-ai", children: "Overview" }, - { href: "/code-with-ai/platforms/vscode", children: "VS Code Extension" }, + { + href: "/code-with-ai/platforms/vscode", + children: "VS Code Extension", + subLinks: [{ href: "/code-with-ai/platforms/vscode/whats-new", children: "What's New" }], + }, { href: "/code-with-ai/platforms/jetbrains", children: "JetBrains Extension", @@ -36,6 +40,11 @@ export const CodeWithAiNav: NavSection[] = [ href: "/code-with-ai/agents/auto-model", children: "Auto Model", }, + { + href: "/code-with-ai/agents/custom-models", + children: "Custom Models", + platform: "new", + }, { href: "/code-with-ai/agents/free-and-budget-models", children: "Free & Budget Models", diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts index 90ee60a72dc..695801fc49d 100644 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ b/packages/kilo-docs/lib/nav/kiloclaw.ts @@ -7,6 +7,7 @@ export const KiloClawNav: NavSection[] = [ { href: "/kiloclaw/overview", children: "Overview" }, { href: "/kiloclaw/dashboard", children: "Dashboard" }, { href: "/kiloclaw/pre-installed-software", children: "Pre-installed Software" }, + { href: "/kiloclaw/end-to-end", children: "End to End Config" }, { href: "/kiloclaw/control-ui/overview", children: "Control UI", @@ -36,6 +37,14 @@ export const KiloClawNav: NavSection[] = [ { href: "/kiloclaw/development-tools/google", children: "Google Workspace" }, ], }, + { + href: "/kiloclaw/triggers", + children: "Triggers", + subLinks: [ + { href: "/kiloclaw/triggers", children: "Overview" }, + { href: "/kiloclaw/triggers/webhooks", children: "Webhooks" }, + ], + }, { href: "/kiloclaw/tools", children: "Tools", diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index dccd254c000..f86612c02b0 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -34,4 +34,9 @@ exclude = [ '^https?://vercel\.link/', # API base URL, returns 404 when fetched directly '^https?://api\.apertis\.ai/v1/?$', + # Redirects to authenticated Google Cloud console + '^https?://console\.cloud\.google\.com', + # Consistently times out in CI + '^https?://opncd\.ai', + '^https?://zod\.dev/v4/changelog', ] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 33f8ab6751a..35af7893fb5 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.1.12", + "version": "7.1.20", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/ai-providers/lmstudio.md b/packages/kilo-docs/pages/ai-providers/lmstudio.md index e22058308b2..3ca78d716f4 100644 --- a/packages/kilo-docs/pages/ai-providers/lmstudio.md +++ b/packages/kilo-docs/pages/ai-providers/lmstudio.md @@ -71,6 +71,44 @@ Then set your default model: {% /tab %} {% /tabs %} +## Using Custom or Unlisted Models + +If the model you loaded in LM Studio doesn't appear in the Kilo model picker, you can register it as a custom model in your config file: + +```jsonc +{ + "model": "lmstudio/my-custom-model", + "provider": { + "lmstudio": { + "models": { + "my-custom-model": { + "name": "My Custom Model", + }, + }, + }, + }, +} +``` + +The model key (`my-custom-model`) must match the model identifier that LM Studio serves. If the display name you want differs from the API identifier, use the `id` field to set the API-facing name separately: + +```jsonc +{ + "provider": { + "lmstudio": { + "models": { + "my-llama": { + "id": "meta-llama-3.1-8b-instruct", + "name": "Llama 3.1 8B (Local)", + }, + }, + }, + }, +} +``` + +See [Custom Models](/docs/code-with-ai/agents/custom-models) for the full list of configuration fields and more examples. + ## Tips and Notes - **Resource Requirements:** Running large language models locally can be resource-intensive. Make sure your computer meets the minimum requirements for the model you choose. diff --git a/packages/kilo-docs/pages/ai-providers/ollama.md b/packages/kilo-docs/pages/ai-providers/ollama.md index 33f8199e24e..3f2ba1887c2 100644 --- a/packages/kilo-docs/pages/ai-providers/ollama.md +++ b/packages/kilo-docs/pages/ai-providers/ollama.md @@ -116,6 +116,32 @@ Then set your default model: {% /tab %} {% /tabs %} +## Using Custom or Unlisted Models + +If your Ollama model doesn't appear in the Kilo model picker, register it as a custom model in your config file: + +```jsonc +{ + "model": "ollama/my-finetune:latest", + "provider": { + "ollama": { + "models": { + "my-finetune:latest": { + "name": "My Fine-tuned Model", + "tool_call": true, + "limit": { + "context": 32768, + "output": 8192, + }, + }, + }, + }, + }, +} +``` + +See [Custom Models](/docs/code-with-ai/agents/custom-models) for the full list of configuration fields and more examples. + ## Further Reading Refer to the [Ollama documentation](https://ollama.com/docs) for more information on installing, configuring and using Ollama. diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 5e373573752..2e335fa9365 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -40,9 +40,25 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon {% /tab %} {% tab label="VSCode" %} -Open **Settings** (gear icon) and go to the **Providers** tab to add an OpenAI Compatible provider. Enter your API key and the provider's base URL. +1. Open **Settings** (gear icon) and go to the **Providers** tab. +2. Scroll to the bottom and click **Custom provider**. -The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. +![Custom provider button](/docs/img/custom-models/custom-provider-button.png) + +3. Fill in the custom provider dialog: + +![Custom provider configuration dialog](/docs/img/custom-models/custom-provider-details.png) + +- **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. +- **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. +- **Headers** (optional) — Custom HTTP headers as key-value pairs. + +4. Click **Submit** to save. The provider's models appear in the model picker. + +For additional model configuration (token limits, tool calling, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab or the [Custom Models](/docs/code-with-ai/agents/custom-models) guide. {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 36f4d816539..5e2c539e6a1 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -71,7 +71,14 @@ Press `Cmd+D` (macOS) / `Ctrl+D` (Windows/Linux) to toggle the diff panel. It sh ## Terminals -Each session has a dedicated integrated terminal. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session. +Each session has a dedicated integrated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session. + +### Switching Between Terminal and Agent Manager + +A common workflow is letting the agent work, then switching to the terminal to run tests or inspect the worktree, then switching back to control the agent: + +1. **Agent Manager → Terminal:** Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to open and focus the terminal for the current session. The terminal runs inside the session's worktree, so commands like `npm test` or `git status` operate on the agent's isolated branch. +2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This works from anywhere in VS Code — the terminal, another editor tab, or the sidebar. ## Setup Scripts @@ -83,21 +90,21 @@ Agent Manager state is persisted in `.kilo/agent-manager.json`. Sessions, worktr ## Keyboard Shortcuts (Agent Manager Panel) -| Shortcut (macOS) | Shortcut (Windows/Linux) | Action | -| ------------------------ | ------------------------- | --------------------------------- | -| `Cmd+Shift+M` | `Ctrl+Shift+M` | Open Agent Manager | -| `Cmd+N` | `Ctrl+N` | New worktree | -| `Cmd+Shift+N` | `Ctrl+Shift+N` | New worktree (advanced options) | -| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree | -| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree | -| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree | -| `Cmd+W` | `Ctrl+W` | Close current tab | -| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree | -| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree | -| `Cmd+/` | `Ctrl+/` | Show terminal for current session | -| `Cmd+D` | `Ctrl+D` | Toggle diff panel | -| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts | -| `Cmd+1` … `Cmd+9` | `Ctrl+1` … `Ctrl+9` | Jump to worktree/session by index | +| Shortcut (macOS) | Shortcut (Windows/Linux) | Action | +| ------------------------ | ------------------------- | ------------------------------------------------ | +| `Cmd+Shift+M` | `Ctrl+Shift+M` | Open / focus Agent Manager (works from anywhere) | +| `Cmd+N` | `Ctrl+N` | New worktree | +| `Cmd+Shift+N` | `Ctrl+Shift+N` | New worktree (advanced options) | +| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree | +| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree | +| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree | +| `Cmd+W` | `Ctrl+W` | Close current tab | +| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree | +| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree | +| `Cmd+/` | `Ctrl+/` | Focus terminal for current session | +| `Cmd+D` | `Ctrl+D` | Toggle diff panel | +| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts | +| `Cmd+1` … `Cmd+9` | `Ctrl+1` … `Ctrl+9` | Jump to worktree/session by index | ## Troubleshooting diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index c89e043a9a4..5b04e1137d5 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -56,19 +56,32 @@ Once configured, the Review Agent runs automatically on PR/MR events. For platfo Code Reviewer is also available locally. This is valuable for developers who want to review their code before pushing a pull request to their team publicly, or for developers who want reviews and don't need to ship a pull request to GitHub. -### VS Code +{% tabs %} +{% tab label="VSCode" %} -Select 'Review' from the mode dropdown after making local changes, and click 'Send' for AI-powered feedback and suggestions. - -![VS Code interface showing Review option in mode dropdown](/docs/img/code-reviewer/review-mode.png "Review Mode") - -### CLI - -The CLI provides two commands for local code reviews: +Two slash commands are available for local code reviews: - **`/local-review`** — Review all changes on your current branch vs the base branch - **`/local-review-uncommitted`** — Review uncommitted changes (staged + unstaged) +{% /tab %} +{% tab label="CLI" %} + +Two slash commands are available for local code reviews: + +- **`/local-review`** — Review all changes on your current branch vs the base branch +- **`/local-review-uncommitted`** — Review uncommitted changes (staged + unstaged) + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +Select 'Review' from the mode dropdown after making local changes, and click 'Send' for AI-powered feedback and suggestions. + +{% image src="/docs/img/code-reviewer/review-mode.png" alt="VS Code interface showing Review option in mode dropdown" width="800" caption="Review Mode" /%} + +{% /tab %} +{% /tabs %} + ## How Code Reviews Work When a pull request or merge request is opened or updated: diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 2320c8ea217..1510f122224 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -61,7 +61,7 @@ When you describe a task, the agent uses its tools — `read`, `grep`, `glob`, a In the terminal-based TUI, you can provide context in several ways: -- **Type `@` for file autocomplete** — In the TUI, type `@` followed by a filename to get autocomplete suggestions for quick file references. +- **Type `@` for file autocomplete** — In the TUI, type `@` followed by a filename to get autocomplete suggestions. Selecting a file attaches its contents to your message. You can limit how much is included by appending a line range, e.g. `@src/utils.ts#10-50`. - **Mention file paths in your message** — Simply refer to files by path in your conversation text (e.g., "look at src/utils.ts") and the agent will read them. - **Use `kilo run -f`** — When using the non-interactive `kilo run` command, pass `-f path/to/file.ts` to explicitly include a file's contents in the context. - **Let the agent find files itself** — The agent has access to `glob` (find files by pattern), `grep` (search file contents), and `read` (read file contents) tools. Describe what you're looking for and it will locate the relevant code. 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 new file mode 100644 index 00000000000..52d0473d577 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -0,0 +1,317 @@ +--- +title: "Custom Models" +description: "How to configure custom or unlisted models for any provider" +platform: new +--- + +# Custom Models + +Kilo Code ships with a curated list of models for each provider, but you can use **any model** your provider supports — including models that aren't in the built-in list. This is useful for: + +- Using a newly released model before it's added to the built-in catalog +- Running a custom or fine-tuned model via LM Studio, Ollama, or another local provider +- Connecting to a self-hosted model behind an OpenAI-compatible API +- Configuring model-specific options like token limits, pricing, or reasoning settings + +## Defining a Custom Model + +Add custom models under the `provider..models` key in your config file. The model key becomes the model ID you reference elsewhere. + +{% tabs %} +{% tab label="CLI" %} + +**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`): + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "lmstudio/my-custom-model", + "provider": { + "lmstudio": { + "models": { + "my-custom-model": { + "name": "My Custom Model", + }, + }, + }, + }, +} +``` + +{% /tab %} +{% tab label="VSCode" %} + +1. Open **Settings** (gear icon) and go to the **Providers** tab. + +2. Scroll to the bottom of the provider list and click **Custom provider**. + +![Custom provider button in the Providers tab](/docs/img/custom-models/custom-provider-button.png) + +3. Fill in the custom provider dialog: + +![Custom provider configuration dialog](/docs/img/custom-models/custom-provider-details.png) + +- **Provider ID** — A unique identifier using lowercase letters, numbers, hyphens, or underscores (e.g., `myprovider`). This becomes the `provider_id` in the `provider_id/model_id` format. +- **Display name** — A human-readable name shown in the UI (e.g., `My AI Provider`). +- **Base URL** — The OpenAI-compatible API endpoint (e.g., `https://api.myprovider.com/v1`). When a valid URL is entered, Kilo automatically fetches available models from the endpoint. +- **API key** — Your provider's API key. Optional — leave empty if you manage authentication via headers. +- **Models** — Add models manually by ID and display name, or select from the auto-fetched list that appears after entering a valid base URL. +- **Headers** (optional) — Add custom HTTP headers as key-value pairs if your provider requires them. + +4. Click **Submit** to save. Your custom provider appears in the provider list and its models become available in the model picker. + +To edit an existing custom provider, click the **Edit provider** button next to it in the connected providers section. + +For additional model configuration (token limits, tool calling, reasoning, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab for the format. + +{% /tab %} +{% /tabs %} + +The `model` key uses the format `provider_id/model_id`, where: + +- **`provider_id`** is the key under `provider` (e.g., `lmstudio`, `ollama`, `openai`, `anthropic`, `openai-compatible`) +- **`model_id`** is the key under `provider..models` (e.g., `my-custom-model`) + +## Model Configuration Fields + +All fields are optional. When a model ID matches one already in the built-in catalog, your values are merged on top of the defaults — you only need to specify what you want to override. + +| Field | Type | Description | +| ------------- | --------- | ----------------------------------------------------------------------------- | +| `name` | `string` | Display name shown in the model picker | +| `id` | `string` | API-facing model ID sent to the provider. Defaults to the config key | +| `tool_call` | `boolean` | Whether the model supports tool/function calling | +| `reasoning` | `boolean` | Whether the model supports extended thinking | +| `temperature` | `boolean` | Whether the model supports the temperature parameter | +| `attachment` | `boolean` | Whether the model supports file attachments | +| `limit` | `object` | Token limits: `{ context, output, input? }` | +| `cost` | `object` | Pricing per million tokens: `{ input, output, cache_read?, cache_write? }` | +| `options` | `object` | Arbitrary provider-specific model options | +| `headers` | `object` | Custom HTTP headers to include in requests | +| `provider` | `object` | Override `{ npm?, api? }` — the AI SDK package or base API URL for this model | +| `variants` | `object` | Named variant configurations (e.g., different reasoning efforts) | + +## Examples + +### Local model with LM Studio + +Register a model that LM Studio serves under a custom name: + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "lmstudio/deepseek-r1-0528", + "provider": { + "lmstudio": { + "models": { + "deepseek-r1-0528": { + "name": "DeepSeek R1 0528", + }, + }, + }, + }, +} +``` + +### Local model with Ollama + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "ollama/my-finetune:latest", + "provider": { + "ollama": { + "models": { + "my-finetune:latest": { + "name": "My Fine-tuned Model", + "tool_call": true, + "limit": { + "context": 32768, + "output": 8192, + }, + }, + }, + }, + }, +} +``` + +### New or unlisted model from a cloud provider + +Use a model that's not yet in the built-in catalog: + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "openai/gpt-6-preview", + "provider": { + "openai": { + "models": { + "gpt-6-preview": { + "name": "GPT-6 Preview", + "tool_call": true, + "reasoning": true, + "limit": { + "context": 200000, + "output": 32768, + }, + }, + }, + }, + }, +} +``` + +### OpenAI-compatible provider with a custom endpoint + +Connect to any provider that exposes an OpenAI-compatible API: + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "openai-compatible/my-model", + "provider": { + "openai-compatible": { + "options": { + "apiKey": "{env:MY_PROVIDER_API_KEY}", + "baseURL": "https://api.my-provider.com/v1", + }, + "models": { + "my-model": { + "name": "My Custom Model", + "tool_call": true, + "limit": { + "context": 128000, + "output": 16384, + }, + }, + }, + }, + }, +} +``` + +### Configuring model options and variants + +Override options or define reasoning variants for a built-in model: + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "provider": { + "anthropic": { + "models": { + "claude-sonnet-4-20250514": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000, + }, + }, + "variants": { + "thinking-high": { + "thinking": { + "type": "enabled", + "budgetTokens": 32000, + }, + }, + "fast": { + "disabled": true, + }, + }, + }, + }, + }, + }, +} +``` + +### Using the `id` field to map model names + +If the model key in your config differs from what the provider expects, use the `id` field: + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "lmstudio/my-local-llama", + "provider": { + "lmstudio": { + "models": { + "my-local-llama": { + "id": "meta-llama-3.1-8b-instruct", + "name": "Llama 3.1 8B (Local)", + }, + }, + }, + }, +} +``` + +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. + +## Model Loading Priority + +When Kilo starts, it resolves the active model in this order: + +1. The `--model` (or `-m`) command-line flag +2. The `model` key in your config file +3. The last used model from your previous session +4. The first available model using an internal priority + +The format for all of these is `provider_id/model_id`. + +## Provider-Level Options + +You can also set options that apply to all models from a provider: + +```jsonc +{ + "provider": { + "openai": { + "options": { + "apiKey": "{env:OPENAI_API_KEY}", + "baseURL": "https://my-proxy.example.com/v1", + "timeout": 120000, + }, + }, + }, +} +``` + +| Option | Type | Description | +| --------- | ----------------- | ------------------------------------------------------ | +| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | +| `baseURL` | `string` | Override the provider's base API URL | +| `timeout` | `number \| false` | Request timeout in milliseconds, or `false` to disable | + +## Filtering Available Models + +Control which models appear in the model picker for a provider using allowlists and blocklists: + +```jsonc +{ + "provider": { + "openai": { + "whitelist": ["gpt-5", "gpt-5-mini"], + "blacklist": ["gpt-4-turbo"], + }, + }, +} +``` + +- **`whitelist`** — only these model IDs are available from this provider +- **`blacklist`** — these model IDs are hidden from this provider + +## Troubleshooting + +**Model doesn't appear in the model picker:** + +- Verify the provider has valid credentials configured (API key, or local server running) +- Check that the model key matches what you set in `"model": "provider/model-key"` +- Run `kilo models` to list all available models and confirm your provider is active + +**Model errors or unexpected behavior:** + +- Set `tool_call: true` if you need the model to use tools (file editing, terminal, etc.) +- Set `limit.context` and `limit.output` to match the model's actual capabilities +- For local models, ensure your inference server is running and accessible at the configured URL diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 43d9dbec509..932babfabbb 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -54,6 +54,8 @@ While the specifics change constantly, some principles stay consistent: **For local/private work**: Ollama and LM Studio let you run models locally. The tradeoff is usually speed and capability for privacy and zero API costs. +**Using an unlisted model?** You can register any model — including fine-tunes, newly released models, or custom local models — by adding it to your config file. See [Custom Models](/docs/code-with-ai/agents/custom-models) for details. + ## Context Windows Matter One thing that doesn't change: context window size matters for your workflow. diff --git a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md index f928aef3899..9b8117a53b4 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md +++ b/packages/kilo-docs/pages/code-with-ai/features/checkpoints.md @@ -19,7 +19,7 @@ Checkpoints let you: {% tabs %} {% tab label="VSCode" %} -Checkpoints (called **snapshots** in the new extension) are configured via the `snapshot` key in your config file (`kilo.jsonc` or `~/.config/kilo/kilo.jsonc`): +Checkpoints (called **snapshots** in the new extension) are enabled by default. They are configured via the `snapshot` key in your config file (`kilo.jsonc` or `~/.config/kilo/kilo.jsonc`): ```json { @@ -30,8 +30,12 @@ Checkpoints (called **snapshots** in the new extension) are configured via the ` You can also toggle this in Settings: 1. Open Settings by clicking the gear icon {% codicon name="gear" /%} -2. Go to the `Checkpoints` tab -3. Toggle the snapshot setting +2. Go to the **Checkpoints** tab +3. Toggle the snapshot setting on or off + +{% callout type="info" %} +Unlike the legacy extension which used a separate shadow Git repository, the new extension uses a dedicated snapshot Git repository stored outside your project. Your project's `.git` history is never modified by the snapshot system. +{% /callout %} {% /tab %} {% tab label="CLI" %} @@ -64,11 +68,24 @@ Access checkpoint settings in Kilo Code settings under the "Checkpoints" section {% tabs %} {% tab label="VSCode" %} -The new extension uses **git-based snapshots** of your working directory. Snapshots are taken before and after agent edits. They integrate with your existing Git repository — no separate shadow repository is created. +The new extension uses **git-based snapshots** to track your workspace state. A dedicated Git repository (with a detached work tree pointing at your project) is created outside your project directory to store snapshot data — your project's own `.git` history is never touched. -**Reverting changes:** +Snapshots are captured automatically at the boundaries of each model call within an agent turn: -You can revert any message's changes from the chat. A **Revert Banner** appears at the top of the chat when the session is in a reverted state, making it clear that you are viewing an earlier state. Use the banner to unrevert and return to the latest state. +1. **Before** the model starts generating (step start) +2. **After** the model finishes and its tool calls have been executed (step finish) + +A single user message can produce **multiple steps**. For example, if the agent edits a file, runs a command, sees the output, and then edits another file, each model call in that sequence gets its own snapshot pair. The system records which files changed in each step. + +However, while snapshots are taken at each step boundary, **the revert UI operates at the user message level**. You can only revert to the point just before a user message was sent — you cannot revert to an intermediate step within a single agent response. + +{% callout type="warning" %} +Revert granularity is **per user message**, not per individual step or file edit. If the agent makes changes across multiple steps within a single response, reverting will undo all of those changes at once. +{% /callout %} + +{% callout type="info" %} +Snapshots respect your `.gitignore` rules. Files ignored by Git (such as `node_modules/`, `dist/`, or `.env`) are excluded from snapshots. +{% /callout %} {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -102,12 +119,50 @@ Checkpoints are stored as Git commits in the shadow repository, capturing: {% tabs %} {% tab label="VSCode" %} -Checkpoints are integrated directly into your workflow through the chat interface. Each message that caused file changes shows a diff summary. You can: +Checkpoints are integrated directly into your chat interface. Each agent turn that modified files shows a collapsible diff summary listing the changed files with addition/deletion counts. -- Click the diff badge on a message to open the **Diff Viewer** and review what changed -- Click **Revert** on any message to restore the workspace to its state before that message +### Viewing Changes -A **Revert Banner** is shown at the top of the chat whenever you are in a reverted state, with an option to return to the current state. +Click the diff summary on any agent turn to expand it and see which files were modified. Click an individual file to open a side-by-side diff in the VS Code editor. + +### Reverting with "Revert to here" + +Every user message in the chat that has a corresponding agent response shows a **Revert to here** button (a left-arrow icon) when you hover over it: + +{% image src="/docs/img/checkpoints/revert-to-here-button.png" alt="Revert to here button shown on hover over a user message" width="350" /%} + +The revert button appears on **user messages only** — these are the revert points in the conversation. You revert to the state your workspace was in just before a given user message was sent. There is no way to revert to a point partway through an agent response. + +Clicking **Revert to here** does two things: + +1. **Restores your workspace files** to the state they were in just before that message was sent +2. **Hides all subsequent messages** in the chat so you see the conversation as it was at that point + +The button is only active when the agent is idle. While the agent is running, the button is disabled to prevent reverting mid-operation. + +### The Revert Banner + +After reverting, a **Revert Banner** appears at the bottom of the chat. The banner shows: + +- The number of messages that were reverted (e.g. "1 message reverted" or "3 messages reverted") +- A per-file breakdown of the changes that were undone, with addition/deletion counts +- A hint: "Send a new message to make this permanent" + +The banner provides two actions: + +- **Redo** — Steps forward one message at a time, re-applying changes from the next reverted message +- **Redo All** — Restores the workspace to the latest state and un-hides all messages (only shown when more than one message is reverted) + +### Making a Revert Permanent + +While in a reverted state, you have two choices: + +- **Redo / Redo All** to return to where you were +- **Send a new message** to branch off from the reverted point. When you send a new message while reverted, the reverted messages are permanently deleted from the session and the agent continues from the restored state. This is how you "undo" the agent's work and try a different approach. + +{% callout type="tip" %} +Reverting is non-destructive until you send a new message. You can freely revert and redo to compare different states of your code without losing anything. +{% /callout %} {% /tab %} {% tab label="CLI" %} @@ -183,7 +238,38 @@ To restore a project to a previous checkpoint state: {% tabs %} {% tab label="VSCode" %} -The new extension uses the underlying Git repository in your workspace to create snapshot commits. No shadow repository is used. +### Snapshot Architecture + +The snapshot system consists of: + +1. **Snapshot Git Repository**: A dedicated Git repository created outside your project at `~/.local/share/kilo/snapshot///`. This stores all snapshot tree objects without affecting your project's Git history. Each worktree gets its own snapshot repository, identified by a hash of the worktree path. + +2. **Step-level Snapshots**: The agent runtime automatically runs `git write-tree` against your workspace before and after each agent step. The resulting tree hashes are stored alongside the conversation messages. + +3. **Patch Records**: After each step, the system records which files were modified. These patch records enable targeted file-level reverts rather than full-workspace restores. + +### How Revert Works + +When you click "Revert to here" on a message: + +1. The system collects all patch records (file change lists) from messages after the revert point +2. A snapshot of the current workspace is taken so the operation can be undone +3. For each changed file, the system checks out the version from the pre-change snapshot using the stored tree hash +4. Files that were created by the agent (and didn't exist before) are deleted +5. The session records the revert state so the UI can show the Revert Banner + +When you click "Redo All" (unrevert): + +1. The workspace is fully restored from the snapshot taken in step 2 above using `git checkout-index` +2. The revert state is cleared from the session + +### Storage and Cleanup + +Snapshot data is stored per-project and is periodically cleaned up. A background process runs `git gc --prune=7.days` every hour, which removes unreachable snapshot objects older than 7 days. Because snapshots are stored as raw tree hashes (not refs or commits), older snapshots may be pruned by garbage collection even if a session still references them. + +### Worktree Isolation + +When using the Agent Manager with git worktrees, each worktree gets its own isolated snapshot repository. This prevents snapshot data from one worktree interfering with another while sharing underlying Git objects for storage efficiency. {% /tab %} {% tab label="VSCode (Legacy)" %} 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 91b40871124..1ee5cbfe61d 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -320,14 +320,18 @@ Project-level configuration takes precedence over global settings. Common configuration options include: -- **`model`** - Default model to use -- **`provider`** - Provider-specific settings (API keys, base URLs, custom models) +- **`model`** - Default model in `provider_id/model_id` format (e.g., `"anthropic/claude-sonnet-4-20250514"`) +- **`provider`** - Provider-specific settings (API keys, base URLs, [custom models](/docs/code-with-ai/agents/custom-models)) - **`mcp`** - MCP server configuration - **`permission`** - Tool permission settings (`allow` or `ask`) - **`instructions`** - Paths to instruction files (e.g., `["CONTRIBUTING.md", ".cursor/rules/*.md"]`) - **`formatter`** - Code formatter configuration - **`disabled_providers`** / **`enabled_providers`** - Control which providers are available +{% callout type="tip" %} +**Using a model that's not in the built-in list?** You can register any model by adding it under `provider..models` in your config file. See [Custom Models](/docs/code-with-ai/agents/custom-models) for full details and examples. +{% /callout %} + ### Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: @@ -464,10 +468,20 @@ The CLI supports overriding config values with environment variables. The suppor - For `kilocode` provider: `KILOCODE_` (e.g., `KILOCODE_MODEL` → `kilocodeModel`) - For other providers: `KILO_` (e.g., `KILO_API_KEY` → `apiKey`) -## Switching into an Organization from the CLI +## Using the CLI in an Organization -Use the `/teams` command to see a list of all organizations you can switch into. +If you belong to a Kilo organization (Team or Enterprise), you can route CLI requests through that organization. The process differs slightly between interactive and non-interactive usage. -Use `/teams` and select a team to switch teams. +### Interactive Usage -The process is the same when switching into a Team or Enterprise organization. +In an interactive CLI session, use the `/teams` command to select an organization from your membership list. + +Your selection is persisted locally so it carries over to future sessions. + +### Non-Interactive Usage (`kilo run`) + +There is no `--org` or `--team` flag on `kilo run`. Instead, the organization is determined from the following sources, in order of priority (highest first): + +1. **`KILO_ORG_ID` environment variable** — Best for non-interactive and CI environments. + +2. **`Persisted selection from the last `/teams` pick`** — If you've run an interactive session and selected an organization via `/teams`, that selection is stored in the CLI auth file and reused automatically. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md similarity index 100% rename from packages/kilo-docs/pages/code-with-ai/platforms/vscode.md rename to packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md new file mode 100644 index 00000000000..5cbb2854324 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md @@ -0,0 +1,58 @@ +--- +title: "What's New in Kilo Code (April 2026)" +description: "The Kilo Code extension has been rebuilt from the ground up on the Kilo CLI — faster, more flexible, and with access to 500+ models." +--- + +# What's New in Kilo Code + +The Kilo Code extension has been completely rebuilt on a portable, open-source core shared across VS Code, the CLI, and Cloud Agents. This is the biggest update since launch: faster execution with parallel tool calls and subagents, the new Agent Manager for running multiple agents side by side, inline code review with line-level comments, multi-model comparisons, and access to 500+ models. + +Whether you're writing features in VS Code, debugging over SSH, or reviewing code on Slack, Kilo now goes with you. Read the [full announcement on the Kilo Blog](https://blog.kilo.ai/p/new-kilo-for-vs-code-is-live) for everything that's new. + +--- + +## Adjusting to the new version + +A lot has changed under the hood, and some things have moved around. If you're coming from the previous extension, you might have questions about where to find certain features or how things work now. We've collected the most common questions below. + +Still stumped after reading this? Come find us in discord at #vscode. + +### Where did code indexing go? + +Code indexing is temporarily unavailable in the new extension. It is actively being worked on and is expected to return soon. Please follow [this issue](https://github.com/Kilo-Org/kilocode/issues/6144) + +### How do checkpoints work in the new extension? + +Checkpoints are now called **snapshots** in the new extension. +They use Git-based snapshots of your working directory, taken before and after agent edits. +You can revert any message's changes directly from the chat, and a revert banner appears when you're viewing an earlier state. +See the [Checkpoints documentation](/docs/code-with-ai/features/checkpoints) for details. + +### Where is the auto-approve settings UI? + +The old auto-confirm commands UI has been replaced by a granular per-tool permission system. +Open **Settings → Auto Approve** to configure each tool (bash, read, edit, glob, grep, etc.) with **Allow**, **Ask**, or **Deny**. +There is no longer a separate command allowlist — shell execution is controlled by the `bash` tool permission. +See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for more information. + +### Is the context progress graph still available? + +The context progress graph is being evaluated for the new extension. +This feature may be reintroduced in a future update. + +### Where is the diff view for file changes? + +Each message that caused file changes shows a **diff badge** in the chat — click it to open the Diff Viewer and review what changed. +The Agent Manager also includes a built-in diff reviewer that shows every change file by file, in unified or split view. + +### How do I do code reviews in the new extension? + +You can now trigger local AI-powered code reviews directly by using two commands: **`/local-review`** to review all changes on your current branch vs the base branch, and **`/local-review-uncommitted`** to review staged and unstaged changes. +See the [Code Reviews](/docs/automate/code-reviews/overview) documentation for the full setup and options. + +### Where did orchestrator mode go? + +Orchestrator mode is deprecated. +Agents with full tool access (Code, Plan, Debug) can now **delegate to subagents automatically** — you no longer need a dedicated orchestrator. Just pick the agent for your task and it will coordinate subagents when helpful. +You can also define your own [custom subagents](/docs/customize/custom-subagents). +See the [Orchestrator Mode](/docs/code-with-ai/agents/orchestrator-mode) page for the full details on what changed. diff --git a/packages/kilo-docs/pages/kiloclaw/end-to-end.md b/packages/kilo-docs/pages/kiloclaw/end-to-end.md new file mode 100644 index 00000000000..5aa0e1913d8 --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/end-to-end.md @@ -0,0 +1,157 @@ +--- +title: "Setup walkthrough" +description: "Start-to-finish guide for configuring your KiloClaw instance" +--- + +# Setup walkthrough + +This guide walks you through a full KiloClaw setup — from creating accounts to scheduling your first automated workflow. Plan for about 60 minutes. + +## Planning your setup + +For most users, a useful KiloClaw configuration involves: + +1. A **chat platform** (called a "channel" in OpenClaw) so you can message your Claw +2. **Google services** for email, calendar, and Drive +3. **GitHub** for code and markdown syncing + +### Use dedicated accounts for your Claw + +We recommend creating **separate accounts** for your KiloClaw rather than connecting it to your personal accounts. This applies to Google, GitHub, and any other services you connect. A dedicated account improves isolation — your personal data stays separate, and you can control exactly what access the Claw has by sharing or delegating only what you want. + +### Chat platform options + +- **[Kilo Chat](https://app.kilo.ai)** — available in the web app and coming soon to iOS and Android; requires zero configuration +- **[Telegram](/docs/kiloclaw/chat-platforms/telegram)** — easy to set up, private by default +- **[Discord](/docs/kiloclaw/chat-platforms/discord)** — moderate setup +- **[Slack](/docs/kiloclaw/chat-platforms/slack)** — most involved setup + +{% callout type="warning" title="Chain-of-connection security" %} +If your Claw has access to sensitive data (like your email), be careful which chat platform you connect it to. On broadly-accessible platforms like Slack or Discord, anyone on the server could potentially message your Claw and access that data. If you're connecting sensitive integrations, use a private platform like Kilo Chat or Telegram. +{% /callout %} + +The steps below walk you through this configuration. + +## Preflight Steps + +Take these steps before configuring your Claw. + +If you are doing a [1-1 configuration call with Kilo](https://kilo.ai/kiloclaw/config-service), please complete these steps before the call. + +### Google + +Configuring Google services is by far the most involved part of setting up your Claw. + +Before configuring, take these preflight steps: + +1. **Create a Google Account for your Claw** — Go to [google.com](https://www.google.com/) and create a new Google/Gmail account dedicated to your KiloClaw. Something like `yourname.bot@gmail.com` works well. + +2. **Set up Google Cloud** — Visit [console.cloud.google.com](https://console.cloud.google.com). Accept the terms of service and click "Start my free tier". You may need to add a credit card for identity verification. + + {% callout type="info" %} + Nothing KiloClaw does costs any money with Google. + {% /callout %} + +3. **Install Docker** — KiloClaw configures Google by running a Docker container on your machine. Download Docker at [docker.com](https://www.docker.com/), then open it. You don't need to sign in or create a Docker account. + +## Other Services + +1. **Create a GitHub account for your Claw** — Using your new Gmail address, create a matching GitHub account for your Claw. + +## Set up a messaging platform + +Your Claw needs a way to communicate with you. **[Kilo Chat](https://app.kilo.ai)** requires no setup — just open the web app. For other platforms, follow the relevant guide: + +- [Telegram](/docs/kiloclaw/chat-platforms/telegram) — about 2 minutes +- [Discord](/docs/kiloclaw/chat-platforms/discord) — about 10 minutes +- [Slack](/docs/kiloclaw/chat-platforms/slack) — about 15 minutes; always use the manifest + +{% callout type="tip" %} +If you're not sure which to pick, Kilo Chat (no setup) or Telegram (2 minutes) are the easiest options. +{% /callout %} + +## Set up Google OAuth + +This lets your Claw act as the bot Google account — sending email, reading calendar, and more. Takes about 15 minutes. + +Prerequisites: Docker is installed and running, and your bot Google account is already created. + +1. In the KiloClaw dashboard, go to **Settings → Google Account** and copy the Docker command shown. +2. Open a terminal and run the command. +3. Follow the steps in the console: + - At each step, confirm you're logged in to the bot account (check the top-right corner of the screen). + - After project creation, confirm you're in the correct project. + - The last step may look like it failed — this is expected. + +For full details, see the [Google setup guide](/docs/kiloclaw/development-tools/google). + +## Set up GitHub + +A dedicated bot GitHub account is strongly recommended. Takes about 7 minutes. + +**Create a Personal Access Token (PAT):** + +1. In GitHub, go to **Settings → Developer Settings → Personal Access Tokens → Classic → Generate new token**. +2. Select these scopes: `repo`, `workflow`, `write:org`, `read:user`. + +For full details, see the [GitHub setup guide](/docs/kiloclaw/development-tools/github). + +**Set up a private workspace repo:** + +Once GitHub is connected, ask your Claw to back up its workspace: + +> Use your GitHub access to back up your workspace. Make it a GitHub repo and push it as a private repo. Add me as a member so I can see it. Then set up a cron job to pull, rebase, and push any changes at least once an hour. + +After sending that, redeploy from the dashboard to pick up the changes. + +## Grant email and calendar access + +After OAuth is set up, decide how much access to give your Claw to your personal accounts. + +| Option | What it does | Best for | Configured from | +| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------- | +| Forward select emails | A Gmail filter forwards specific senders or labels to the bot account | Targeted use cases like newsletter digests | Your personal account | +| Forward all email | Forwards your full inbox to the bot | Simpler setups where noise is acceptable | Bot account (destination) | +| Full account delegation | Gives the bot direct read/write access to your personal account | Maximum capability | Your personal account — Gmail Settings → Add a delegate | + +{% callout type="info" %} +Email forwarding is configured from the **destination** (bot) account. Account delegation is configured from the **source** (personal) account. +{% /callout %} + +**Push notifications:** by default, your Claw wakes up on every incoming email. If you'd prefer a digest (e.g., once at 7am), disable push notifications in **Settings → Google Account** on the [dashboard](/docs/kiloclaw/dashboard) — otherwise it processes each email as it arrives. + +**Google Calendar:** share your personal calendar from your personal Google account. Go to **Google Calendar → Settings → Settings for my calendars → [your calendar] → Share with specific people**, and add the bot account. + +## Enable auto-approval + +By default, KiloClaw asks for confirmation before every tool call. To let it act freely, go to the [KiloClaw dashboard](https://app.kilo.ai/claw) and enable auto-approval in the **Default Permissions** section. + +## Prompt and schedule work + +### How to prompt your Claw + +Just tell it in plain language what you want. Be specific. If you want it to remember something across sessions, tell it to write it down in a specific file. + +### Scheduling jobs + +Tell your Claw when and what to do — for example: + +> Schedule a daily cron job at 7am to summarize my emails and send me a digest. + +{% callout type="tip" %} +Mentioning "cron job" helps it understand you want a recurring scheduled task. +{% /callout %} + +### Skills + +Reusable capabilities that extend what your Claw can do — things like triaging email, summarizing documents, or managing GitHub issues. You can install a pre-built skill by asking your Claw: + +> Install the [skill name] skill. + +Or ask your Claw to build a custom skill from scratch — it has a built-in skill-builder skill for exactly this. You can explore popular skills and use case inspiration at the [KiloClaw Bytes library](https://kilo.ai/kiloclaw/bytes). + +## Manage inference + +**Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive. + +You can also use your KiloPass credits — find this under **Profile** in the dashboard. diff --git a/packages/kilo-docs/pages/kiloclaw/suggested-configuration.md b/packages/kilo-docs/pages/kiloclaw/suggested-configuration.md new file mode 100644 index 00000000000..fa32037e7aa --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/suggested-configuration.md @@ -0,0 +1,171 @@ +--- +title: "Setup walkthrough" +description: "Start-to-finish guide for configuring your KiloClaw instance" +--- + +# Setup walkthrough + +This guide walks you through a full KiloClaw setup — from creating accounts to scheduling your first automated workflow. Plan for about 60 minutes. + +--- + +## Planning your setup + +For most users, a useful KiloClaw configuration involves: + +1. A **chat platform** (called a "channel" in OpenClaw) so you can message your Claw +2. **Google services** for email, calendar, and Drive +3. **GitHub** for code and markdown syncing + +### Use dedicated accounts for your Claw + +We recommend creating **separate accounts** for your KiloClaw rather than connecting it to your personal accounts. This applies to Google, GitHub, and any other services you connect. A dedicated account improves isolation — your personal data stays separate, and you can control exactly what access the Claw has by sharing or delegating only what you want. + +### Chat platform options + +- **[Kilo Chat](https://app.kilo.ai)** — available in the web app and coming soon to iOS and Android; requires zero configuration +- **[Telegram](/docs/kiloclaw/chat-platforms/telegram)** — easy to set up, private by default +- **[Discord](/docs/kiloclaw/chat-platforms/discord)** — moderate setup +- **[Slack](/docs/kiloclaw/chat-platforms/slack)** — most involved setup + +{% callout type="warning" title="Chain-of-connection security" %} +If your Claw has access to sensitive data (like your email), be careful which chat platform you connect it to. On broadly-accessible platforms like Slack or Discord, anyone on the server could potentially message your Claw and access that data. If you're connecting sensitive integrations, use a private platform like Kilo Chat or Telegram. +{% /callout %} + +The steps below walk you through this configuration. + +--- + +## Preflight Steps + +Take these steps before configuring your Claw. + +If you are doing a [1-1 configuration call with Kilo](https://kilo.ai/kiloclaw/config-service), please complete these steps before the call. + +### Google + +Configuring Google services is by far the most involved part of setting up your Claw. + +Before configuring, take these preflight steps: + +1. **Create a Google Account for your Claw** — Go to [google.com](https://www.google.com/) and create a new Google/Gmail account dedicated to your KiloClaw. Something like `yourname.bot@gmail.com` works well. + +2. **Set up Google Cloud** — Visit [console.cloud.google.com](https://console.cloud.google.com). Accept the terms of service and click "Start my free tier". You may need to add a credit card for identity verification. + + {% callout type="info" %} + Nothing KiloClaw does costs any money with Google. + {% /callout %} + +3. **Install Docker** — KiloClaw configures Google by running a Docker container on your machine. Download Docker at [docker.com](https://www.docker.com/), then open it. You don't need to sign in or create a Docker account. + +## Other Services + +1. **Create a GitHub account for your Claw** — Using your new Gmail address, create a matching GitHub account for your Claw. + +--- + +## Set up a messaging platform + +Your Claw needs a way to communicate with you. **[Kilo Chat](https://app.kilo.ai)** requires no setup — just open the web app. For other platforms, follow the relevant guide: + +- [Telegram](/docs/kiloclaw/chat-platforms/telegram) — about 2 minutes +- [Discord](/docs/kiloclaw/chat-platforms/discord) — about 10 minutes +- [Slack](/docs/kiloclaw/chat-platforms/slack) — about 15 minutes; always use the manifest + +{% callout type="tip" %} +If you're not sure which to pick, Kilo Chat (no setup) or Telegram (2 minutes) are the easiest options. +{% /callout %} + +--- + +## Set up Google OAuth + +This lets your Claw act as the bot Google account — sending email, reading calendar, and more. Takes about 15 minutes. + +Prerequisites: Docker is installed and running, and your bot Google account is already created. + +1. In the KiloClaw dashboard, go to **Settings → Google Account** and copy the Docker command shown. +2. Open a terminal and run the command. +3. Follow the steps in the console: + - At each step, confirm you're logged in to the bot account (check the top-right corner of the screen). + - After project creation, confirm you're in the correct project. + - The last step may look like it failed — this is expected. + +For full details, see the [Google setup guide](/docs/kiloclaw/development-tools/google). + +--- + +## Set up GitHub + +A dedicated bot GitHub account is strongly recommended. Takes about 7 minutes. + +**Create a Personal Access Token (PAT):** + +1. In GitHub, go to **Settings → Developer Settings → Personal Access Tokens → Classic → Generate new token**. +2. Select these scopes: `repo`, `workflow`, `write:org`, `read:user`. + +For full details, see the [GitHub setup guide](/docs/kiloclaw/development-tools/github). + +**Set up a private workspace repo:** + +Once GitHub is connected, ask your Claw to back up its workspace: + +``` +Use your GitHub access to back up your workspace. Make it a GitHub repo and push it as a private repo. Add me as a member so I can see it. Then set up a cron job to pull, rebase, and push any changes at least once an hour. +``` + +After sending that, redeploy from the dashboard to pick up the changes. + +--- + +## Grant email and calendar access + +After OAuth is set up, decide how much access to give your Claw to your personal accounts. + +| Option | What it does | Best for | Configured from | +| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------- | +| Forward select emails | A Gmail filter forwards specific senders or labels to the bot account | Targeted use cases like newsletter digests | Your personal account | +| Forward all email | Forwards your full inbox to the bot | Simpler setups where noise is acceptable | Bot account (destination) | +| Full account delegation | Gives the bot direct read/write access to your personal account | Maximum capability | Your personal account — Gmail Settings → Add a delegate | + +{% callout type="info" %} +Email forwarding is configured from the **destination** (bot) account. Account delegation is configured from the **source** (personal) account. +{% /callout %} + +**Push notifications:** by default, your Claw wakes up on every incoming email. If you'd prefer a digest (e.g., once at 7am), disable push notifications in **Settings → Google Account** on the [dashboard](/docs/kiloclaw/dashboard) — otherwise it processes each email as it arrives. + +**Google Calendar:** share your personal calendar from your personal Google account. Go to **Google Calendar → Settings → Settings for my calendars → [your calendar] → Share with specific people**, and add the bot account. + +--- + +## Enable auto-approval + +By default, KiloClaw asks for confirmation before every tool call. To let it act freely, go to the [KiloClaw dashboard](https://app.kilo.ai/claw) and enable auto-approval in the **Default Permissions** section. + +--- + +## Prompt and schedule work + +**How to prompt your Claw:** just tell it in plain language what you want. Be specific. If you want it to remember something across sessions, tell it to write it down in a specific file. + +**Scheduling jobs:** tell your Claw when and what to do — for example: + +``` +Every day at 7am, summarize my emails and send me a digest. +``` + +Mentioning "cron job" helps it understand you want a recurring scheduled task. + +**Skills:** the OpenClaw Skills hub has pre-built capabilities you can add. The easiest way to install a skill is to just ask your Claw by name: + +``` +Install the [skill name] skill. +``` + +--- + +## Manage inference + +**Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive. + +You can also use your KiloPass credits — find this under **Profile** in the dashboard. diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/index.md b/packages/kilo-docs/pages/kiloclaw/triggers/index.md new file mode 100644 index 00000000000..10412d71eb3 --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/triggers/index.md @@ -0,0 +1,32 @@ +--- +title: "Triggers" +description: "Automate your KiloClaw agent with webhooks and scheduled triggers" +--- + +# Triggers + +Triggers let external events and schedules drive your KiloClaw agent automatically. Instead of typing every instruction yourself, triggers deliver messages to your agent on your behalf. This lets it react to real-world events or run tasks on a schedule without polling. + +All triggers are managed from the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard). + +## Trigger Types + +| Type | Description | Status | +| --- | --- | --- | +| [**Webhooks**](/docs/kiloclaw/triggers/webhooks) | Receive HTTP requests from external services (GitHub, Stripe, monitoring tools, etc.) and deliver them as chat messages to your agent | Available | +| **Time-based** | Run tasks on a schedule using cron expressions | Coming soon | + +## How Triggers Work + +1. An event occurs (an HTTP request arrives, or a schedule fires) +2. The trigger validates the request and renders the payload through a **prompt template** +3. The rendered message is delivered to your KiloClaw instance as a chat message +4. Your agent processes and responds like any other conversation + +Prompt templates give you control over how payloads are presented to your agent. You can include instructions, context, and formatting alongside the raw event data. + +## Related + +- [Webhooks](/docs/kiloclaw/triggers/webhooks) +- [KiloClaw Overview](/docs/kiloclaw/overview) +- [Dashboard Reference](/docs/kiloclaw/dashboard) diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md b/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md new file mode 100644 index 00000000000..c2c04cdabbf --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md @@ -0,0 +1,110 @@ +--- +title: "Webhooks" +description: "Trigger your KiloClaw agent from external events using webhooks" +--- + +# Webhooks + +KiloClaw supports inbound webhooks so external events can trigger your agent automatically. Form submissions, alerts, calendar updates, ecommerce orders, IoT sensor data; anything that can send an HTTP request can kick off a conversation with your agent. When a webhook fires, the payload is rendered through a prompt template and delivered as a chat message to your KiloClaw instance. The agent processes and responds as if you typed it yourself. + +## Setup + +1. Go to the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard) +2. Find the **Webhook Integration** card and click **Manage** +3. Click **Set Up Webhook** + +KiloClaw generates a unique webhook URL for your instance. Copy it and configure it as the destination in whatever service you want to receive events from (GitHub, Stripe, a monitoring tool, etc.). + +{% callout type="warning" title="Treat the URL as a secret" %} +The webhook URL contains 128 bits of entropy and acts as its own credential (similar to Slack webhook URLs). Anyone with the URL can send messages to your instance. Do not commit it to public repositories or share it in public channels. +{% /callout %} + +## How It Works + +1. An external service sends an HTTP POST to your webhook URL +2. The webhook worker validates the request (and optionally checks authentication) +3. The payload is rendered through your **prompt template** (see below) +4. The rendered message is delivered to your KiloClaw instance as a chat message +5. Your agent receives and responds to the message like any other conversation + +## Prompt Template + +The prompt template controls how webhook payloads are presented to your agent. You can customize it from the **Webhook Integration** section in Settings. + +**Default template:** + +``` +You received a webhook event. Here is the payload: + +{{bodyJson}} +``` + +**Available variables:** + +| Variable | Description | +| --- | --- | +| `{{body}}` | Raw request body | +| `{{bodyJson}}` | Pretty-printed JSON body | +| `{{method}}` | HTTP method (e.g., `POST`) | +| `{{headers}}` | Request headers | +| `{{path}}` | Request path | +| `{{query}}` | Query string parameters | +| `{{timestamp}}` | Time the webhook was received | + +You can tailor the template to give your agent more context. For example: + +``` +A GitHub push event just arrived. Summarize the changes and open a PR if any tests are affected. + +Payload: +{{bodyJson}} +``` + +## Managing Your Webhook + +Once set up, the Webhook Integration card in Settings gives you several controls: + +### Pause and Resume + +Toggle the **Active/Paused** switch to temporarily stop accepting webhooks without deleting the URL. When paused, incoming requests are rejected. Resume at any time to start accepting them again. + +### Rotate URL + +If your webhook URL is compromised, click **Rotate URL** to generate a new one. This immediately invalidates the old URL, so you will need to update your integrations with the new URL afterward. A confirmation dialog is shown before rotation. + +### Webhook Authentication (Optional) + +For additional security, you can require inbound requests to include a shared secret header. This is useful when the sending service supports webhook signing. + +1. Toggle **Webhook Authentication** to enabled +2. Set the **Secret Header** name (default: `x-webhook-secret`) +3. Enter a **Shared Secret** value +4. Click **Save** + +Requests missing the header or providing an incorrect secret are rejected. + +{% callout type="note" title="Authentication is optional" %} +The webhook URL itself is already a credential (128-bit entropy). Authentication adds a second layer and is only needed if your sending service requires or supports it. +{% /callout %} + +## Viewing Webhook Activity + +KiloClaw webhooks also appear in the **Webhooks** page under Cloud (read only). From there you can click **View Captured Requests** to inspect recent payloads, response codes, and timing. This is useful for debugging integration issues. + +## Example: GitHub Push Notifications + +1. Set up a webhook in your KiloClaw Settings +2. In your GitHub repository, go to **Settings > Webhooks > Add webhook** +3. Paste your KiloClaw webhook URL as the **Payload URL** +4. Set **Content type** to `application/json` +5. Select the events you want to trigger on (e.g., **Just the push event**) +6. Click **Add webhook** + +Now every push to that repository sends a payload to your agent. Customize the prompt template to tell the agent what to do with it. You could have it summarize commits, run checks, notify a channel, or anything else. + +## Related + +- [KiloClaw Overview](/docs/kiloclaw/overview) +- [Dashboard Reference](/docs/kiloclaw/dashboard) +- [GitHub Integration](/docs/kiloclaw/development-tools/github) +- [Connecting Chat Platforms](/docs/kiloclaw/chat-platforms) diff --git a/packages/kilo-docs/public/globals.css b/packages/kilo-docs/public/globals.css index 1dd89b9e0a4..ecc0be3b3fc 100644 --- a/packages/kilo-docs/public/globals.css +++ b/packages/kilo-docs/public/globals.css @@ -182,6 +182,15 @@ a:hover { color: #dcdcaa; } +/* Blockquotes */ +.article-content blockquote { + border-left: 3px solid var(--border-color); + margin: 1em 0; + padding: 0.25em 1em; + color: var(--text-secondary); + font-style: italic; +} + /* Lists - Override Tailwind's reset to restore default list styling */ .article-content ul, .article-content ol { diff --git a/packages/kilo-docs/public/img/checkpoints/revert-to-here-button.png b/packages/kilo-docs/public/img/checkpoints/revert-to-here-button.png new file mode 100644 index 00000000000..1670d3017c8 Binary files /dev/null and b/packages/kilo-docs/public/img/checkpoints/revert-to-here-button.png differ diff --git a/packages/kilo-docs/public/img/custom-models/custom-provider-button.png b/packages/kilo-docs/public/img/custom-models/custom-provider-button.png new file mode 100644 index 00000000000..4fabb5481ae Binary files /dev/null and b/packages/kilo-docs/public/img/custom-models/custom-provider-button.png differ diff --git a/packages/kilo-docs/public/img/custom-models/custom-provider-details.png b/packages/kilo-docs/public/img/custom-models/custom-provider-details.png new file mode 100644 index 00000000000..2a22661b9da Binary files /dev/null and b/packages/kilo-docs/public/img/custom-models/custom-provider-details.png differ diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index dc9878552e9..47575601aa1 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -10,6 +10,9 @@ - +- + + - - @@ -18,7 +21,7 @@ - -- +- - @@ -84,6 +87,8 @@ - +- + - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 953ba40dfb5..2fe42fe6c7d 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.1.12", + "version": "7.1.20", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index f2f08dc92ad..1c9a72dcf40 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -27,8 +27,8 @@ export const DEFAULT_FREE_MODEL = "kilo-auto/free" /** Token expiration duration in milliseconds (1 year) */ export const TOKEN_EXPIRATION_MS = 365 * 24 * 60 * 60 * 1000 -/** User-Agent header value for requests */ -export const USER_AGENT = "opencode-kilo-provider" +/** User-Agent header base value for requests */ +export const USER_AGENT_BASE = "opencode-kilo-provider" /** Content-Type header value for requests */ export const CONTENT_TYPE = "application/json" diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8229966bf83..fb86344075d 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { getKiloUrlFromToken } from "../auth/token.js" -import { DEFAULT_HEADERS } from "../headers.js" +import { getDefaultHeaders } from "../headers.js" import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js" /** @@ -88,7 +88,7 @@ export async function fetchKiloModels(options?: { // Fetch models with timeout const response = await fetch(modelsURL, { headers: { - ...DEFAULT_HEADERS, + ...getDefaultHeaders(), ...(token ? { Authorization: `Bearer ${token}` } : {}), }, signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), diff --git a/packages/kilo-gateway/src/api/modes.ts b/packages/kilo-gateway/src/api/modes.ts index 18557ab50f4..85493804c52 100644 --- a/packages/kilo-gateway/src/api/modes.ts +++ b/packages/kilo-gateway/src/api/modes.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { KILO_API_BASE, MODELS_FETCH_TIMEOUT_MS } from "./constants.js" -import { DEFAULT_HEADERS } from "../headers.js" +import { getDefaultHeaders } from "../headers.js" /** * Group entry in an organization mode config. @@ -70,7 +70,7 @@ export async function fetchOrganizationModes(token: string, organizationId: stri const url = `${KILO_API_BASE}/api/organizations/${encodeURIComponent(organizationId)}/modes` const response = await fetch(url, { headers: { - ...DEFAULT_HEADERS, + ...getDefaultHeaders(), Authorization: `Bearer ${token}`, }, signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), diff --git a/packages/kilo-gateway/src/auth/device-auth-tui.ts b/packages/kilo-gateway/src/auth/device-auth-tui.ts index 8d233f26b82..466aca7baaf 100644 --- a/packages/kilo-gateway/src/auth/device-auth-tui.ts +++ b/packages/kilo-gateway/src/auth/device-auth-tui.ts @@ -1,4 +1,4 @@ -import open from "open" +import { execFile } from "child_process" import type { DeviceAuthInitiateResponse, DeviceAuthPollResponse } from "../types.js" import { poll } from "./polling.js" import { getKiloProfile, getKiloDefaultModel } from "../api/profile.js" @@ -70,10 +70,14 @@ export async function authenticateWithDeviceAuthTUI(inputs?: Record { - // Silently fail if browser can't be opened - user can manually open URL - }) + // Step 2: Open browser (windowsHide: true prevents cmd.exe flash on Windows) + const [cmd, ...args] = + process.platform === "darwin" + ? ["open", verificationUrl] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", verificationUrl] + : ["xdg-open", verificationUrl] + execFile(cmd, args, { windowsHide: true }) // Step 3: Return instructions and callback for TUI to handle return { diff --git a/packages/kilo-gateway/src/headers.ts b/packages/kilo-gateway/src/headers.ts index 6170c427e7e..814f3d538d3 100644 --- a/packages/kilo-gateway/src/headers.ts +++ b/packages/kilo-gateway/src/headers.ts @@ -6,7 +6,7 @@ import { HEADER_EDITORNAME, HEADER_MACHINEID, HEADER_FEATURE, - USER_AGENT, + USER_AGENT_BASE, CONTENT_TYPE, DEFAULT_EDITOR_NAME, ENV_EDITOR_NAME, @@ -36,12 +36,23 @@ export function getFeatureHeader(): string | undefined { return process.env[ENV_FEATURE] || undefined } +/** + * Get User-Agent header value. + * Appends the version from KILOCODE_VERSION when available. + */ +export function getUserAgent(): string { + const version = process.env[ENV_VERSION] + return version ? `${USER_AGENT_BASE}/${version}` : USER_AGENT_BASE +} + /** * Default headers for KiloCode requests */ -export const DEFAULT_HEADERS = { - "User-Agent": USER_AGENT, - "Content-Type": CONTENT_TYPE, +export function getDefaultHeaders(): Record { + return { + "User-Agent": getUserAgent(), + "Content-Type": CONTENT_TYPE, + } } /** diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index c441cc5a6c1..04dbd7d5128 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -9,7 +9,7 @@ export { KiloAuthPlugin, default } from "./plugin.js" export { createKilo } from "./provider.js" export { createKiloDebug } from "./provider-debug.js" export { kiloCustomLoader } from "./loader.js" -export { buildKiloHeaders, getEditorNameHeader, getFeatureHeader, DEFAULT_HEADERS } from "./headers.js" +export { buildKiloHeaders, getEditorNameHeader, getFeatureHeader, getDefaultHeaders, getUserAgent } from "./headers.js" // ============================================================================ // Auth @@ -86,7 +86,7 @@ export { DEFAULT_MODEL, DEFAULT_FREE_MODEL, TOKEN_EXPIRATION_MS, - USER_AGENT, + USER_AGENT_BASE, CONTENT_TYPE, DEFAULT_PROVIDER_NAME, ANONYMOUS_API_KEY, diff --git a/packages/kilo-gateway/src/provider-debug.ts b/packages/kilo-gateway/src/provider-debug.ts index 240d8492fbf..37abac857b8 100644 --- a/packages/kilo-gateway/src/provider-debug.ts +++ b/packages/kilo-gateway/src/provider-debug.ts @@ -2,7 +2,7 @@ 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 { buildKiloHeaders, DEFAULT_HEADERS } from "./headers.js" +import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js" /** @@ -32,7 +32,7 @@ export function createKiloDebug(options: KiloProviderOptions = {}): SDK { // Merge custom headers with defaults const customHeaders = { - ...DEFAULT_HEADERS, + ...getDefaultHeaders(), ...buildKiloHeaders(undefined, { kilocodeOrganizationId: options.kilocodeOrganizationId, kilocodeTesterWarningsDisabledUntil: undefined, diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index 618360a4c35..e6a43bf0f42 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -4,7 +4,7 @@ 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 { buildKiloHeaders, DEFAULT_HEADERS } from "./headers.js" +import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js" /** @@ -39,7 +39,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { // Merge custom headers with defaults const customHeaders = { - ...DEFAULT_HEADERS, + ...getDefaultHeaders(), ...buildKiloHeaders(undefined, { kilocodeOrganizationId: options.kilocodeOrganizationId, kilocodeTesterWarningsDisabledUntil: undefined, diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 79a27b7f5f5..44e36e3a3e3 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -8,7 +8,7 @@ import { fetchProfile, fetchBalance } from "../api/profile.js" import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js" import { fetchOrganizationModes, clearModesCache } from "../api/modes.js" -import { KILO_API_BASE, HEADER_FEATURE } from "../api/constants.js" +import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js" import { buildKiloHeaders } from "../headers.js" import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js" import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js" @@ -496,6 +496,126 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { } }, ) + .get( + "/claw/status", + describeRoute({ + summary: "Get KiloClaw instance status", + description: "Fetch the user's KiloClaw instance status via the KiloClaw worker", + operationId: "kilo.claw.status", + responses: { + 200: { + description: "Instance status", + content: { + "application/json": { + schema: resolver( + z.object({ + status: z + .enum(["provisioned", "starting", "restarting", "running", "stopped", "destroying"]) + .nullable(), + sandboxId: z.string().optional(), + flyRegion: z.string().optional(), + machineSize: z.object({ cpus: z.number(), memory_mb: z.number() }).optional(), + openclawVersion: z.string().nullable().optional(), + lastStartedAt: z.string().nullable().optional(), + lastStoppedAt: z.string().nullable().optional(), + channelCount: z.number().optional(), + secretCount: z.number().optional(), + userId: z.string().optional(), + }), + ), + }, + }, + }, + ...errors(401, 502), + }, + }), + async (c: any) => { + try { + const auth = await Auth.get("kilo") + if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined + if (!token) return c.json({ error: "No valid token found" }, 401) + + const organizationId = auth.type === "oauth" ? auth.accountId : undefined + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + if (organizationId) { + headers[HEADER_ORGANIZATIONID] = organizationId + } + + const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers }) + + if (!response.ok) { + const text = await response.text() + return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any) + } + + return c.json(await response.json()) + } catch (err: any) { + console.error("[Kilo Gateway] claw/status: error", err?.message ?? err) + return c.json({ error: "Failed to reach KiloClaw" }, 502) + } + }, + ) + .get( + "/claw/chat-credentials", + describeRoute({ + summary: "Get KiloClaw chat credentials", + description: "Fetch Stream Chat credentials for the user's KiloClaw instance", + operationId: "kilo.claw.chatCredentials", + responses: { + 200: { + description: "Stream Chat credentials or null", + content: { + "application/json": { + schema: resolver( + z + .object({ + apiKey: z.string(), + userId: z.string(), + userToken: z.string(), + channelId: z.string(), + }) + .nullable(), + ), + }, + }, + }, + ...errors(401, 502), + }, + }), + async (c: any) => { + try { + const auth = await Auth.get("kilo") + if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined + if (!token) return c.json({ error: "No valid token found" }, 401) + + const organizationId = auth.type === "oauth" ? auth.accountId : undefined + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + if (organizationId) { + headers[HEADER_ORGANIZATIONID] = organizationId + } + + const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, { headers }) + + if (!response.ok) { + const text = await response.text() + return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any) + } + + return c.json(await response.json()) + } catch (err: any) { + console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err) + return c.json({ error: "Failed to reach KiloClaw" }, 502) + } + }, + ) .get( "/cloud-sessions", describeRoute({ diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 43d34af4943..6799fd9ba2f 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.1.12", + "version": "7.1.20", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts new file mode 100644 index 00000000000..458772d31b1 --- /dev/null +++ b/packages/kilo-i18n/src/uk.ts @@ -0,0 +1,72 @@ +export const dict = { + // Kilo Gateway provider translations + "provider.connect.kiloGateway.line1": + "Kilo Gateway надає доступ до добірки надійних оптимізованих моделей для агентів кодування.", + "provider.connect.kiloGateway.line2": + "За допомогою одного API-ключа ви отримаєте доступ до таких моделей, як Claude, GPT, Gemini, GLM та інших.", + "provider.connect.kiloGateway.visit.prefix": "Відвідайте ", + "provider.connect.kiloGateway.visit.link": "kilo.ai", + "provider.connect.kiloGateway.visit.suffix": " щоб отримати свій API-ключ.", + + // Provider dialog translations + "dialog.provider.group.recommended": "Рекомендовані", + "dialog.provider.kilo.note": "Доступ до 500+ моделей ШІ", + + // Reasoning block label + "ui.permission.run": "Виконати", + "ui.reasoning.label": "Міркування", + + // Marketplace + "marketplace.tab.skills": "Навички", + "marketplace.tab.mcpServers": "MCP-сервери", + "marketplace.tab.modes": "Режими", + "marketplace.category.all": "Усі", + "marketplace.placeholder": "Буде реалізовано", + "marketplace.card.installed": "Встановлено", + "marketplace.card.install": "Встановити", + "marketplace.card.remove": "Видалити", + "marketplace.card.removeScope": "Видалити ({{scope}})", + "marketplace.card.showMore": "Показати більше", + "marketplace.card.showLess": "Показати менше", + "marketplace.install.title": "Встановити {{name}}", + "marketplace.install.scope": "Область", + "marketplace.install.scope.project": "Проєкт", + "marketplace.install.scope.global": "Глобально", + "marketplace.install.prerequisites": "Передумови", + "marketplace.install.installing": "Встановлення...", + "marketplace.install.cancel": "Скасувати", + "marketplace.install.success": "Успішно встановлено!", + "marketplace.install.failed": "Встановлення не вдалося", + "marketplace.install.done": "Готово", + "marketplace.install.close": "Закрити", + "marketplace.remove.title": "Видалити {{name}}?", + "marketplace.remove.confirm": + "Ви впевнені, що хочете видалити цей {{type}}? Це видалить його з вашої конфігурації {{scope}}.", + "marketplace.remove.cancel": "Скасувати", + "marketplace.remove.confirm.button": "Видалити", + "marketplace.tab.mcp": "MCP", + "marketplace.search": "Пошук...", + "marketplace.filter.all": "Усі елементи", + "marketplace.filter.notInstalled": "Не встановлено", + "marketplace.empty": "Елементів не знайдено", + "marketplace.badge.mcpServer": "MCP-сервер", + "marketplace.badge.mode": "Режим", + "marketplace.card.by": "від {{author}}", + "marketplace.install.method": "Метод встановлення", + "marketplace.install.parameters": "Параметри", + "marketplace.install.optional": "(необов'язково)", + "marketplace.install.required": "{{name}} є обов'язковим", + "marketplace.scope.project": "проєкт", + "marketplace.scope.global": "глобально", + "marketplace.remove.type.mcp": "MCP-сервер", + "marketplace.remove.type.skill": "навичка", + "marketplace.remove.type.mode": "режим", + "marketplace.remove.failed": "Не вдалося видалити {{name}}", + "marketplace.install": "Встановити", + "marketplace.filter.installed": "Встановлено", + "marketplace.error.dismiss": "Закрити", + "marketplace.warning.busyOne": "Виконується одна сесія, її буде перервано", + "marketplace.warning.busyMany": "Виконується кілька сесій, їх буде перервано", + "marketplace.warning.installAnyway": "Встановити все одно", + "marketplace.warning.cancel": "Скасувати", +} diff --git a/packages/kilo-jetbrains/.gitignore b/packages/kilo-jetbrains/.gitignore new file mode 100644 index 00000000000..8eaae9e5aa4 --- /dev/null +++ b/packages/kilo-jetbrains/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +.intellijPlatform/ +.ai/ +!gradle/wrapper/gradle-wrapper.jar diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md new file mode 100644 index 00000000000..d2543504aa4 --- /dev/null +++ b/packages/kilo-jetbrains/README.md @@ -0,0 +1,71 @@ +# Kilo JetBrains + +AI coding agent plugin for JetBrains IDEs. + +--- + +## Set up your environment + +### Prerequisites + +- **Bun** -- used to build CLI binaries and run build scripts +- **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK +- **IntelliJ IDEA** -- to run the plugin in a sandboxed IDE + +--- + +## Open in IntelliJ + +When you open the monorepo root in IntelliJ IDEA, the Gradle project at `packages/kilo-jetbrains/` should be auto-detected via `.idea/gradle.xml`. If not, link it manually: **File > Settings > Build Tools > Gradle > +** and select `packages/kilo-jetbrains/settings.gradle.kts`. + +--- + +## Build locally + +From `packages/kilo-jetbrains/`: + +``` +bun run build +``` + +This builds the CLI binary for your current OS/arch only, copies it into the backend module resources, and runs `./gradlew buildPlugin`. The plugin archive is output to `build/distributions/`. + +Or via Turbo from the repo root: + +``` +bun turbo build --filter=@kilocode/kilo-jetbrains +``` + +--- + +## Build for production + +From `packages/kilo-jetbrains/`: + +``` +bun run build:production +``` + +This builds CLI binaries for all 6 desktop platforms (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64, windows-arm64), copies them all into the backend jar, and fails if any are missing. Gradle also validates all platforms are present via `-Pproduction=true`. + +The built plugin archive is at `build/distributions/kilo.jetbrains-.zip`. This zip can be installed in any JetBrains IDE via **Settings > Plugins > Install Plugin from Disk**. + +--- + +## Run the plugin + +After building, use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed. + +Note: `runIde` does not build CLI binaries -- run `bun run build` at least once before using it. Subsequent Kotlin/Gradle changes can be iterated with `runIde` directly. + +--- + +## Run Gradle directly + +You can run `./gradlew buildPlugin` directly if CLI binaries are already in `backend/build/generated/cli/`. Gradle will fail with a clear error if they are missing. + +For production verification: + +``` +./gradlew buildPlugin -Pproduction=true +``` diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts new file mode 100644 index 00000000000..296cd4e8d8f --- /dev/null +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.kotlin) +} + +kotlin { + jvmToolchain(21) +} + +sourceSets { + main { + resources.srcDir(layout.buildDirectory.dir("generated/cli")) + } +} + +val cliDir = layout.buildDirectory.dir("generated/cli/cli") +val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false) + +val requiredPlatforms = listOf( + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "windows-x64", + "windows-arm64", +) + +val checkCli by tasks.registering { + description = "Verify CLI binaries exist before building" + val dir = cliDir.map { it.asFile } + val prod = production.get() + val platforms = requiredPlatforms.toList() + doLast { + val resolved = dir.get() + if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) { + throw GradleException( + "CLI binaries not found at ${resolved.absolutePath}.\n" + + "Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together." + ) + } + if (prod) { + val missing = platforms.filter { platform -> + val dir = File(resolved, platform) + val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo" + !File(dir, exe).exists() + } + if (missing.isNotEmpty()) { + throw GradleException( + "Production build requires all platform CLI binaries.\n" + + "Missing: ${missing.joinToString(", ")}\n" + + "Run 'bun run build:production' to build all platforms." + ) + } + } + } +} + +tasks.processResources { + dependsOn(checkCli) +} + +dependencies { + intellijPlatform { + intellijIdea(libs.versions.intellij.platform) + bundledModule("intellij.platform.backend") + } + + implementation(project(":shared")) +} diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml new file mode 100644 index 00000000000..220ca388a9a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts new file mode 100644 index 00000000000..671a4cd5e3a --- /dev/null +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType +import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget + +group = "ai.kilocode.jetbrains" +version = "7.0.1" + +plugins { + application + id("java") + alias(libs.plugins.intellij.platform) + + alias(libs.plugins.kotlin) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.compose.compiler) apply false +} + +subprojects { + apply(plugin = "org.jetbrains.intellij.platform.module") +} + +allprojects { + repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/") + } +} + +dependencies { + intellijPlatform { + intellijIdea(libs.versions.intellij.platform) + + pluginModule(implementation(project(":shared"))) + pluginModule(implementation(project(":frontend"))) + pluginModule(implementation(project(":backend"))) + testFramework(TestFrameworkType.Platform) + } +} + +intellijPlatform { + splitMode = true + splitModeTarget = SplitModeTarget.BOTH + + pluginVerification { + ides { + create(IntelliJPlatformType.IntellijIdeaCommunity, libs.versions.intellij.platform) + } + } +} + +tasks.named("runIde") { + jvmArgumentProviders += CommandLineArgumentProvider { + listOf("-Dnosplash=true") + } +} + + diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts new file mode 100644 index 00000000000..12190932592 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.compose.compiler) +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + intellijPlatform { + intellijIdea(libs.versions.intellij.platform) + bundledModule("intellij.platform.frontend") + } + + implementation(project(":shared")) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml new file mode 100644 index 00000000000..58d7b183a3f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties new file mode 100644 index 00000000000..5d33083cf31 --- /dev/null +++ b/packages/kilo-jetbrains/gradle.properties @@ -0,0 +1,4 @@ +kotlin.stdlib.default.dependency=false +org.gradle.configuration-cache=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml new file mode 100644 index 00000000000..63bbebf0e7f --- /dev/null +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[versions] +intellij-platform = "2025.3" +intellij-gradle-plugin = "2.10.5" +kotlin-jvm-plugin = "2.1.20" +kotlin-serialization-plugin = "2.1.20" +kotlin-serialization = "1.7.3" + +[plugins] +intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" } +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-jvm-plugin" } diff --git a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..249e5832f09 Binary files /dev/null and b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..f50f69e3279 --- /dev/null +++ b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/kilo-jetbrains/gradlew b/packages/kilo-jetbrains/gradlew new file mode 100755 index 00000000000..1b6c787337f --- /dev/null +++ b/packages/kilo-jetbrains/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/kilo-jetbrains/gradlew.bat b/packages/kilo-jetbrains/gradlew.bat new file mode 100644 index 00000000000..107acd32c4e --- /dev/null +++ b/packages/kilo-jetbrains/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json new file mode 100644 index 00000000000..34b33a8422a --- /dev/null +++ b/packages/kilo-jetbrains/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kilocode/kilo-jetbrains", + "private": true, + "scripts": { + "build": "bun script/build.ts", + "build:production": "bun script/build.ts --production" + } +} diff --git a/packages/kilo-jetbrains/script/build.ts b/packages/kilo-jetbrains/script/build.ts new file mode 100644 index 00000000000..8a82168276c --- /dev/null +++ b/packages/kilo-jetbrains/script/build.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env bun + +/** + * Build the Kilo JetBrains plugin. + * + * Usage: + * bun script/build.ts # Local build — only current platform binary required + * bun script/build.ts --production # Production build — all 6 platform binaries required + * + * Steps: + * 1. Builds CLI binaries (or uses prebuilt ones from dist/). + * Local: builds only current platform (--single). + * Production: builds all platforms. + * 2. Copies them into backend/build/generated/cli/cli/{os}/kilo[.exe] + * so they end up inside the backend jar at /cli/{os}/kilo. + * 3. Invokes Gradle to build the plugin. + */ + +import { $ } from "bun" +import { join, relative } from "node:path" +import { existsSync, mkdirSync, chmodSync, cpSync, rmSync } from "node:fs" + +const production = process.argv.includes("--production") + +const root = join(import.meta.dir, "..") +const packages = join(root, "..") +const opencodeDir = join(packages, "opencode") +const distDir = join(opencodeDir, "dist") +const cliDir = join(root, "backend", "build", "generated", "cli", "cli") + +/** All desktop platforms. */ +const platforms = [ + { os: "darwin-arm64", exe: "kilo" }, + { os: "darwin-x64", exe: "kilo" }, + { os: "linux-arm64", exe: "kilo" }, + { os: "linux-x64", exe: "kilo" }, + { os: "windows-x64", exe: "kilo.exe" }, + { os: "windows-arm64", exe: "kilo.exe" }, +] as const + +function localPlatformTag(): string { + const os = process.platform === "win32" ? "windows" : process.platform + return `${os}-${process.arch}` +} + +function log(msg: string) { + console.log(`[jetbrains-build] ${msg}`) +} + +function distBinPath(os: string, exe: string): string { + return join(distDir, `@kilocode/cli-${os}`, "bin", exe) +} + +function hasDist(): boolean { + if (production) { + return platforms.every((p) => existsSync(distBinPath(p.os, p.exe))) + } + const tag = localPlatformTag() + const local = platforms.find((p) => p.os === tag) + return local ? existsSync(distBinPath(local.os, local.exe)) : false +} + +async function prepareCli() { + const mode = production ? "production" : "local" + log(`Mode: ${mode}`) + + if (!hasDist()) { + log("Building CLI binaries via opencode...") + if (!existsSync(join(opencodeDir, "package.json"))) { + throw new Error(`Expected opencode package at ${opencodeDir}`) + } + const args = production ? [] : ["--single"] + await $`bun run build ${args}`.cwd(opencodeDir) + } else { + log("Found prebuilt CLI binaries in opencode/dist/, skipping CLI build") + } + + if (existsSync(cliDir)) { + rmSync(cliDir, { recursive: true }) + } + + const missing: string[] = [] + let copied = 0 + for (const p of platforms) { + const src = distBinPath(p.os, p.exe) + if (!existsSync(src)) { + missing.push(p.os) + continue + } + + const dir = join(cliDir, p.os) + mkdirSync(dir, { recursive: true }) + const dest = join(dir, p.exe) + cpSync(src, dest) + chmodSync(dest, 0o755) + copied++ + log(`Copied ${relative(root, src)} -> ${relative(root, dest)}`) + } + + if (copied === 0) { + throw new Error("No CLI binaries were copied — the build cannot proceed") + } + + if (production && missing.length > 0) { + throw new Error(`Production build requires all platform binaries. Missing: ${missing.join(", ")}`) + } + + if (missing.length > 0) { + log(`Skipped ${missing.length} platforms (not needed for local build): ${missing.join(", ")}`) + } + + log(`Copied ${copied}/${platforms.length} platform binaries`) +} + +const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew" + +async function buildPlugin() { + log("Building JetBrains plugin via Gradle...") + const args = production ? ["-Pproduction=true"] : [] + await $`${gradlew} buildPlugin ${args}`.cwd(root) + log("Done. Plugin archive is in build/distributions/") +} + +try { + await prepareCli() + await buildPlugin() +} catch (err) { + console.error(`[jetbrains-build] ERROR: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +} diff --git a/packages/kilo-jetbrains/settings.gradle.kts b/packages/kilo-jetbrains/settings.gradle.kts new file mode 100644 index 00000000000..4fa336c9b9c --- /dev/null +++ b/packages/kilo-jetbrains/settings.gradle.kts @@ -0,0 +1,13 @@ +rootProject.name = "kilo.jetbrains" + +include("shared") +include("frontend") +include("backend") + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/") + } +} diff --git a/packages/kilo-jetbrains/shared/build.gradle.kts b/packages/kilo-jetbrains/shared/build.gradle.kts new file mode 100644 index 00000000000..6e02f979264 --- /dev/null +++ b/packages/kilo-jetbrains/shared/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kotlin) +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + intellijPlatform { + intellijIdea(libs.versions.intellij.platform) + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/resources/kilo.jetbrains.shared.xml b/packages/kilo-jetbrains/shared/src/main/resources/kilo.jetbrains.shared.xml new file mode 100644 index 00000000000..164f46cfcfb --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/resources/kilo.jetbrains.shared.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/kilo-jetbrains/src/main/resources/META-INF/plugin.xml b/packages/kilo-jetbrains/src/main/resources/META-INF/plugin.xml new file mode 100644 index 00000000000..96e28986525 --- /dev/null +++ b/packages/kilo-jetbrains/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,17 @@ + + Kilo Code + Kilo Code + + + + + + + + + diff --git a/packages/kilo-jetbrains/src/main/resources/META-INF/pluginIcon.svg b/packages/kilo-jetbrains/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 00000000000..f20188a670a --- /dev/null +++ b/packages/kilo-jetbrains/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 673aa62f638..ce9af0d3fd7 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.1.12", + "version": "7.1.20", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 9a144014897..fb213597db2 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.1.12", + "version": "7.1.20", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/i18n/uk.ts b/packages/kilo-ui/src/i18n/uk.ts new file mode 100644 index 00000000000..197e28172a4 --- /dev/null +++ b/packages/kilo-ui/src/i18n/uk.ts @@ -0,0 +1 @@ +export * from "@opencode-ai/ui/i18n/uk" diff --git a/packages/kilo-vscode/README.md b/packages/kilo-vscode/README.md index e69a35887f8..2e5f2621d55 100644 --- a/packages/kilo-vscode/README.md +++ b/packages/kilo-vscode/README.md @@ -1,9 +1,9 @@

- VS Code Marketplace - X (Twitter) - Substack Blog - Discord - Reddit + VS Code Marketplace + X (Twitter) + Substack Blog + Discord + Reddit

# 🚀 Kilo @@ -40,7 +40,7 @@ 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. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: -[![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig) +Watch the video ## Developer Setup diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 69ef88cbe9c..f6cc9503fdd 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.1.12", + "version": "7.1.20", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -373,6 +373,26 @@ "command": "kilo-code.new.openInTab", "group": "navigation", "when": "true" + }, + { + "command": "kilo-code.new.plusButtonClicked", + "group": "navigation@0", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel" + }, + { + "command": "kilo-code.new.historyButtonClicked", + "group": "navigation@1", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel" + }, + { + "command": "kilo-code.new.profileButtonClicked", + "group": "navigation@2", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel" + }, + { + "command": "kilo-code.new.settingsButtonClicked", + "group": "navigation@3", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel" } ], "editor/context": [ @@ -633,7 +653,8 @@ "th", "bs", "tr", - "nl" + "nl", + "uk" ], "enumDescriptions": [ "Auto (VS Code language)", @@ -654,7 +675,8 @@ "ภาษาไทย", "Bosanski", "Türkçe", - "Nederlands" + "Nederlands", + "Українська" ] }, "kilo-code.new.model.providerID": { diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index 86eb685dcbc..ee00dbb778e 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -36,10 +36,9 @@ const targets = [ const binDir = join(import.meta.dir, "..", "bin") const distDir = join(import.meta.dir, "..", "dist") const outDir = join(import.meta.dir, "..", "out") -const outDirProd = join(import.meta.dir, "..", "out", "prod") console.log("\n🧹 Cleaning up directories...") -for (const dir of [binDir, distDir, outDir, outDirProd]) { +for (const dir of [binDir, distDir, outDir]) { if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }) console.log(` ✓ Cleaned ${dir}`) @@ -48,7 +47,6 @@ for (const dir of [binDir, distDir, outDir, outDirProd]) { mkdirSync(outDir, { recursive: true }) mkdirSync(distDir, { recursive: true }) -mkdirSync(outDirProd, { recursive: true }) console.log("\n🔄 Rebuilding SDK types (ensures dist/ is in sync with server API)...") await $`bun run --cwd ${join(import.meta.dir, "..", "..", "sdk", "js")} build` @@ -84,18 +82,11 @@ for (const config of targets) { console.log(` 📦 Packaging .vsix for ${config.target}...`) const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`) - await $`vsce package --pre-release --no-dependencies --skip-license --target ${config.target} -o ${vsixPath}`.env({ + await $`vsce package --no-dependencies --skip-license --target ${config.target} -o ${vsixPath}`.env({ ...process.env, npm_config_ignore_scripts: "true", }) console.log(` ✅ Created ${vsixPath}`) - - const prodVsixPath = join(outDirProd, `kilo-vscode-${config.target}.vsix`) - await $`vsce package --no-dependencies --skip-license --target ${config.target} -o ${prodVsixPath}`.env({ - ...process.env, - npm_config_ignore_scripts: "true", - }) - console.log(` ✅ Created ${prodVsixPath}`) } console.log("\n✨ All VSIX packages built successfully!") diff --git a/packages/kilo-vscode/script/publish.ts b/packages/kilo-vscode/script/publish.ts index 3a526e86d5a..ef891606266 100755 --- a/packages/kilo-vscode/script/publish.ts +++ b/packages/kilo-vscode/script/publish.ts @@ -7,7 +7,6 @@ import { Script } from "@opencode-ai/script" console.log(`Publishing VSCode extension for release: v${Script.version}`) const outDir = process.env.VSIX_DIR || join(import.meta.dir, "..", "out") -const outDirProd = join(outDir, "prod") console.log(`Using VSIX directory: ${outDir}`) @@ -40,13 +39,12 @@ console.log(`\nFound ${vsixFiles.length} VSIX files`) for (const target of targets) { const vsixPath = join(outDir, `kilo-vscode-${target}.vsix`) console.log(`\n🚀 Publishing ${target} to VS Code Marketplace...`) - await $`vsce publish --pre-release --packagePath ${vsixPath}` + await $`vsce publish --packagePath ${vsixPath}` console.log(` ✅ Published ${target} to VS Code Marketplace`) - const prodVsixPath = join(outDirProd, `kilo-vscode-${target}.vsix`) console.log(`\n📤 Publishing ${target} to Open VSX...`) - await $`npx ovsx publish --pat ${process.env.OPENVSX_TOKEN} --packagePath ${prodVsixPath}` - console.log(` ✅ Published Prod ${target} to Open VSX`) + await $`npx ovsx publish --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}` + console.log(` ✅ Published ${target} to Open VSX`) } if (Script.release) { diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c6e1a046da1..633fb7e3c34 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -39,6 +39,7 @@ import { MarketplaceService } from "./services/marketplace" import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" +import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" // legacy-migration start import { checkAndShowMigrationWizard, @@ -74,6 +75,7 @@ import { computeDefaultSelection, fetchProviderData, validateRecents, + validateFavorites, connectProvider as connectProviderAction, authorizeProviderOAuth as authorizeOAuthAction, completeProviderOAuth as completeOAuthAction, @@ -143,7 +145,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private unsubscribeNotificationDismiss: (() => void) | null = null private unsubscribeLanguageChange: (() => void) | null = null private unsubscribeProfileChange: (() => void) | null = null + private unsubscribeFavoritesChange: (() => void) | null = null private unsubscribeMigrationComplete: (() => void) | null = null // legacy-migration + private unsubscribeClearPendingPrompts: (() => void) | null = null + private unsubscribeDirectoryProvider: (() => void) | null = null private initConnectionPromise: Promise | null = null private webviewMessageDisposable: vscode.Disposable | null = null @@ -154,6 +159,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private chatAutocomplete: ChatTextAreaAutocomplete | null = null private projectDirectory: string | null | undefined private slimEditMetadata = true + + private pendingFollowup: Followup | null = null /** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */ private statsPoller: GitStatsPoller | null = null private cachedStats: unknown = null @@ -175,6 +182,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) { this.projectDirectory = options?.projectDirectory this.slimEditMetadata = options?.slimEditMetadata ?? true + TelemetryProxy.getInstance().setProvider(this) } @@ -468,6 +476,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper z.object({ mime: z.string(), url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")), + filename: z.string().optional(), }), ) .optional() @@ -477,6 +486,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.text, typeof message.messageID === "string" ? message.messageID : undefined, message.sessionID, + typeof message.draftID === "string" ? message.draftID : undefined, message.providerID, message.modelID, message.agent, @@ -491,6 +501,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper z.object({ mime: z.string(), url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")), + filename: z.string().optional(), }), ) .optional() @@ -501,6 +512,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.arguments, typeof message.messageID === "string" ? message.messageID : undefined, message.sessionID, + typeof message.draftID === "string" ? message.draftID : undefined, message.providerID, message.modelID, message.agent, @@ -580,6 +592,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "openSettingsPanel": vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab) break + case "openVSCodeSettings": + vscode.commands.executeCommand("workbench.action.openSettings", message.query) + break case "openMarketplacePanel": vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory) break @@ -675,10 +690,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break case "questionReply": - await handleQuestionReply(this.questionCtx, message.requestID, message.answers) + this.noteFollowup(message.answers, message.sessionID) + if (!(await handleQuestionReply(this.questionCtx, message.requestID, message.answers, message.sessionID))) { + this.pendingFollowup = null + } break case "questionReject": - await handleQuestionReject(this.questionCtx, message.requestID) + this.pendingFollowup = null + await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID) break case "requestConfig": this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e)) @@ -840,6 +859,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "recentsLoaded", recents }) break } + case "toggleFavorite": { + const current = validateFavorites(this.extensionContext?.globalState.get("favoriteModels")) + const key = `${message.providerID}/${message.modelID}` + const exists = current.some((f) => `${f.providerID}/${f.modelID}` === key) + const favorites = + message.action === "add" && !exists + ? [...current, { providerID: message.providerID, modelID: message.modelID }] + : message.action === "remove" && exists + ? current.filter((f) => `${f.providerID}/${f.modelID}` !== key) + : current + await this.extensionContext?.globalState.update("favoriteModels", favorites) + this.connectionService.notifyFavoritesChanged(favorites) + break + } + case "requestFavorites": { + const favorites = validateFavorites(this.extensionContext?.globalState.get("favoriteModels")) + this.postMessage({ type: "favoritesLoaded", favorites }) + break + } // legacy-migration start case "requestLegacyMigrationData": void handleRequestLegacyMigrationData(this.migrationCtx) @@ -955,6 +993,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.unsubscribeNotificationDismiss?.() this.unsubscribeLanguageChange?.() this.unsubscribeProfileChange?.() + this.unsubscribeFavoritesChange?.() + this.unsubscribeClearPendingPrompts?.() + this.unsubscribeDirectoryProvider?.() try { const workspaceDir = this.getWorkspaceDirectory() @@ -972,6 +1013,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return event.type !== "message.part.updated" && event.type !== "message.part.delta" } + if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) { + return true + } + // session.status must always pass through — even for sessions not tracked by this // KiloProvider instance. The Settings panel is a separate provider with no tracked // sessions, but it needs session.status to populate sessionStatusMap and allStatusMap @@ -1026,6 +1071,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "profileData", data }) }) + // Subscribe to favorites change broadcast from other KiloProvider instances + this.unsubscribeFavoritesChange = this.connectionService.onFavoritesChanged((favorites) => { + this.postMessage({ type: "favoritesLoaded", favorites }) + }) + // legacy-migration start // Subscribe to migration-complete broadcast from any KiloProvider instance this.unsubscribeMigrationComplete = this.connectionService.onMigrationComplete(() => { @@ -1033,6 +1083,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) // legacy-migration end + // Subscribe to clear-pending-prompts broadcast (fired after config save drains prompts) + this.unsubscribeClearPendingPrompts = this.connectionService.onClearPendingPrompts(() => { + this.postMessage({ type: "clearPendingPrompts" }) + }) + + // Register this provider's directories so drainPendingPrompts() covers all instances + this.unsubscribeDirectoryProvider = this.connectionService.registerDirectoryProvider(() => { + return [this.getWorkspaceDirectory(), ...this.sessionDirectories.values()] + }) + // Get current state and push to webview const serverInfo = this.connectionService.getServerInfo() this.connectionState = this.connectionService.getConnectionState() @@ -1476,6 +1536,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } 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) @@ -1483,7 +1544,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (msg.type === "completeProviderOAuth") return completeOAuthAction(ctx, rid, pid, method, code) if (msg.type === "disconnectProvider") return disconnectProviderAction(ctx, rid, pid, this.cachedConfigMessage, set) if (msg.type === "saveCustomProvider" && config) - return saveCustomProviderAction(ctx, rid, pid, config, key, this.cachedConfigMessage, set) + return saveCustomProviderAction(ctx, rid, pid, config, key, keyChanged, this.cachedConfigMessage, set) } private async handleFetchCustomProviderModels(msg: Record): Promise { @@ -1988,6 +2049,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // races with the async config.update() write on the CLI backend). this.pending++ try { + // Reject all pending permissions and questions across every provider + // so their CLI-side Promises resolve before disposeAll() wipes + // Instance state. Throws on failure to abort the config save. + await this.connectionService.drainPendingPrompts() + await this.client.global.config.update({ config: partial }, { throwOnError: true }) // Re-fetch the full merged config (global + project + all layers) so the @@ -2019,17 +2085,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Handle sending a message from the webview. - * Uses promptAsync (fire-and-forget) — the server queues concurrent prompts - * internally, so the client doesn't need to block on busy sessions. - */ /** * Ensure a session exists, creating one if needed. Returns the resolved * session ID and workspace directory, or undefined when the client is * disconnected. */ - private async resolveSession(sessionID?: string): Promise<{ sid: string; dir: string } | undefined> { + private async resolveSession( + sessionID?: string, + draftID?: string, + ): Promise<{ sid: string; dir: string } | undefined> { if (!this.client) return undefined const dir = sessionID ? this.getWorkspaceDirectory(sessionID) : this.getContextDirectory() @@ -2040,9 +2104,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.contextSessionID = session.id this.trackDirectory(session.id, dir) this.trackedSessionIds.add(session.id) + if (draftID) this.contextSessionID = session.id this.postMessage({ type: "sessionCreated", session: this.sessionToWebview(session), + draftID, }) } @@ -2056,6 +2122,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper text: string, messageID?: string, sessionID?: string, + draftID?: string, providerID?: string, modelID?: string, agent?: string, @@ -2068,6 +2135,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper error: "Not connected to CLI backend", text, sessionID, + draftID, messageID, files, }) @@ -2076,7 +2144,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { - resolved = await this.resolveSession(sessionID) + resolved = await this.resolveSession(sessionID, draftID) const parts: Array = [] if (files) { @@ -2112,6 +2180,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper error: getErrorMessage(error) || "Failed to send message", text, sessionID: resolved?.sid ?? sessionID, + draftID, messageID, files, }) @@ -2123,6 +2192,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper args: string, messageID?: string, sessionID?: string, + draftID?: string, providerID?: string, modelID?: string, agent?: string, @@ -2135,6 +2205,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper error: "Not connected to CLI backend", text: `/${command} ${args}`.trim(), sessionID, + draftID, messageID, files, }) @@ -2143,7 +2214,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { - resolved = await this.resolveSession(sessionID) + resolved = await this.resolveSession(sessionID, draftID) if (messageID) { this.connectionService.recordMessageSessionId(messageID, resolved!.sid) @@ -2172,6 +2243,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper error: getErrorMessage(error) || "Failed to send command", text: `/${command} ${args}`.trim(), sessionID: resolved?.sid ?? sessionID, + draftID, messageID, files, }) @@ -2515,6 +2587,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Extract sessionID from the event + if (event.type === "session.created" && this.adoptPendingFollowup(event.properties.info)) { + return + } + const sessionID = this.connectionService.resolveEventSessionId(event) // Events without sessionID (server.connected, server.heartbeat) → always forward @@ -2773,6 +2849,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sessionDirectories.set(sessionId, dir) } + private noteFollowup(answers: string[][], sessionID?: string) { + const dir = this.getWorkspaceDirectory(sessionID) + this.pendingFollowup = recordFollowup({ answers, dir, now: Date.now() }) ?? null + } + + private matchesPendingFollowup(session: Session) { + return matchFollowup({ pending: this.pendingFollowup, dir: session.directory, now: Date.now() }) + } + + private adoptPendingFollowup(session: Session) { + const now = Date.now() + const match = this.matchesPendingFollowup(session) + if (!match) { + if ( + this.pendingFollowup && + !matchFollowup({ pending: this.pendingFollowup, dir: this.pendingFollowup.dir, now }) + ) { + this.pendingFollowup = null + } + return false + } + + this.pendingFollowup = null + this.trackDirectory(session.id, session.directory) + this.registerSession(session) + void this.handleLoadMessages(session.id) + return true + } + private getProjectDirectory(sessionId?: string): string | undefined { return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId)) } @@ -2860,7 +2965,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.unsubscribeNotificationDismiss?.() this.unsubscribeLanguageChange?.() this.unsubscribeProfileChange?.() + this.unsubscribeFavoritesChange?.() this.unsubscribeMigrationComplete?.() + this.unsubscribeClearPendingPrompts?.() + this.unsubscribeDirectoryProvider?.() this.webviewMessageDisposable?.dispose() this.trackedSessionIds.clear() this.syncedChildSessions.clear() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index b463e7682da..424d0b54b07 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -19,6 +19,7 @@ import { createTerminalHost } from "./terminal-host" import { executeVscodeTask } from "./task-runner" import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" + import { shouldStopDiffPolling } from "./delete-worktree" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" @@ -58,11 +59,11 @@ export class AgentManagerProvider implements Disposable { private cachedWorktreeStats: AgentManagerOutMessage | undefined private cachedLocalStats: AgentManagerOutMessage | undefined private applyingWorktreeId: string | undefined + /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on * an async `session.get` round-trip and can be stale during rapid tab switches. */ private activeSessionId: string | undefined - constructor( private readonly host: Host, private readonly connectionService: KiloConnectionService, @@ -172,9 +173,17 @@ export class AgentManagerProvider implements Disposable { return } - await state.load() + const migration = await state.load() manager.cleanupOrphanedTempDirs() + // When the .kilocode → .kilo migration rewrote git worktree refs, nudge + // VS Code's git extension to re-discover them. Without this, worktrees + // won't appear in Source Control until the next VS Code restart. + if (migration.refsFixed > 0) { + this.log(`Migration fixed ${migration.refsFixed} git worktree ref(s), refreshing git`) + this.host.refreshGit() + } + // Do not auto-remove stale worktrees on load. // Presence checks run in the shared poller and require explicit user cleanup. @@ -222,6 +231,9 @@ export class AgentManagerProvider implements Disposable { if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId) if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId) if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId) + if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) { + this.activeSessionId = m.draftID + } if (m.type === "agentManager.configureSetupScript") { void this.configureSetupScript() return null @@ -238,6 +250,10 @@ export class AgentManagerProvider implements Disposable { this.openWorktreeDirectory(m.worktreeId) return null } + if (m.type === "agentManager.copyToClipboard") { + this.host.copyToClipboard(m.text) + return null + } if (m.type === "previewImage") { return msg } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 6dbaee91edb..1148fbc3f02 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -53,7 +53,7 @@ interface StateFile { defaultBaseBranch?: string } -import { KILO_DIR, migrateAgentManagerData } from "./constants" +import { KILO_DIR, migrateAgentManagerData, type MigrationResult } from "./constants" const STATE_FILE = "agent-manager.json" @@ -302,11 +302,12 @@ export class WorktreeStateManager { // Persistence // --------------------------------------------------------------------------- - async load(): Promise { + async load(): Promise { // Migrate Agent Manager data from .kilocode → .kilo before first read + let migration: MigrationResult = { refsFixed: 0 } if (!this.migrated) { this.migrated = true - await migrateAgentManagerData(this.root, this.log) + migration = await migrateAgentManagerData(this.root, this.log) } try { const content = await fs.promises.readFile(this.file, "utf-8") @@ -346,6 +347,7 @@ export class WorktreeStateManager { this.log(`Failed to load state: ${error}`) } } + return migration } /** Remove worktrees whose directories no longer exist on disk. */ diff --git a/packages/kilo-vscode/src/agent-manager/constants.ts b/packages/kilo-vscode/src/agent-manager/constants.ts index ec09e877416..771ce4172f2 100644 --- a/packages/kilo-vscode/src/agent-manager/constants.ts +++ b/packages/kilo-vscode/src/agent-manager/constants.ts @@ -30,6 +30,12 @@ const AGENT_MANAGER_ITEMS = [ "setup-script.bat", ] +/** Result of the migration so callers can react (e.g. refresh VS Code git). */ +export interface MigrationResult { + /** Number of git worktree refs that were rewritten from .kilocode → .kilo. */ + refsFixed: number +} + /** * Migrate Agent Manager data from .kilocode/ to .kilo/. * @@ -43,7 +49,7 @@ const AGENT_MANAGER_ITEMS = [ * * Idempotent: safe to call on every startup. */ -export async function migrateAgentManagerData(root: string, log: (msg: string) => void): Promise { +export async function migrateAgentManagerData(root: string, log: (msg: string) => void): Promise { const legacy = path.join(root, LEGACY_DIR) const target = path.join(root, KILO_DIR) @@ -77,9 +83,12 @@ export async function migrateAgentManagerData(root: string, log: (msg: string) = } } + let refsFixed = 0 if (await isDirectory(path.join(target, "worktrees"))) { - await fixGitWorktreeRefs(root, log) + refsFixed = await fixGitWorktreeRefs(root, log) } + + return { refsFixed } } async function exists(filepath: string): Promise { @@ -114,7 +123,7 @@ async function resolveGitDir(root: string): Promise { const content = await fs.promises.readFile(gitPath, "utf-8") const match = content.match(/^gitdir:\s*(.+)$/m) - if (!match) return undefined + if (!match?.[1]) return undefined // gitdir points to e.g. /repo/.git/worktrees/foo — go up two levels to /repo/.git return path.resolve(path.dirname(gitPath), match[1].trim(), "..", "..") } catch { @@ -128,21 +137,29 @@ async function resolveGitDir(root: string): Promise { * Git stores absolute paths in .git/worktrees/{name}/gitdir. When the * worktree directory moves, those paths become stale. This rewrites any * gitdir files that reference the old .kilocode path. + * + * Returns the number of refs that were successfully fixed so callers can + * tell whether a VS Code git refresh is warranted. */ -async function fixGitWorktreeRefs(root: string, log: (msg: string) => void): Promise { +async function fixGitWorktreeRefs(root: string, log: (msg: string) => void): Promise { const gitDir = await resolveGitDir(root) - if (!gitDir) return + if (!gitDir) { + log("fixGitWorktreeRefs: could not resolve git directory") + return 0 + } const gitWorktreesDir = path.join(gitDir, "worktrees") try { const stat = await fs.promises.stat(gitWorktreesDir) - if (!stat.isDirectory()) return - } catch { - return + if (!stat.isDirectory()) return 0 + } catch (err) { + log(`fixGitWorktreeRefs: ${gitWorktreesDir} not accessible: ${err}`) + return 0 } const oldSegment = path.join(root, LEGACY_DIR) + path.sep const newSegment = path.join(root, KILO_DIR) + path.sep + let fixed = 0 try { const entries = await fs.promises.readdir(gitWorktreesDir, { withFileTypes: true }) @@ -151,15 +168,26 @@ async function fixGitWorktreeRefs(root: string, log: (msg: string) => void): Pro const gitdirFile = path.join(gitWorktreesDir, entry.name, "gitdir") try { const content = await fs.promises.readFile(gitdirFile, "utf-8") - if (content.includes(oldSegment)) { - await fs.promises.writeFile(gitdirFile, content.replaceAll(oldSegment, newSegment)) + if (!content.includes(oldSegment)) continue + + const updated = content.replaceAll(oldSegment, newSegment) + await fs.promises.writeFile(gitdirFile, updated) + + // Verify the write persisted — catch silent FS failures (e.g. read-only mount) + const verify = await fs.promises.readFile(gitdirFile, "utf-8") + if (verify === updated) { + fixed++ log(`Fixed git worktree ref: ${entry.name}`) + } else { + log(`Warning: git worktree ref write did not persist for ${entry.name}`) } - } catch { - // gitdir file missing or unreadable — skip + } catch (err) { + log(`Warning: could not fix git worktree ref ${entry.name}: ${err}`) } } - } catch { - // .git/worktrees unreadable — skip + } catch (err) { + log(`Warning: could not read ${gitWorktreesDir}: ${err}`) } + + return fixed } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index a9d521641d2..34bbdd3b7f3 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -101,9 +101,15 @@ export interface Host { /** Get the CLI server port (for webview CSP). */ serverPort(): number | undefined + /** Copy text to the system clipboard. */ + copyToClipboard(text: string): void + /** Capture a telemetry event. */ capture(event: string, properties?: Record): void + /** Ask VS Code's git extension to re-scan repositories (e.g. after worktree ref migration). */ + refreshGit(): void + /** Dispose all host resources. */ dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 3dab224e322..aeffd54bbce 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -262,6 +262,11 @@ interface OpenWorktreeIn { worktreeId: string } +interface CopyToClipboardIn { + type: "agentManager.copyToClipboard" + text: string +} + interface ShowExistingLocalTerminalIn { type: "agentManager.showExistingLocalTerminal" } @@ -401,6 +406,33 @@ interface LoadMessagesIn { sessionID: string } +interface SendMessageIn { + type: "sendMessage" + text: string + messageID?: string + sessionID?: string + draftID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: Array<{ mime: string; url: string; filename?: string }> +} + +interface SendCommandIn { + type: "sendCommand" + command: string + arguments: string + messageID?: string + sessionID?: string + draftID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: Array<{ mime: string; url: string; filename?: string }> +} + interface ClearSessionIn { type: "clearSession" } @@ -435,6 +467,7 @@ export type AgentManagerInMessage = | ShowTerminalIn | ShowLocalTerminalIn | OpenWorktreeIn + | CopyToClipboardIn | ShowExistingLocalTerminalIn | RequestRepoInfoIn | CreateMultiVersionIn @@ -460,6 +493,8 @@ export type AgentManagerInMessage = | GenericOpenFileIn | PreviewImageIn | LoadMessagesIn + | SendMessageIn + | SendCommandIn | ClearSessionIn | AbortIn | ContinueInWorktreeIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index ced0419cb08..f3f3a4c969f 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -152,9 +152,19 @@ export class VscodeHost implements Host { return this.connectionService.getServerInfo()?.port } + copyToClipboard(text: string): void { + void vscode.env.clipboard.writeText(text) + } + capture(event: string, properties?: Record): void { TelemetryProxy.capture(event as TelemetryEventName, properties) } + refreshGit(): void { + // Trigger VS Code's built-in git extension to re-scan repositories. + // This picks up worktrees whose gitdir refs were just rewritten by migration. + void vscode.commands.executeCommand("git.refresh") + } + dispose(): void {} } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index bf6d27da0f2..6679ac84453 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -45,6 +45,18 @@ export function activate(context: vscode.ExtensionContext) { } }) + // Track all open tab panel providers so toolbar button commands can target them. + // NOTE: The editor/title toolbar for tab panels intentionally omits Agent Manager + // and Marketplace buttons (unlike the sidebar). Too many icons causes VS Code to + // collapse them into a "..." overflow menu, hiding important buttons like Settings. + const tabPanels = new Map() + const activeTabProvider = () => { + for (const [panel, p] of tabPanels) { + if (panel.active) return p + } + return undefined + } + // Create the provider with shared service const provider = new KiloProvider(context.extensionUri, connectionService, context) @@ -94,9 +106,11 @@ export function activate(context: vscode.ExtensionContext) { agentManagerProvider.continueFromSidebar(sessionId, progress), ) tabProvider.resolveWebviewPanel(panel) + tabPanels.set(panel, tabProvider) panel.onDidDispose( () => { console.log("[Kilo New] Tab panel restored from restart disposed") + tabPanels.delete(panel) tabProvider.dispose() }, null, @@ -158,7 +172,9 @@ export function activate(context: vscode.ExtensionContext) { // Register toolbar button command handlers context.subscriptions.push( vscode.commands.registerCommand("kilo-code.new.plusButtonClicked", () => { - provider.postMessage({ type: "action", action: "plusButtonClicked" }) + const tab = activeTabProvider() + if (tab) tab.postMessage({ type: "action", action: "plusButtonClicked" }) + else provider.postMessage({ type: "action", action: "plusButtonClicked" }) }), vscode.commands.registerCommand("kilo-code.new.agentManagerOpen", () => { agentManagerProvider.openPanel() @@ -167,7 +183,9 @@ export function activate(context: vscode.ExtensionContext) { settingsEditorProvider.openPanel("marketplace", undefined, directory) }), vscode.commands.registerCommand("kilo-code.new.historyButtonClicked", () => { - provider.postMessage({ type: "action", action: "historyButtonClicked" }) + const tab = activeTabProvider() + if (tab) tab.postMessage({ type: "action", action: "historyButtonClicked" }) + else provider.postMessage({ type: "action", action: "historyButtonClicked" }) }), vscode.commands.registerCommand("kilo-code.new.cycleAgentMode", () => { provider.postMessage({ type: "action", action: "cycleAgentMode" }) @@ -199,7 +217,7 @@ export function activate(context: vscode.ExtensionContext) { provider.postMessage({ type: "triggerTask", text: `Generate a terminal command: ${input}` }) }), vscode.commands.registerCommand("kilo-code.new.openInTab", () => { - return openKiloInNewTab(context, connectionService, agentManagerProvider) + return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels) }), vscode.commands.registerCommand("kilo-code.new.showChanges", () => { diffViewerProvider.openPanel() @@ -330,6 +348,7 @@ async function openKiloInNewTab( context: vscode.ExtensionContext, connectionService: KiloConnectionService, agentManagerProvider: AgentManagerProvider, + tabPanels: Map, ) { const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0) const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 @@ -356,6 +375,7 @@ async function openKiloInNewTab( agentManagerProvider.continueFromSidebar(sessionId, progress), ) tabProvider.resolveWebviewPanel(panel) + tabPanels.set(panel, tabProvider) // Wait for the new panel to become active before locking the editor group. // This avoids the race where VS Code hasn't switched focus yet. @@ -365,6 +385,7 @@ async function openKiloInNewTab( panel.onDidDispose( () => { console.log("[Kilo New] Tab panel disposed") + tabPanels.delete(panel) tabProvider.dispose() }, null, diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 6b91cb668ec..a7dbc21b800 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -225,7 +225,7 @@ export type WebviewMessage = | { type: "questionResolved"; requestID: string } | { type: "permissionResolved"; permissionID: string } | { type: "permissionError"; permissionID: string } - | { type: "sessionCreated"; session: ReturnType } + | { type: "sessionCreated"; session: ReturnType; draftID?: string } | { type: "sessionUpdated"; session: ReturnType } | { type: "messageRemoved"; sessionID: string; messageID: string } | { type: "sessionError"; sessionID?: string; error?: unknown } diff --git a/packages/kilo-vscode/src/kilo-provider/followup-session.ts b/packages/kilo-vscode/src/kilo-provider/followup-session.ts new file mode 100644 index 00000000000..3b220d44926 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/followup-session.ts @@ -0,0 +1,25 @@ +import { normalizePath } from "../agent-manager/git-import" + +const TTL = 30_000 +const LABEL = "Start new session" + +export interface Followup { + dir: string + time: number +} + +export function recordFollowup(input: { answers: string[][]; dir: string; now: number }): Followup | undefined { + const answer = input.answers[0]?.[0]?.trim() + if (answer !== LABEL) return + return { + dir: input.dir, + time: input.now, + } +} + +export function matchFollowup(input: { pending: Followup | null; dir: string; now: number }): boolean { + const item = input.pending + if (!item) return false + if (input.now - item.time > TTL) return false + return normalizePath(item.dir) === normalizePath(input.dir) +} diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 68307d28a0b..20f09828185 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -215,6 +215,7 @@ export async function handleImportAndSend( error: err instanceof Error ? err.message : "Failed to send message after import", text, sessionID: session.id, + draftID: session.id, messageID, files, }) diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts index 53e722df50c..2279088c5e1 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts @@ -15,37 +15,51 @@ interface QuestionContext { } /** Handle question reply from the webview. */ -export async function handleQuestionReply(ctx: QuestionContext, requestID: string, answers: string[][]): Promise { +export async function handleQuestionReply( + ctx: QuestionContext, + requestID: string, + answers: string[][], + sessionID?: string, +): Promise { if (!ctx.client) { ctx.postMessage({ type: "questionError", requestID }) - return + return false } + const sid = sessionID ?? ctx.currentSessionId + try { await ctx.client.question.reply( - { requestID, answers, directory: ctx.getWorkspaceDirectory(ctx.currentSessionId) }, + { requestID, answers, directory: ctx.getWorkspaceDirectory(sid) }, { throwOnError: true }, ) + return true } catch (error) { console.error("[Kilo New] KiloProvider: Failed to reply to question:", error) ctx.postMessage({ type: "questionError", requestID }) + return false } } /** Handle question reject (dismiss) from the webview. */ -export async function handleQuestionReject(ctx: QuestionContext, requestID: string): Promise { +export async function handleQuestionReject( + ctx: QuestionContext, + requestID: string, + sessionID?: string, +): Promise { if (!ctx.client) { ctx.postMessage({ type: "questionError", requestID }) - return + return false } + const sid = sessionID ?? ctx.currentSessionId + try { - await ctx.client.question.reject( - { requestID, directory: ctx.getWorkspaceDirectory(ctx.currentSessionId) }, - { throwOnError: true }, - ) + await ctx.client.question.reject({ requestID, directory: ctx.getWorkspaceDirectory(sid) }, { throwOnError: true }) + return true } catch (error) { console.error("[Kilo New] KiloProvider: Failed to reject question:", error) ctx.postMessage({ type: "questionError", requestID }) + return false } } diff --git a/packages/kilo-vscode/src/legacy-migration/legacy-types.ts b/packages/kilo-vscode/src/legacy-migration/legacy-types.ts index a57ae2bff84..15dfa1a01e3 100644 --- a/packages/kilo-vscode/src/legacy-migration/legacy-types.ts +++ b/packages/kilo-vscode/src/legacy-migration/legacy-types.ts @@ -289,6 +289,7 @@ export interface MigrationProviderInfo { export interface MigrationMcpServerInfo { name: string type: string + disabled?: boolean } export interface MigrationCustomModeInfo { diff --git a/packages/kilo-vscode/src/legacy-migration/migration-service.ts b/packages/kilo-vscode/src/legacy-migration/migration-service.ts index a3ea98ca44e..54b02206f71 100644 --- a/packages/kilo-vscode/src/legacy-migration/migration-service.ts +++ b/packages/kilo-vscode/src/legacy-migration/migration-service.ts @@ -767,9 +767,18 @@ async function migrateLanguage(language: string): Promise { // --------------------------------------------------------------------------- function convertMcpServer(server: LegacyMcpServer): McpLocalConfig | McpRemoteConfig | null { + const enabled = server.disabled ? { enabled: false as const } : {} + // Legacy stores timeout in seconds, the new config expects milliseconds + const timeout = server.timeout !== undefined ? server.timeout * 1000 : undefined if (server.type === "sse" || server.type === "streamable-http") { if (!server.url) return null - return { type: "remote", url: server.url, headers: server.headers } + return { + type: "remote", + url: server.url, + headers: server.headers, + ...(timeout !== undefined && { timeout }), + ...enabled, + } } // Default: stdio if (!server.command) return null @@ -778,7 +787,8 @@ function convertMcpServer(server: LegacyMcpServer): McpLocalConfig | McpRemoteCo type: "local", command, environment: server.env, - ...(server.timeout !== undefined && { timeout: server.timeout }), + ...(timeout !== undefined && { timeout }), + ...enabled, } } @@ -1145,9 +1155,11 @@ function buildProviderList( function buildMcpServerList(settings: LegacyMcpSettings | null): MigrationMcpServerInfo[] { if (!settings?.mcpServers) return [] - return Object.entries(settings.mcpServers) - .filter(([, server]) => !server.disabled) - .map(([name, server]) => ({ name, type: server.type ?? "stdio" })) + return Object.entries(settings.mcpServers).map(([name, server]) => ({ + name, + type: server.type ?? "stdio", + disabled: server.disabled, + })) } /** @internal — exported for testing only */ diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts index e59616216df..c0ddc3d75bb 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/messages.ts @@ -10,11 +10,15 @@ type Assistant = Extract export function parseMessagesFromConversation( conversation: LegacyApiMessage[], id: string, + dirOrItem?: string | LegacyHistoryItem, item?: LegacyHistoryItem, ): Array> { + const dir = typeof dirOrItem === "string" ? dirOrItem : (dirOrItem?.workspace ?? "") + const next = typeof dirOrItem === "string" ? item : dirOrItem + return conversation .filter((entry) => entry.role === "user" || entry.role === "assistant") - .map((entry, index) => parseMessage(entry, index, id, item)) + .map((entry, index) => parseMessage(entry, index, id, dir, next)) .filter((message): message is NonNullable => Boolean(message)) } @@ -22,6 +26,7 @@ function parseMessage( entry: LegacyApiMessage, index: number, id: string, + dir: string, item?: LegacyHistoryItem, ): NonNullable | undefined { const created = entry.ts ?? item?.ts ?? 0 @@ -55,8 +60,8 @@ function parseMessage( mode: item?.mode ?? "code", agent: "main", path: { - cwd: item?.workspace ?? "", - root: item?.workspace ?? "", + cwd: dir, + root: dir, }, cost: 0, tokens: { diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/path.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/path.ts new file mode 100644 index 00000000000..4b841725400 --- /dev/null +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/path.ts @@ -0,0 +1,25 @@ +import * as fs from "fs/promises" +import * as path from "path" + +export async function normalizeLegacyPath(input?: string): Promise { + const raw = input?.trim() + if (!raw) return "" + + // Collapse legacy paths into one stable absolute form before importing them. + const normalized = path.normalize(path.resolve(raw)) + const canonical = normalizeWindowsDriveLetter(normalized) + + return fs.realpath(canonical).catch(() => canonical) +} + +export function isWindowsDrivePath(input: string): boolean { + return /^[a-z]:[\\/]/i.test(input) +} + +function normalizeWindowsDriveLetter(input: string): string { + // Match the canonical drive-letter casing used later by Windows path filters. + if (!isWindowsDrivePath(input)) return input + const head = input[0] + if (!head) return input + return head.toUpperCase() + input.slice(1) +} diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts index b073f535d19..6a06b364796 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/project.ts @@ -4,12 +4,13 @@ import { createProjectID } from "./ids" export function createProject(item?: LegacyHistoryItem): NonNullable { const project = makeProject() + const dir = item?.workspace ?? "" - project.id = createProjectID(item?.workspace) + project.id = createProjectID(dir) - project.worktree = item?.workspace ?? "" + project.worktree = dir - project.sandboxes = item?.workspace ? [item.workspace] : [] + project.sandboxes = dir ? [dir] : [] project.timeCreated = item?.ts ?? 0 diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts b/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts index 14058c6e94f..725aacf6059 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/lib/session.ts @@ -6,6 +6,7 @@ export function createSession( id: string, item: LegacyHistoryItem | undefined, projectID: string, + dir: string, ): NonNullable { const session = makeSession() @@ -15,7 +16,7 @@ export function createSession( session.slug = id - session.directory = item?.workspace ?? "" + session.directory = dir session.title = item?.task ?? id diff --git a/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts b/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts index 76a063b3f80..966ba46bf41 100644 --- a/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts +++ b/packages/kilo-vscode/src/legacy-migration/sessions/parser.ts @@ -8,6 +8,7 @@ import type { import { getApiConversationHistory, parseFile } from "./lib/legacy-conversation" import { parseMessagesFromConversation } from "./lib/messages" import { parsePartsFromConversation } from "./lib/parts/parts" +import { normalizeLegacyPath } from "./lib/path" import { createProject } from "./lib/project" import { createSession } from "./lib/session" @@ -19,11 +20,13 @@ export interface NormalizedSession { } export async function parseSession(id: string, dir: string, item?: LegacyHistoryItem): Promise { - const project = createProject(item) - const session = createSession(id, item, project.id) + const root = await normalizeLegacyPath(item?.workspace) + const next = item ? { ...item, workspace: root } : undefined + const project = createProject(next) + const session = createSession(id, next, project.id, root) const file = await getApiConversationHistory(id, dir) const conversation = parseFile(file) - const messages = parseMessagesFromConversation(conversation, id, item) + const messages = parseMessagesFromConversation(conversation, id, root, next) const parts = parsePartsFromConversation(conversation, id, item) return { diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 44524fe9b01..9df4a3eec3b 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -4,7 +4,7 @@ */ import type { KiloClient } from "@kilocode/sdk/v2" import { validateProviderID as validateProviderIDShared } from "./shared/custom-provider" -import { sanitizeCustomProviderConfig } from "./shared/custom-provider" +import { resolveCustomProviderAuth, sanitizeCustomProviderConfig } from "./shared/custom-provider" import { KILO_AUTO, parseModelString } from "./shared/provider-model" /** @@ -28,7 +28,17 @@ export async function fetchProviderData(client: KiloClient, dir: string) { authRequest, ]) const authStates: Record = {} - return { response, authMethods, authStates } + const all = response.all.map((item) => { + const raw = item as Record + if (typeof raw.id === "string" && typeof raw.key === "string" && raw.key) { + authStates[raw.id] = "api" + } + if (!("key" in raw)) return item + const next = { ...raw } + delete next.key + return next as (typeof response.all)[number] + }) + return { response: { ...response, all }, authMethods, authStates } } export function buildActionContext( @@ -44,6 +54,9 @@ export function buildActionContext( getErrorMessage: errFn, workspaceDir: dir, disposeGlobal: async (reason: string) => { + // Wait for the server to finish disposing before refreshing providers. + // Shared State.dispose() now has a hard per-disposer timeout, so this + // wait is bounded without needing a client-side timeout here. await client.global.dispose().catch((error: unknown) => { console.warn(`[Kilo New] KiloProvider: global.dispose() after ${reason} failed:`, error) }) @@ -70,6 +83,12 @@ export function validateRecents(raw: unknown): Array<{ providerID: string; model .map((r) => ({ providerID: r.providerID, modelID: r.modelID })) } +/** Validate and sanitize favorite model selections from untrusted sources. */ +export function validateFavorites(raw: unknown): Array<{ providerID: string; modelID: string }> { + if (!Array.isArray(raw)) return [] + return raw.filter(isModelSelection).map((r) => ({ providerID: r.providerID, modelID: r.modelID })) +} + export function computeDefaultSelection( cachedConfig: { config?: { model?: string } } | null, vscodePID: string, @@ -246,6 +265,7 @@ export async function saveCustomProvider( providerID: string, provider: Record, apiKey: string | undefined, + apiKeyChanged: boolean, cachedConfigMessage: unknown, setCachedConfig: (msg: unknown) => void, ) { @@ -281,10 +301,13 @@ export async function saveCustomProvider( setCachedConfig(msg) ctx.postMessage({ type: "configUpdated", config: updated }) + const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) + try { - if (apiKey) { - await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: apiKey } }, { throwOnError: true }) - } else { + if (auth.mode === "set") { + await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: auth.key } }, { throwOnError: true }) + } + if (auth.mode === "clear") { await ctx.client.auth.remove({ providerID: id }, { throwOnError: true }) } } catch (error) { diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index abee02099a5..beb7fe6ad82 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -13,6 +13,9 @@ type NotificationDismissListener = (notificationId: string) => void type LanguageChangeListener = (locale: string) => void type ProfileChangeListener = (data: unknown) => void type MigrationCompleteListener = () => void +type FavoritesChangeListener = (favorites: Array<{ providerID: string; modelID: string }>) => void +type ClearPendingPromptsListener = () => void +type DirectoryProvider = () => string[] // Poll /global/health at the same interval as packages/app/src/context/server.tsx. // This provides a second detection channel for server death independent of the SSE heartbeat. @@ -38,6 +41,9 @@ export class KiloConnectionService { private readonly languageChangeListeners: Set = new Set() private readonly profileChangeListeners: Set = new Set() private readonly migrationCompleteListeners: Set = new Set() + private readonly favoritesChangeListeners: Set = new Set() + private readonly clearPendingPromptsListeners: Set = new Set() + private readonly directoryProviders: Set = new Set() /** * Shared mapping used to resolve session scope for events that don't reliably include a sessionID. @@ -228,6 +234,107 @@ export class KiloConnectionService { } } + /** + * Subscribe to favorites change events broadcast from any KiloProvider. Returns unsubscribe function. + */ + onFavoritesChanged(listener: FavoritesChangeListener): () => void { + this.favoritesChangeListeners.add(listener) + return () => { + this.favoritesChangeListeners.delete(listener) + } + } + + /** + * Broadcast a favorites change event to all subscribed KiloProvider instances. + */ + notifyFavoritesChanged(favorites: Array<{ providerID: string; modelID: string }>): void { + for (const listener of this.favoritesChangeListeners) { + listener(favorites) + } + } + + /** + * Subscribe to clear-pending-prompts broadcast. Returns unsubscribe function. + * Fired after a config save drains all pending permissions/questions so each + * webview can clear stale prompt UI. + */ + onClearPendingPrompts(listener: ClearPendingPromptsListener): () => void { + this.clearPendingPromptsListeners.add(listener) + return () => { + this.clearPendingPromptsListeners.delete(listener) + } + } + + /** + * Register a callback that returns workspace directories tracked by a + * KiloProvider (root + worktree dirs). Used by drainPendingPrompts() to + * cover all active Instance directories across every provider. + */ + registerDirectoryProvider(provider: DirectoryProvider): () => void { + this.directoryProviders.add(provider) + return () => { + this.directoryProviders.delete(provider) + } + } + + /** + * Reject all pending permission requests and questions across every + * directory known to any KiloProvider **and** every project the CLI + * backend has ever opened. The project list covers worktree sessions + * whose provider was disposed (panel/sidebar closed) while the CLI + * backend kept running. + * + * Must be called before operations that trigger Instance.disposeAll() + * (e.g. config save) to prevent orphaned Promises from freezing + * sessions. + * + * Throws if any list/reject call fails so callers can abort the + * destructive operation. + */ + async drainPendingPrompts(): Promise { + if (!this.client) return + + // Collect directories from all mounted providers (root + worktree dirs). + const dirs = new Set() + for (const provider of this.directoryProviders) { + for (const dir of provider()) { + dirs.add(dir) + } + } + + // Also include every project directory the CLI backend knows about. + // This covers worktree sessions whose KiloProvider was already disposed. + const { data: projects, error: projectsErr } = await this.client.project.list() + if (projectsErr) throw new Error(`Failed to list projects: ${String(projectsErr)}`) + if (projects) { + for (const p of projects) { + dirs.add(p.worktree) + } + } + + for (const dir of dirs) { + const { data: perms, error: permsErr } = await this.client.permission.list({ directory: dir }) + if (permsErr) throw new Error(`Failed to list permissions for ${dir}: ${String(permsErr)}`) + if (perms) { + for (const perm of perms) { + const { error } = await this.client.permission.reply({ requestID: perm.id, reply: "reject", directory: dir }) + if (error) throw new Error(`Failed to reject permission ${perm.id}: ${String(error)}`) + } + } + const { data: qs, error: qsErr } = await this.client.question.list({ directory: dir }) + if (qsErr) throw new Error(`Failed to list questions for ${dir}: ${String(qsErr)}`) + if (qs) { + for (const q of qs) { + const { error } = await this.client.question.reject({ requestID: q.id, directory: dir }) + if (error) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`) + } + } + } + for (const listener of this.clearPendingPromptsListeners) { + listener() + } + } + /** * Subscribe to connection state changes. Returns unsubscribe function. */ @@ -250,6 +357,9 @@ export class KiloConnectionService { this.notificationDismissListeners.clear() this.profileChangeListeners.clear() this.migrationCompleteListeners.clear() + this.favoritesChangeListeners.clear() + this.clearPendingPromptsListeners.clear() + this.directoryProviders.clear() this.messageSessionIdsByMessageId.clear() this.client = null this.sseClient = null diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts index b758b4ba105..494688559ec 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts @@ -15,6 +15,7 @@ import { dict as ru } from "./ru" import { dict as th } from "./th" import { dict as zh } from "./zh" import { dict as tr } from "./tr" +import { dict as uk } from "./uk" import { dict as zht } from "./zht" import { type dict as enDict } from "./en" @@ -35,6 +36,7 @@ const bundles: Record> = { ru, th, tr, + uk, zh, zht, } diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts new file mode 100644 index 00000000000..fe0c703d0e7 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts @@ -0,0 +1,4 @@ +export const dict = { + "server.processExited": "Процес CLI завершився з кодом {{code}} до запуску сервера", + "server.startupTimeout": "Час очікування запуску сервера вичерпано після {{seconds}} секунд", +} as const diff --git a/packages/kilo-vscode/src/shared/custom-provider.ts b/packages/kilo-vscode/src/shared/custom-provider.ts index 61305e3c1b9..74ec2771057 100644 --- a/packages/kilo-vscode/src/shared/custom-provider.ts +++ b/packages/kilo-vscode/src/shared/custom-provider.ts @@ -51,6 +51,10 @@ export type SanitizedProviderConfig = { models: Record } +export type CustomProviderAuthChange = { mode: "preserve" } | { mode: "clear" } | { mode: "set"; key: string } + +export const MASKED_CUSTOM_PROVIDER_KEY = "********" + type Issue = { error: string; issue?: z.ZodIssue } function fail(error: string, issue?: z.ZodIssue): Issue { @@ -78,6 +82,18 @@ export function parseCustomProviderSecret(raw: string): { value: { apiKey?: stri return fail(issue?.message ?? INVALID_ENV, issue) } +export function resolveCustomProviderAuth(apiKey: string | undefined, changed: boolean): CustomProviderAuthChange { + const key = apiKey?.trim() + if (!changed) return { mode: "preserve" } + if (key) return { mode: "set", key } + return { mode: "clear" } +} + +export function resolveCustomProviderKey(auth: "api" | "oauth" | "wellknown" | undefined) { + if (auth !== "api") return "" + return MASKED_CUSTOM_PROVIDER_KEY +} + export function normalizeCustomProviderConfig( config: z.output, ): SanitizedProviderConfig { 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 bf2ffcb5706..c991cc6f5b6 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -532,7 +532,7 @@ const VSCODE_ALLOWED: Record = { */ const MAX_LINES: Record = { "AgentManagerProvider.ts": { - maxLines: 1900, + maxLines: 1910, note: "primary extraction target: break into smaller orchestrators", }, } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts index b51354ae41a..38a34b3f6ed 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts @@ -15,6 +15,9 @@ import { dict as appNo } from "../../webview-ui/src/i18n/no" import { dict as appBr } from "../../webview-ui/src/i18n/br" import { dict as appTh } from "../../webview-ui/src/i18n/th" import { dict as appBs } from "../../webview-ui/src/i18n/bs" +import { dict as appTr } from "../../webview-ui/src/i18n/tr" +import { dict as appNl } from "../../webview-ui/src/i18n/nl" +import { dict as appUk } from "../../webview-ui/src/i18n/uk" import { dict as amEn } from "../../webview-ui/agent-manager/i18n/en" import { dict as amZh } from "../../webview-ui/agent-manager/i18n/zh" import { dict as amZht } from "../../webview-ui/agent-manager/i18n/zht" @@ -31,6 +34,9 @@ import { dict as amNo } from "../../webview-ui/agent-manager/i18n/no" import { dict as amBr } from "../../webview-ui/agent-manager/i18n/br" import { dict as amTh } from "../../webview-ui/agent-manager/i18n/th" import { dict as amBs } from "../../webview-ui/agent-manager/i18n/bs" +import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr" +import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl" +import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk" const PREFIX = "agentManager." @@ -51,6 +57,9 @@ const locales = { br: amBr, th: amTh, bs: amBs, + tr: amTr, + nl: amNl, + uk: amUk, } const appLocales = { @@ -70,6 +79,9 @@ const appLocales = { br: appBr, th: appTh, bs: appBs, + tr: appTr, + nl: appNl, + uk: appUk, } function placeholders(text: string): string[] { diff --git a/packages/kilo-vscode/tests/unit/custom-provider.test.ts b/packages/kilo-vscode/tests/unit/custom-provider.test.ts index d80c98a7096..5432eada9ad 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test" import { + MASKED_CUSTOM_PROVIDER_KEY, parseCustomProviderSecret, + resolveCustomProviderKey, + resolveCustomProviderAuth, sanitizeCustomProviderConfig, validateProviderID, } from "../../src/shared/custom-provider" @@ -31,6 +34,34 @@ describe("parseCustomProviderSecret", () => { }) }) +describe("resolveCustomProviderAuth", () => { + it("preserves auth when the api key field is unchanged", () => { + expect(resolveCustomProviderAuth(undefined, false)).toEqual({ mode: "preserve" }) + }) + + it("stores a changed api key", () => { + expect(resolveCustomProviderAuth(" sk-test ", true)).toEqual({ mode: "set", key: "sk-test" }) + }) + + it("clears auth when the field was changed to empty", () => { + expect(resolveCustomProviderAuth(undefined, true)).toEqual({ mode: "clear" }) + }) +}) + +describe("resolveCustomProviderKey", () => { + it("returns a masked value for api-backed providers", () => { + expect(resolveCustomProviderKey("api")).toBe(MASKED_CUSTOM_PROVIDER_KEY) + }) + + it("hides non-api auth from the edit form", () => { + expect(resolveCustomProviderKey("oauth")).toBe("") + }) + + it("returns empty when there is no saved key", () => { + expect(resolveCustomProviderKey(undefined)).toBe("") + }) +}) + describe("sanitizeCustomProviderConfig", () => { it("normalizes config and forces the approved package", () => { const result = sanitizeCustomProviderConfig({ diff --git a/packages/kilo-vscode/tests/unit/followup-session.test.ts b/packages/kilo-vscode/tests/unit/followup-session.test.ts new file mode 100644 index 00000000000..bca8e448c13 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/followup-session.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { matchFollowup, recordFollowup } from "../../src/kilo-provider/followup-session" + +describe("followup-session", () => { + it("records a pending follow-up for Start new session replies", () => { + const pending = recordFollowup({ + answers: [["Start new session"]], + dir: "/repo", + now: 1, + }) + + expect(pending).toEqual({ + dir: "/repo", + time: 1, + }) + }) + + it("ignores other question replies", () => { + const pending = recordFollowup({ + answers: [["Continue here"]], + dir: "/repo", + now: 1, + }) + + expect(pending).toBeUndefined() + }) + + it("matches pending follow-ups by normalized directory before expiry", () => { + const pending = { + dir: "c:/repo/.kilo/worktrees/feature", + time: 1, + } + + expect(matchFollowup({ pending, dir: "C:\\repo\\.kilo\\worktrees\\feature\\", now: 2 })).toBe(true) + expect(matchFollowup({ pending, dir: "c:/repo/.kilo/worktrees/other", now: 2 })).toBe(false) + expect(matchFollowup({ pending, dir: "c:/repo/.kilo/worktrees/feature", now: 30_002 })).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts index 3410f5a2ad3..7c59e3d4d0f 100644 --- a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts @@ -37,6 +37,9 @@ import { dict as appNo } from "../../webview-ui/src/i18n/no" import { dict as appBr } from "../../webview-ui/src/i18n/br" import { dict as appTh } from "../../webview-ui/src/i18n/th" import { dict as appBs } from "../../webview-ui/src/i18n/bs" +import { dict as appTr } from "../../webview-ui/src/i18n/tr" +import { dict as appNl } from "../../webview-ui/src/i18n/nl" +import { dict as appUk } from "../../webview-ui/src/i18n/uk" // Layer 2: upstream UI (@opencode-ai/ui re-exported via @kilocode/kilo-ui) import { dict as uiEn } from "../../../ui/src/i18n/en" @@ -55,6 +58,9 @@ import { dict as uiNo } from "../../../ui/src/i18n/no" import { dict as uiBr } from "../../../ui/src/i18n/br" import { dict as uiTh } from "../../../ui/src/i18n/th" import { dict as uiBs } from "../../../ui/src/i18n/bs" +import { dict as uiTr } from "../../../ui/src/i18n/tr" +import { dict as uiNl } from "../../../ui/src/i18n/nl" +import { dict as uiUk } from "../../../ui/src/i18n/uk" // Layer 3: kilo-i18n overrides import { dict as kiloEn } from "../../../kilo-i18n/src/en" @@ -73,9 +79,15 @@ import { dict as kiloNo } from "../../../kilo-i18n/src/no" import { dict as kiloBr } from "../../../kilo-i18n/src/br" import { dict as kiloTh } from "../../../kilo-i18n/src/th" import { dict as kiloBs } from "../../../kilo-i18n/src/bs" +import { dict as kiloTr } from "../../../kilo-i18n/src/tr" +import { dict as kiloNl } from "../../../kilo-i18n/src/nl" +import { dict as kiloUk } from "../../../kilo-i18n/src/uk" // Layer 4: agent manager (locale alignment already tested in agent-manager-i18n-split.test.ts) import { dict as amEn } from "../../webview-ui/agent-manager/i18n/en" +import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr" +import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl" +import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk" // ── Extension-side dictionaries ───────────────────────────────────────────── @@ -95,6 +107,9 @@ import { dict as cliNo } from "../../src/services/cli-backend/i18n/no" import { dict as cliBr } from "../../src/services/cli-backend/i18n/br" import { dict as cliTh } from "../../src/services/cli-backend/i18n/th" import { dict as cliBs } from "../../src/services/cli-backend/i18n/bs" +import { dict as cliTr } from "../../src/services/cli-backend/i18n/tr" +import { dict as cliNl } from "../../src/services/cli-backend/i18n/nl" +import { dict as cliUk } from "../../src/services/cli-backend/i18n/uk" import { dict as acEn } from "../../src/services/autocomplete/i18n/en" @@ -119,6 +134,9 @@ const appLocales: Record> = { br: appBr, th: appTh, bs: appBs, + tr: appTr, + nl: appNl, + uk: appUk, } const kiloLocales: Record> = { @@ -138,6 +156,9 @@ const kiloLocales: Record> = { br: kiloBr, th: kiloTh, bs: kiloBs, + tr: kiloTr, + nl: kiloNl, + uk: kiloUk, } const uiLocales: Record> = { @@ -157,6 +178,9 @@ const uiLocales: Record> = { br: uiBr, th: uiTh, bs: uiBs, + tr: uiTr, + nl: uiNl, + uk: uiUk, } const cliLocales: Record> = { @@ -176,6 +200,9 @@ const cliLocales: Record> = { br: cliBr, th: cliTh, bs: cliBs, + tr: cliTr, + nl: cliNl, + uk: cliUk, } // Merge webview dictionaries in the same priority order as language.tsx diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts new file mode 100644 index 00000000000..27dd0b486b3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "bun:test" +import type { Event, Session } from "@kilocode/sdk/v2/client" + +// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) +const { KiloProvider } = await import("../../src/KiloProvider") + +type Internals = { + webview: { postMessage: (message: unknown) => Promise } | null + trackedSessionIds: Set + currentSession: Session | null + pendingFollowup: { dir: string; time: number } | null + handleLoadMessages: (sessionID: string) => Promise + initializeConnection: () => Promise + syncWebviewState: () => Promise + flushPendingSessionRefresh: () => Promise + fetchAndSendProviders: () => Promise + fetchAndSendAgents: () => Promise + fetchAndSendSkills: () => Promise + fetchAndSendCommands: () => Promise + fetchAndSendConfig: () => Promise + fetchAndSendNotifications: () => Promise + seedSessionStatusMap: () => Promise + sendNotificationSettings: () => void + startStatsPolling: () => void +} + +function created(input: { id: string; directory: string }): Event { + return { + type: "session.created", + properties: { + info: { + id: input.id, + slug: `${input.id}-slug`, + projectID: "project-1", + directory: input.directory, + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }, + }, + } as Event +} + +function connection() { + let filter: ((event: Event) => boolean) | undefined + let listener: ((event: Event) => void) | undefined + + return { + emit(event: Event) { + if (!filter || !listener) throw new Error("expected SSE subscription") + if (!filter(event)) return + listener(event) + }, + connect: async () => {}, + getClient: () => ({}) as never, + onEventFiltered: (next: (event: Event) => boolean, cb: (event: Event) => void) => { + filter = next + listener = cb + return () => undefined + }, + onStateChange: () => () => undefined, + onNotificationDismissed: () => () => undefined, + onClearPendingPrompts: () => () => undefined, + onLanguageChanged: () => () => undefined, + onProfileChanged: () => () => undefined, + onMigrationComplete: () => () => undefined, + onFavoritesChanged: () => () => undefined, + registerDirectoryProvider: () => () => undefined, + getServerInfo: () => ({ port: 12345 }), + getConnectionState: () => "connected" as const, + resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined), + recordMessageSessionId: () => undefined, + notifyNotificationDismissed: () => undefined, + } +} + +describe("KiloProvider follow-up sessions", () => { + it("adopts pending follow-up sessions for single-session views", async () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + const loaded: string[] = [] + + internal.webview = { + postMessage: async (message: unknown) => { + sent.push(message) + return true + }, + } + internal.syncWebviewState = async () => {} + internal.flushPendingSessionRefresh = async () => {} + internal.fetchAndSendProviders = async () => {} + internal.fetchAndSendAgents = async () => {} + internal.fetchAndSendSkills = async () => {} + internal.fetchAndSendCommands = async () => {} + internal.fetchAndSendConfig = async () => {} + internal.fetchAndSendNotifications = async () => {} + internal.seedSessionStatusMap = async () => {} + internal.sendNotificationSettings = () => {} + internal.startStatsPolling = () => {} + + await internal.initializeConnection() + sent.length = 0 + + internal.pendingFollowup = { dir: "/repo", time: Date.now() } + internal.handleLoadMessages = async (sessionID: string) => { + loaded.push(sessionID) + } + + service.emit(created({ id: "ses-followup", directory: "/repo" })) + await Promise.resolve() + + expect(internal.currentSession?.id).toBe("ses-followup") + expect(internal.trackedSessionIds.has("ses-followup")).toBe(true) + expect(loaded).toEqual(["ses-followup"]) + expect(sent).toEqual([ + { + type: "sessionCreated", + session: { + id: "ses-followup", + title: "Session", + createdAt: new Date(1).toISOString(), + updatedAt: new Date(1).toISOString(), + parentID: null, + revert: null, + summary: null, + }, + draftID: undefined, + }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index d74842073d3..9704a172d4a 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -81,6 +81,9 @@ function createConnection(client: ReturnType) { onLanguageChanged: () => () => undefined, onProfileChanged: () => () => undefined, onMigrationComplete: () => () => undefined, + onFavoritesChanged: () => () => undefined, + onClearPendingPrompts: () => () => undefined, + registerDirectoryProvider: () => () => undefined, getServerInfo: () => ({ port: 12345 }), getConnectionState: () => "connected" as const, resolveEventSessionId: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts index 69450d78025..f89e2fc330e 100644 --- a/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts +++ b/packages/kilo-vscode/tests/unit/legacy-migration/messages.test.ts @@ -43,7 +43,7 @@ function sample(): LegacyApiMessage[] { describe("legacy migration messages", () => { it("parses a basic legacy conversation into ordered user and assistant messages with stable ids", () => { - const list = parseMessagesFromConversation(sample(), id, item) + const list = parseMessagesFromConversation(sample(), id, item.workspace, item) expect(list).toHaveLength(2) expect(list[0]?.data.role).toBe("user") @@ -53,7 +53,7 @@ describe("legacy migration messages", () => { }) it("creates valid assistant message metadata for the SDK/backend shape", () => { - const list = parseMessagesFromConversation(sample(), id, item) + const list = parseMessagesFromConversation(sample(), id, item.workspace, item) const msg = list.find((x) => x.data.role === "assistant") expect(msg?.data.role).toBe("assistant") @@ -66,7 +66,7 @@ describe("legacy migration messages", () => { }) it("ignores unsupported legacy entries instead of producing broken messages", () => { - const list = parseMessagesFromConversation(sample(), id, item) + const list = parseMessagesFromConversation(sample(), id, item.workspace, item) expect(list).toHaveLength(2) expect(list.some((x) => x.data.role !== "user" && x.data.role !== "assistant")).toBe(false) @@ -92,6 +92,7 @@ describe("legacy migration messages", () => { }, ], id, + item.workspace, item, ) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts index db2477b265d..0d3d9fa34ea 100644 --- a/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts +++ b/packages/kilo-vscode/tests/unit/legacy-migration/parser.test.ts @@ -15,14 +15,14 @@ const item = { describe("legacy migration parser", () => { it("uses the final deterministic ids expected for migration", () => { const project = createProject(item) - const session = createSession(id, item, project.id) + const session = createSession(id, item, project.id, item.workspace) const msg1 = createMessageID(id, 0) const msg2 = createMessageID(id, 1) const prt1 = createPartID(id, 0, 0) const prt2 = createPartID(id, 1, 0) expect(project.id).toBe(createProject(item).id) - expect(session.id).toBe(createSession(id, item, project.id).id) + expect(session.id).toBe(createSession(id, item, project.id, item.workspace).id) expect(session.id.startsWith("ses_migrated_")).toBe(true) expect(msg1).toBe(createMessageID(id, 0)) expect(msg2).toBe(createMessageID(id, 1)) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/path.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/path.test.ts new file mode 100644 index 00000000000..71e807c1560 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/legacy-migration/path.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" +import * as path from "path" + +const realpath = mock(async (input: string) => input) + +mock.module("fs/promises", () => ({ + realpath, +})) + +const { isWindowsDrivePath, normalizeLegacyPath } = await import("../../../src/legacy-migration/sessions/lib/path") + +describe("legacy migration path", () => { + beforeEach(() => { + realpath.mockReset() + realpath.mockImplementation(async (input: string) => input) + }) + + it("returns an empty string for empty legacy paths", async () => { + expect(await normalizeLegacyPath(" ")).toBe("") + expect(realpath).not.toHaveBeenCalled() + }) + + it("detects Windows drive paths without relying on runtime platform", () => { + expect(isWindowsDrivePath("c:\\repo")).toBe(true) + expect(isWindowsDrivePath("C:/repo")).toBe(true) + expect(isWindowsDrivePath("/repo")).toBe(false) + }) + + it("uppercases the Windows drive letter before resolving the final path", async () => { + realpath.mockImplementation(async (input: string) => input) + + const value = await normalizeLegacyPath("c:/repo/../repo/file.txt") + const expected = path.normalize(path.resolve("c:/repo/../repo/file.txt")) + + expect(realpath).toHaveBeenCalledTimes(1) + expect(realpath.mock.calls[0]?.[0]).toBe(expected.replace(/^c:/, "C:")) + expect(value).toBe(expected.replace(/^c:/, "C:")) + }) + + it("falls back to the normalized path when realpath fails", async () => { + realpath.mockRejectedValueOnce(new Error("missing")) + + const input = "C:/repo/./child" + const value = await normalizeLegacyPath(input) + + expect(value).toBe(path.normalize(path.resolve(input))) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts b/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts index 7764b88ea4d..4faceb315cb 100644 --- a/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts +++ b/packages/kilo-vscode/tests/unit/legacy-migration/session.test.ts @@ -13,6 +13,7 @@ describe("legacy migration session", () => { mode: "code", }, "project-1", + "/workspace/testing", ) expect(session.projectID).toBe("project-1") @@ -24,15 +25,15 @@ describe("legacy migration session", () => { }) it("creates a deterministic session id from the legacy task id", () => { - const a = createSession("legacy-task-1", undefined, "project-1") - const b = createSession("legacy-task-1", undefined, "project-1") + const a = createSession("legacy-task-1", undefined, "project-1", "") + const b = createSession("legacy-task-1", undefined, "project-1", "") expect(a.id).toBe(b.id) expect(a.id.startsWith("ses_migrated_")).toBe(true) }) it("falls back to the legacy id as title when task metadata is missing", () => { - const session = createSession("legacy-task-1", undefined, "project-1") + const session = createSession("legacy-task-1", undefined, "project-1", "") expect(session.slug).toBe("legacy-task-1") expect(session.title).toBe("legacy-task-1") diff --git a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts new file mode 100644 index 00000000000..d5f9dc532de --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "bun:test" +import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../webview-ui/src/utils/prompt-drafts" + +describe("sessionDraftKey", () => { + it("prefixes session ids", () => { + expect(sessionDraftKey("abc")).toBe("session:abc") + }) + + it("returns undefined when no id is present", () => { + expect(sessionDraftKey()).toBeUndefined() + }) +}) + +describe("pendingDraftKey", () => { + it("prefixes pending ids", () => { + expect(pendingDraftKey("pending:1")).toBe("pending:1") + }) + + it("returns undefined when no id is present", () => { + expect(pendingDraftKey()).toBeUndefined() + }) +}) + +describe("scopeDraftKey", () => { + it("scopes raw keys to a prompt box", () => { + expect(scopeDraftKey("prompt:1", "session:abc")).toBe("prompt:1:session:abc") + }) + + it("falls back to an empty key when raw key is missing", () => { + expect(scopeDraftKey("prompt:1")).toBe("prompt:1:empty") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts new file mode 100644 index 00000000000..2e7dbfe0684 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test" +import { fetchProviderData, saveCustomProvider } from "../../src/provider-actions" + +function createCtx() { + const calls = { + set: [] as Array<{ providerID: string; auth: { type: string; key: string } }>, + remove: [] as Array<{ providerID: string }>, + posts: [] as unknown[], + config: [] as unknown[], + cached: [] as unknown[], + refresh: 0, + dispose: 0, + } + + const ctx = { + client: { + auth: { + set: async (input: { providerID: string; auth: { type: string; key: string } }) => { + calls.set.push(input) + return { data: true } + }, + remove: async (input: { providerID: string }) => { + calls.remove.push(input) + return { data: true } + }, + }, + global: { + config: { + get: async () => ({ data: { disabled_providers: [] } }), + update: async (input: unknown) => { + calls.config.push(input) + return { data: input } + }, + }, + }, + }, + postMessage: (message: unknown) => calls.posts.push(message), + getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + workspaceDir: "/tmp", + disposeGlobal: async () => { + calls.dispose += 1 + }, + fetchAndSendProviders: async () => { + calls.refresh += 1 + }, + } as unknown as Parameters[0] + + return { + calls, + ctx, + setCachedConfig: (message: unknown) => calls.cached.push(message), + } +} + +function createProvider() { + return { + name: "My Provider", + options: { baseURL: "https://example.com/v1" }, + models: { + "model-1": { name: "Model One" }, + }, + } +} + +describe("saveCustomProvider", () => { + it("preserves auth when the api key field is unchanged", async () => { + const { ctx, calls, setCachedConfig } = createCtx() + + await saveCustomProvider(ctx, "req", "myprovider", createProvider(), undefined, false, null, setCachedConfig) + + expect(calls.set).toHaveLength(0) + expect(calls.remove).toHaveLength(0) + expect(calls.refresh).toBe(1) + }) + + it("clears auth when the api key field was intentionally cleared", async () => { + const { ctx, calls, setCachedConfig } = createCtx() + + await saveCustomProvider(ctx, "req", "myprovider", createProvider(), undefined, true, null, setCachedConfig) + + expect(calls.set).toHaveLength(0) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + }) + + it("stores a changed api key", async () => { + const { ctx, calls, setCachedConfig } = createCtx() + + await saveCustomProvider(ctx, "req", "myprovider", createProvider(), " sk-test ", true, null, setCachedConfig) + + expect(calls.remove).toHaveLength(0) + expect(calls.set).toEqual([{ providerID: "myprovider", auth: { type: "api", key: "sk-test" } }]) + }) +}) + +describe("fetchProviderData", () => { + it("derives api auth state and strips keys from provider payloads", async () => { + const client = { + provider: { + list: async () => ({ + data: { + all: [ + { + id: "groq-test", + name: "Groq Test", + source: "config", + key: "sk-test", + env: [], + models: {}, + }, + ], + connected: ["groq-test"], + default: { "groq-test": "llama-3.1-8b-instant" }, + }, + }), + auth: async () => ({ data: {} }), + }, + } as unknown as Parameters[0] + + const result = await fetchProviderData(client, "/tmp") + const item = result.response.all[0] as Record + + expect(result.authStates).toEqual({ "groq-test": "api" }) + expect("key" in item).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/question-handler.test.ts b/packages/kilo-vscode/tests/unit/question-handler.test.ts new file mode 100644 index 00000000000..3353d6586a8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/question-handler.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { handleQuestionReject, handleQuestionReply } from "../../src/kilo-provider/handlers/question" + +describe("question handlers", () => { + it("routes replies using the question session when provided", async () => { + const calls: Array> = [] + const client = { + question: { + reply: async (input: Record) => { + calls.push(input) + return true + }, + reject: async () => true, + }, + } as unknown as KiloClient + + const ok = await handleQuestionReply( + { + client, + currentSessionId: "ses-root", + postMessage() {}, + getWorkspaceDirectory(sessionId) { + return sessionId ? `/repo/${sessionId}` : "/repo" + }, + }, + "req-1", + [["Start new session"]], + "ses-worktree", + ) + + expect(ok).toBe(true) + expect(calls).toEqual([ + { + requestID: "req-1", + answers: [["Start new session"]], + directory: "/repo/ses-worktree", + }, + ]) + }) + + it("falls back to the current session when no question session is provided", async () => { + const calls: Array> = [] + const client = { + question: { + reply: async (input: Record) => { + calls.push(input) + return true + }, + reject: async () => true, + }, + } as unknown as KiloClient + + const ok = await handleQuestionReply( + { + client, + currentSessionId: "ses-root", + postMessage() {}, + getWorkspaceDirectory(sessionId) { + return sessionId ? `/repo/${sessionId}` : "/repo" + }, + }, + "req-2", + [["Continue here"]], + ) + + expect(ok).toBe(true) + expect(calls[0]?.directory).toBe("/repo/ses-root") + }) + + it("routes rejects using the question session when provided", async () => { + const calls: Array> = [] + const client = { + question: { + reply: async () => true, + reject: async (input: Record) => { + calls.push(input) + return true + }, + }, + } as unknown as KiloClient + + const ok = await handleQuestionReject( + { + client, + currentSessionId: "ses-root", + postMessage() {}, + getWorkspaceDirectory(sessionId) { + return sessionId ? `/repo/${sessionId}` : "/repo" + }, + }, + "req-3", + "ses-worktree", + ) + + expect(ok).toBe(true) + expect(calls).toEqual([ + { + requestID: "req-3", + directory: "/repo/ses-worktree", + }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/welcome-with-switcher-and-notification-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/welcome-with-switcher-and-notification-chromium-linux.png index 38729c2837b..552d9396935 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/welcome-with-switcher-and-notification-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/welcome-with-switcher-and-notification-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bffa5ba48085a9df0245c2067dc316d2574b974a0d107c0ec64f1bfb051c98e7 -size 27621 +oid sha256:00c00caa93376d82f4128001416fe14ee71f786f0bff3d6550dc97ff9a675037 +size 27167 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 08917a8ecc4..49d7728424e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1084,20 +1084,24 @@ const AgentManagerContent: Component = () => { } window.addEventListener("focus", onWindowFocus) - // When a session is created while on local, replace the current pending tab with the real session. - // Guard against duplicate sessionCreated events (HTTP response + SSE can both fire). + // When a session is created, add it as a local tab. This handles both direct + // creation from the prompt input and backend-created follow-up sessions (plan + // follow-up "Start new session"). Guard against duplicates (HTTP + SSE can both fire). const unsubCreate = vscode.onMessage((msg) => { - if (msg.type === "sessionCreated" && selection() === LOCAL) { - const created = msg as { type: string; session: { id: string } } - if (localSessionIDs().includes(created.session.id)) return - const pending = activePendingId() - if (pending) { - setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? created.session.id : id))) - setActivePendingId(undefined) - } else { - setLocalSessionIDs((prev) => [...prev, created.session.id]) - } + if (msg.type !== "sessionCreated") return + const created = msg as { type: string; session: { id: string } } + if (localSessionIDs().includes(created.session.id)) return + if (worktreeSessionIds().has(created.session.id)) return + const pending = selection() === LOCAL ? activePendingId() : undefined + if (pending) { + setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? created.session.id : id))) + setActivePendingId(undefined) + } else { + saveTabMemory() + setLocalSessionIDs((prev) => [...prev, created.session.id]) + setSelection(LOCAL) } + session.selectSession(created.session.id) }) // Mark sessions loaded as soon as the session context receives data (even if empty) @@ -1157,6 +1161,8 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.sessionAdded") { const ev = msg as { type: string; sessionId: string; worktreeId: string } + saveTabMemory() + setSelection(ev.worktreeId) session.selectSession(ev.sessionId) } @@ -2334,7 +2340,9 @@ const AgentManagerContent: Component = () => { onCommitRename={() => commitRename(wt.id)} onCancelRename={cancelRename} onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)} - onCopyPath={() => navigator.clipboard.writeText(wt.path)} + onCopyPath={() => + vscode.postMessage({ type: "agentManager.copyToClipboard", text: wt.path }) + } onOpen={() => vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }) } @@ -2769,6 +2777,8 @@ const AgentManagerContent: Component = () => { }} readonly={readOnly()} continueInWorktree={selection() === LOCAL} + promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} + pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 18a62cdeb4e..7e499351035 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -1,6 +1,6 @@ // New Worktree dialog — prompt, versions, model, mode, import tab -import { Component, For, Show, createSignal, createMemo, onMount, onCleanup } from "solid-js" +import { Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js" import type { AgentManagerBranchesMessage, AgentManagerImportResultMessage, BranchInfo } from "../src/types/messages" import { Dialog } from "@kilocode/kilo-ui/dialog" import { showToast } from "@kilocode/kilo-ui/toast" @@ -22,7 +22,7 @@ import { allocationsToArray, } from "./MultiModelSelector" import { useLanguage } from "../src/context/language" -import { useImageAttachments } from "../src/hooks/useImageAttachments" +import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments" import { convertToMentionPath } from "../src/utils/path-mentions" import { BranchSelect } from "./BranchSelect" @@ -107,11 +107,23 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran adjustHeight() }) + // Restore cached images from webview state + const cachedImages = cached?.advancedDialogImages as ImageAttachment[] | undefined + if (cachedImages?.length) imageAttach.replace(cachedImages) + const persistPrompt = (value: string) => { const state = vscode.getState>() ?? {} vscode.setState({ ...state, advancedDialogPrompt: value || undefined }) } + const persistImages = (imgs: ImageAttachment[]) => { + const state = vscode.getState>() ?? {} + vscode.setState({ ...state, advancedDialogImages: imgs.length > 0 ? imgs : undefined }) + } + + // Auto-persist images to webview state on any change + createEffect(() => persistImages(imageAttach.images())) + let textareaRef: HTMLTextAreaElement | undefined onMount(() => { @@ -167,6 +179,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran }) persistPrompt("") + persistImages([]) props.onClose() } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts new file mode 100644 index 00000000000..ae8f2cd143f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -0,0 +1,173 @@ +export const dict = { + "agentManager.local": "локальний", + "agentManager.section.worktrees": "РОБОЧІ ДЕРЕВА", + "agentManager.section.sessions": "СЕСІЇ", + "agentManager.notGitRepo": "Не є git-репозиторієм", + + "agentManager.worktree.settings": "Налаштування робочого дерева", + "agentManager.worktree.new": "Нове робоче дерево", + "agentManager.worktree.setupScript": "Скрипт налаштування робочого дерева", + "agentManager.worktree.delete": "Видалити робоче дерево", + "agentManager.worktree.confirmDelete": "Видалити?", + "agentManager.worktree.stale": "Застаріле", + "agentManager.worktree.staleTooltip": "Відсутнє на диску або більше не відстежується як робоче дерево Git", + "agentManager.worktree.removeStale": "Видалити застаріле робоче дерево", + "agentManager.worktree.doubleClickRename": "Двічі клацніть для перейменування", + "agentManager.worktree.versions": "{{count}} версій", + "agentManager.worktree.advancedOptions": "Розширені параметри робочого дерева", + "agentManager.worktree.defaultBaseBranch": "Гілка за замовчуванням", + "agentManager.worktree.defaultBaseBranchAuto": "Автоматичне визначення", + "agentManager.worktree.copyPath": "Копіювати шлях", + "agentManager.worktree.openInVscode": "Відкрити у VS Code", + "agentManager.worktree.rename": "Перейменувати", + + "agentManager.hoverCard.branch": "ГІЛКА", + "agentManager.hoverCard.base": "База", + "agentManager.hoverCard.sessions": "Сесії", + "agentManager.hoverCard.files": "Файли", + "agentManager.hoverCard.changes": "Зміни", + "agentManager.hoverCard.commits": "Коміти", + + "agentManager.session.new": "Нова сесія", + "agentManager.session.untitled": "Без назви", + "agentManager.session.newSession": "Нова сесія", + "agentManager.session.openInWorktree": "Відкрити у робочому дереві", + "agentManager.session.openLocally": "Відкрити локально", + "agentManager.session.readonly": "Сесія лише для читання", + "agentManager.session.noSessions": "Немає відкритих сесій", + + "agentManager.tab.close": "Закрити", + "agentManager.tab.closeTab": "Закрити вкладку", + "agentManager.tab.forkSession": "Розгалужити сесію", + "agentManager.tab.terminal": "Термінал", + "agentManager.tab.openTerminal": "Відкрити термінал", + + "agentManager.setup.failed": "Налаштування робочого дерева не вдалося", + "agentManager.setup.settingUp": "Налаштування робочого дерева", + "agentManager.setup.error.git_not_found": + "Git не встановлено або не знайдено в PATH. Будь ласка, встановіть Git і перезапустіть VS Code.", + "agentManager.setup.error.not_git_repo": + "Відкрийте папку, що містить git-репозиторій, щоб використовувати робочі дерева.", + "agentManager.setup.error.lfs_missing": + "Цей репозиторій використовує Git LFS, але git-lfs не знайдено. Будь ласка, встановіть Git LFS.", + "agentManager.shortcuts.title": "Клавіатурні скорочення", + "agentManager.shortcuts.category.sidebar": "Бічна панель", + "agentManager.shortcuts.category.tabs": "Вкладки", + "agentManager.shortcuts.category.terminal": "Термінал", + "agentManager.shortcuts.category.global": "Глобальні", + "agentManager.shortcuts.previousItem": "Попередній елемент", + "agentManager.shortcuts.nextItem": "Наступний елемент", + "agentManager.shortcuts.newWorktree": "Нове робоче дерево", + "agentManager.shortcuts.openWorktree": "Відкрити робоче дерево", + "agentManager.shortcuts.advancedWorktree": "Розширене робоче дерево", + "agentManager.shortcuts.deleteWorktree": "Видалити робоче дерево", + "agentManager.shortcuts.previousTab": "Попередня вкладка", + "agentManager.shortcuts.nextTab": "Наступна вкладка", + "agentManager.shortcuts.newTab": "Нова вкладка", + "agentManager.shortcuts.closeTab": "Закрити вкладку", + "agentManager.shortcuts.toggleTerminal": "Перемкнути термінал", + "agentManager.shortcuts.openAgentManager": "Відкрити Agent Manager", + "agentManager.shortcuts.cycleAgentMode": "Наступний режим агента", + "agentManager.shortcuts.cyclePreviousAgentMode": "Попередній режим агента", + "agentManager.shortcuts.showShortcuts": "Показати клавіатурні скорочення", + + "agentManager.dialog.removeStaleWorktree.title": "Видалити застаріле робоче дерево", + "agentManager.dialog.removeStaleWorktree.messagePre": "Видалити застаріле робоче дерево ", + "agentManager.dialog.removeStaleWorktree.messagePost": + "? Це лише видалить відображення в Agent Manager і не торкнеться файлів на диску.", + "agentManager.dialog.removeStaleWorktree.cancel": "Скасувати", + "agentManager.dialog.removeStaleWorktree.confirm": "Видалити застаріле робоче дерево", + + "agentManager.dialog.openWorktree": "Нове робоче дерево", + "agentManager.dialog.tab.new": "Нове", + "agentManager.dialog.tab.import": "Імпортувати", + "agentManager.dialog.namePlaceholder": "Назва робочого дерева (необов'язково)", + "agentManager.dialog.promptPlaceholder.mac": "Введіть повідомлення (\u2318Enter для надсилання)", + "agentManager.dialog.promptPlaceholder.other": "Введіть повідомлення (Ctrl+Enter для надсилання)", + "agentManager.dialog.advancedOptions": "Розширені параметри", + "agentManager.dialog.branchName": "Назва гілки", + "agentManager.dialog.branchNamePlaceholder": "автоматично згенерована", + "agentManager.dialog.baseBranch": "Базова гілка", + "agentManager.dialog.searchBranches": "Пошук гілок...", + "agentManager.dialog.branchBadge.default": "за замовчуванням", + "agentManager.dialog.branchBadge.remote": "віддалена", + "agentManager.dialog.versions": "Версії", + "agentManager.dialog.versionHint": "{{count}} робочих дерев виконуватимуться паралельно", + "agentManager.dialog.compareModels": "Порівняти моделі", + "agentManager.dialog.compareModels.tooltip": + "Запустіть агентів на різних моделях паралельно для порівняння результатів", + "agentManager.dialog.compareModels.searchModels": "Пошук моделей...", + "agentManager.dialog.compareModels.selectModels": "Оберіть моделі...", + "agentManager.dialog.creating": "Створення...", + "agentManager.dialog.createWorktree": "Створити робоче дерево", + "agentManager.dialog.removeImage": "Видалити зображення", + "agentManager.dialog.advanced": "Розширено...", + + "agentManager.diff.toggle": "Показати або приховати diff", + "agentManager.diff.openFile": "Відкрити файл", + "agentManager.open.button": "Відкрити", + "agentManager.open.tooltip": "Відкрити це робоче дерево у VS Code", + "agentManager.apply.button": "Застосувати локально", + "agentManager.apply.globalButton": "Застосувати", + "agentManager.apply.tooltip": "Застосувати вибрані зміни робочого дерева до локальної гілки", + "agentManager.apply.dialogTitle": "Застосувати зміни до локальної гілки", + "agentManager.apply.confirm": "Застосувати зараз", + "agentManager.apply.retry": "Повторити застосування", + "agentManager.apply.previewTitle": "Застосувати ці зміни до локальної гілки?", + "agentManager.apply.previewSummary": "{{files}} файлів, +{{additions}} -{{deletions}}", + "agentManager.apply.selectionSummary": "{{selected}} з {{total}} файлів вибрано", + "agentManager.apply.selectAll": "Вибрати все", + "agentManager.apply.selectNone": "Зняти вибір", + "agentManager.apply.moreFiles": "+{{count}} ще", + "agentManager.apply.moreReasons": "{{reason}} (+{{count}} ще)", + "agentManager.apply.checking": "Перевірка...", + "agentManager.apply.applying": "Застосування...", + "agentManager.apply.success": "Зміни застосовано", + "agentManager.apply.conflict": "Під час застосування виникли конфлікти", + "agentManager.apply.conflictToast": + "{{count}} конфліктів у {{files}} файлах. Перегляньте деталі в розділі Застосувати.", + "agentManager.apply.error": "Застосування не вдалося", + "agentManager.apply.conflictsTitle": "Конфлікти", + "agentManager.apply.unknownFile": "Нерозв'язаний файл", + "agentManager.apply.reason.index": "Локальний файл відрізняється від очікуваної версії", + "agentManager.apply.reason.patch": "Патч неможливо застосувати без конфліктів", + "agentManager.apply.reason.contents": "Неможливо прочитати вміст поточного локального файлу", + "agentManager.apply.reason.unknown": "Виявлено конфлікт", + "agentManager.apply.inline.success": "Застосовано", + "agentManager.apply.inline.conflict": "Конфлікти", + "agentManager.apply.inline.error": "Не вдалося", + "agentManager.shortcuts.toggleDiff": "Показати або приховати панель diff", + "agentManager.shortcuts.category.quickSwitch": "Швидке перемикання", + "agentManager.shortcuts.jumpToItem": "Перейти до елемента 1\u20139", + "agentManager.review.sendAllToChat": "Надіслати все до чату", + "agentManager.review.sendAllToChatWithCount": "Надіслати все до чату ({{count}})", + "agentManager.review.sendAllShortcut.mac": "⌘Enter", + "agentManager.review.sendAllShortcut.other": "Ctrl+Enter", + "agentManager.review.modalTitle": "Перегляд коментаря", + "agentManager.review.inlineCount": "Переглянути коментарі ({{count}})", + "agentManager.review.clearAll": "Очистити все", + "agentManager.review.commentOnLine": "Коментар до рядка {{line}}", + "agentManager.review.editCommentOnLine": "Редагувати коментар до рядка {{line}}", + "agentManager.review.commentPlaceholder": "Залишити коментар...", + "agentManager.review.commentAction": "Коментувати", + "agentManager.review.sendToChat": "Надіслати до чату", + "agentManager.review.metaFile": "Файл", + "agentManager.review.metaLine": "Рядок", + "agentManager.review.metaComment": "Коментар користувача", + "agentManager.review.collapsedOnly": "{{count}} згорнуто", + "agentManager.review.collapsedWithLarge": "{{collapsed}} згорнуто, {{large}} великих", + "agentManager.review.largeFileCollapsed": "Великий файл (згорнуто)", + "agentManager.review.endOfLongDiff": "Ви дійшли до кінця!", + + "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pastePrUrl": "Вставте URL PR...", + "agentManager.import.open": "Відкрити", + "agentManager.import.branches": "Гілки", + "agentManager.import.selectBranch": "Оберіть гілку...", + "agentManager.import.loading": "Завантаження...", + "agentManager.import.loadingBranches": "Завантаження гілок...", + "agentManager.import.noMatchingBranches": "Відповідних гілок не знайдено", + "agentManager.import.noBranchesFound": "Гілок не знайдено.", + "agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.", + "agentManager.import.failed": "Імпортування не вдалося", +} diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 8e4c2806f71..ba72dfc8d8c 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -225,12 +225,13 @@ const AppContent: Component = () => { }> + }> setCurrentView("history")} continueInWorktree + promptBoxId="sidebar:new-task" /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 5b4b562d4d5..3f9f0019f1f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -27,6 +27,8 @@ interface ChatViewProps { readonly?: boolean /** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */ continueInWorktree?: boolean + promptBoxId?: string + pendingSessionID?: string } export const ChatView: Component = (props) => { @@ -214,7 +216,7 @@ export const ChatView: Component = (props) => {
- + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 1951792a7f5..feaca6ac761 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -3,7 +3,7 @@ * Text input with send/abort buttons, ghost-text autocomplete, and @ file mention support */ -import { Component, createSignal, createEffect, on, For, Index, onCleanup, Show, untrack } from "solid-js" +import { createSignal, createEffect, on, For, Index, onCleanup, Show, untrack, type Component } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { Dialog } from "@kilocode/kilo-ui/dialog" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -21,17 +21,19 @@ import { ThinkingSelector } from "../shared/ThinkingSelector" import { useFileMention } from "../../hooks/useFileMention" import { useSlashCommand } from "../../hooks/useSlashCommand" import { useGhostText } from "../../hooks/useGhostText" -import { useImageAttachments } from "../../hooks/useImageAttachments" +import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments" import { convertToMentionPath } from "../../utils/path-mentions" import { usePromptHistory } from "../../hooks/usePromptHistory" import { WandSparkles } from "@kilocode/kilo-ui/lucide" import { fileName, dirName, buildHighlightSegments, atEnd } from "./prompt-input-utils" import type { ReviewComment, TextPart } from "../../types/messages" import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown" +import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts" // Per-session input text storage (module-level so it survives remounts) const drafts = new Map() const reviewDrafts = new Map() +const imageDrafts = new Map() function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] { if (incoming.length === 0) return current @@ -44,6 +46,8 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[] interface PromptInputProps { blocked?: () => boolean + boxId?: string + pendingSessionID?: string } export const PromptInput: Component = (props) => { @@ -78,7 +82,20 @@ export const PromptInput: Component = (props) => { }) const history = usePromptHistory() - const sessionKey = () => session.currentSessionID() ?? "__new__" + const boxKey = () => props.boxId ?? "prompt:default" + const rawKey = () => + sessionDraftKey(session.currentSessionID()) ?? + pendingDraftKey(props.pendingSessionID ?? session.draftSessionID()) ?? + "new" + const draftKey = () => scopeDraftKey(boxKey(), rawKey()) + const saveDraft = (key: string, next: string, comments: ReviewComment[], imgs: ImageAttachment[]) => { + if (next) drafts.set(key, next) + else drafts.delete(key) + if (comments.length > 0) reviewDrafts.set(key, comments) + else reviewDrafts.delete(key) + if (imgs.length > 0) imageDrafts.set(key, imgs) + else imageDrafts.delete(key) + } const [text, setText] = createSignal("") const [reviewComments, setReviewComments] = createSignal([]) @@ -91,10 +108,10 @@ export const PromptInput: Component = (props) => { const replaceReviewComments = (next: ReviewComment[]) => { setReviewComments(next) if (next.length === 0) { - reviewDrafts.delete(sessionKey()) + reviewDrafts.delete(draftKey()) return } - reviewDrafts.set(sessionKey(), next) + reviewDrafts.set(draftKey(), next) } const clearReviewComments = () => replaceReviewComments([]) @@ -156,19 +173,17 @@ export const PromptInput: Component = (props) => { let dropdownRef: HTMLDivElement | undefined let slashDropdownRef: HTMLDivElement | undefined // Save/restore input text when switching sessions. - // Uses `on()` to track only sessionKey — avoids re-running on every keystroke. + // Uses `on()` to track only draftKey — avoids re-running on every keystroke. createEffect( - on(sessionKey, (key, prev) => { + on(draftKey, (key, prev) => { if (prev !== undefined && prev !== key) { - drafts.set(prev, untrack(text)) - const pending = untrack(reviewComments) - if (pending.length > 0) reviewDrafts.set(prev, pending) - else reviewDrafts.delete(prev) + saveDraft(prev, untrack(text), untrack(reviewComments), untrack(imageAttach.images)) } const draft = drafts.get(key) ?? "" const pending = reviewDrafts.get(key) ?? [] setText(draft) setReviewComments(pending) + imageAttach.replace(imageDrafts.get(key) ?? []) setEnhancing(false) preEnhanceText = null history.reset() @@ -208,10 +223,13 @@ export const PromptInput: Component = (props) => { // Start a new task, carrying over the current prompt text (without auto-sending it) const onNewTaskRequest = () => { - const prompt = text().trim() - // Pre-populate the draft for the new (empty) session so the effect restores it - if (prompt) drafts.set("__new__", prompt) + const draft = text().trim() + const comments = reviewComments() + const imgs = imageAttach.images() session.clearCurrentSession() + // After clearing, draftKey() points to the "new" bucket — save there + // so the session-switch effect restores the prompt in the new-task view. + saveDraft(draftKey(), draft, comments, imgs) } window.addEventListener("newTaskRequest", onNewTaskRequest) onCleanup(() => window.removeEventListener("newTaskRequest", onNewTaskRequest)) @@ -288,8 +306,11 @@ export const PromptInput: Component = (props) => { const failed = message as import("../../types/messages").SendMessageFailedMessage // Only restore draft if the failure is for the current session and the // input is empty (user hasn't started typing something new). - const target = failed.sessionID ?? "__new__" - if (target === sessionKey() && !text().trim() && imageAttach.images().length === 0) { + const target = scopeDraftKey( + boxKey(), + sessionDraftKey(failed.sessionID) ?? pendingDraftKey(failed.draftID) ?? "new", + ) + if (target === draftKey() && !text().trim() && imageAttach.images().length === 0) { if (failed.text) { setText(failed.text) if (textareaRef) { @@ -306,7 +327,27 @@ export const PromptInput: Component = (props) => { mime: f.mime, dataUrl: f.url, })) - if (images.length > 0) imageAttach.replace(images) + if (images.length > 0) { + imageAttach.replace(images) + imageDrafts.set(target, images) + } + } + } + + if (message.type === "sessionCreated" && message.draftID) { + const target = scopeDraftKey(boxKey(), pendingDraftKey(message.draftID) ?? "new") + const next = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id) ?? "new") + const draft = drafts.get(target) + const pending = reviewDrafts.get(target) + const imgs = imageDrafts.get(target) + if (draft !== undefined) drafts.set(next, draft) + if (pending) reviewDrafts.set(next, pending) + if (imgs) imageDrafts.set(next, imgs) + drafts.delete(target) + reviewDrafts.delete(target) + imageDrafts.delete(target) + if (!session.currentSessionID() && (props.pendingSessionID ?? session.draftSessionID()) === message.draftID) { + session.setDraftSessionID(message.session.id) } } @@ -316,7 +357,7 @@ export const PromptInput: Component = (props) => { if (message.type === "enhancePromptResult") { const result = message as import("../../types/messages").EnhancePromptResultMessage - if (result.requestId === `enhance-${sessionKey()}-${enhanceCounter}`) { + if (result.requestId === `enhance-${draftKey()}-${enhanceCounter}`) { setText(result.text) setEnhancing(false) if (textareaRef) { @@ -329,7 +370,7 @@ export const PromptInput: Component = (props) => { if (message.type === "enhancePromptError") { const result = message as import("../../types/messages").EnhancePromptErrorMessage - if (result.requestId === `enhance-${sessionKey()}-${enhanceCounter}`) { + if (result.requestId === `enhance-${draftKey()}-${enhanceCounter}`) { setEnhancing(false) } } @@ -337,11 +378,7 @@ export const PromptInput: Component = (props) => { onCleanup(() => { // Persist current draft before unmounting - const current = text() - if (current) drafts.set(sessionKey(), current) - const pending = reviewComments() - if (pending.length > 0) reviewDrafts.set(sessionKey(), pending) - else reviewDrafts.delete(sessionKey()) + saveDraft(draftKey(), text(), reviewComments(), imageAttach.images()) unsubscribe() }) @@ -508,7 +545,7 @@ export const PromptInput: Component = (props) => { preEnhanceText = text() enhanceCounter++ setEnhancing(true) - vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${sessionKey()}-${enhanceCounter}` }) + vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${draftKey()}-${enhanceCounter}` }) } const handleSend = () => { @@ -530,7 +567,9 @@ export const PromptInput: Component = (props) => { imageAttach.clear() mention.closeMention() slash.close() - drafts.delete(sessionKey()) + drafts.delete(draftKey()) + reviewDrafts.delete(draftKey()) + imageDrafts.delete(draftKey()) if (textareaRef) textareaRef.style.height = "auto" matched.action() return @@ -549,13 +588,15 @@ export const PromptInput: Component = (props) => { const sel = session.selected() const attachments = allFiles.length > 0 ? allFiles : undefined + const key = draftKey() + const pendingId = props.pendingSessionID ?? session.draftSessionID() // Server-side slash command (cmdMatch/matched already computed above) if (matched) { const rest = draft.slice(cmdMatch![0].length).trim() const args = review && rest ? `${review}\n\n${rest}` : rest || review - session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments) + session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId) } else { - session.sendMessage(message, sel?.providerID, sel?.modelID, attachments) + session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId) } history.append(draft) @@ -565,7 +606,9 @@ export const PromptInput: Component = (props) => { imageAttach.clear() mention.closeMention() slash.close() - drafts.delete(sessionKey()) + drafts.delete(key) + reviewDrafts.delete(key) + imageDrafts.delete(key) if (textareaRef) textareaRef.style.height = "auto" } diff --git a/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx b/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx index abad016a248..7900ef1de3f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx @@ -548,7 +548,7 @@ const MigrationWizard: Component = (props) => { 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 c531abe2de3..abd9e2943d1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx @@ -220,6 +220,29 @@ const AboutKiloCodeTab: Component = (props) => {

+ {/* Telemetry */} +
+

{language.t("settings.aboutKiloCode.telemetry.title")}

+

+ {language.t("settings.aboutKiloCode.telemetry.description")} +

+ +
+ {/* CLI Server */}

{language.t("settings.aboutKiloCode.cliServer")}

diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index 6add99ed13f..4a7908d5c64 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -14,6 +14,7 @@ import { useProvider } from "../../context/provider" import { useVSCode } from "../../context/vscode" import type { ExtensionMessage, ProviderConfig } from "../../types/messages" import { createProviderAction } from "../../utils/provider-action" +import { MASKED_CUSTOM_PROVIDER_KEY, resolveCustomProviderKey } from "../../../../src/shared/custom-provider" const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" @@ -219,11 +220,17 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { return entries.map(([key, value]) => ({ key, value })) } + const auth = props.existing?.config?.env?.length + ? undefined + : props.existing + ? provider.authStates()[props.existing.providerID] + : undefined + const [form, setForm] = createStore({ providerID: props.existing?.providerID ?? "", name: props.existing?.name ?? "", baseURL: (props.existing?.config?.options as { baseURL?: string } | undefined)?.baseURL ?? "", - apiKey: "", + apiKey: resolveCustomProviderKey(auth), models: initModels(), headers: initHeaders(), saving: false, @@ -236,6 +243,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { models: form.models.map(() => ({})), headers: form.headers.map(() => ({})), }) + const [apiTouched, setApiTouched] = createSignal(false) // ── Fetch models state ────────────────────────────────────────────── @@ -271,7 +279,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { // (including setForm("models", ...)) invalidates effects that read from // the same store, causing unwanted re-runs that wipe the model picker. const [fetchURL, setFetchURL] = createSignal(form.baseURL) - const [fetchKey, setFetchKey] = createSignal(form.apiKey) + const [fetchKey, setFetchKey] = createSignal("") let fetchVersion = 0 createEffect(() => { @@ -400,7 +408,8 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { if (picked.length === 0) return // Replace the single empty row or append - const empty = form.models.length === 1 && !form.models[0].id.trim() && !form.models[0].name.trim() + const row = form.models[0] + const empty = form.models.length === 1 && !!row && !row.id.trim() && !row.name.trim() const merged = empty ? picked : [...form.models, ...picked] setForm("models", merged) @@ -477,7 +486,8 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { type: "saveCustomProvider", providerID: result.providerID, config: result.config, - apiKey: result.key, + apiKey: apiTouched() ? result.key : undefined, + apiKeyChanged: apiTouched(), }, { onConnected: () => { @@ -589,8 +599,10 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { description={language.t("provider.custom.field.apiKey.description")} value={form.apiKey} onChange={(v) => { - setForm("apiKey", v) - setFetchKey(v) + const key = !apiTouched() && form.apiKey === MASKED_CUSTOM_PROVIDER_KEY ? v.replace(/^\*+/, "") : v + setApiTouched(true) + setForm("apiKey", key) + setFetchKey(key) }} />
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index 3cd4af62786..55f797b5a9c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -1,6 +1,9 @@ -import { Component, Show, For } from "solid-js" +import { Component, Show, For, useContext } from "solid-js" import type { EnrichedModel } from "../../context/provider" +import { SessionContext } from "../../context/session" import { Markdown } from "@kilocode/kilo-ui/markdown" +import { Icon } from "@kilocode/kilo-ui/icon" +import { useLanguage } from "../../context/language" import { sanitizeName } from "./model-selector-utils" import { fmtPrice } from "./model-preview-utils" @@ -29,6 +32,8 @@ const MODALITY_LABELS: Record = { export const ModelPreview: Component = (props) => { const m = () => props.model + const session = useContext(SessionContext) + const language = useLanguage() return (
@@ -50,10 +55,31 @@ export const ModelPreview: Component = (props) => { Free - {/* Header — name + provider */} + {/* Header — name + provider + star */}
{sanitizeName(model().name)} + + {(() => { + const starred = () => + session! + .favoriteModels() + .some((f) => f.providerID === model().providerID && f.modelID === model().id) + return ( + + ) + })()} +
{model().providerName}
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 115a4bce587..1ae56f28f2b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -7,14 +7,17 @@ * ModelSelector — thin wrapper wired to session context for chat usage. */ -import { Component, createSignal, createMemo, createEffect, onCleanup, For, Show, createSelector } from "solid-js" +import { createSignal, createMemo, createEffect, onCleanup, For, Show, createSelector, useContext } from "solid-js" +import type { Component } from "solid-js" import { PopupSelector } from "./PopupSelector" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tag } from "@kilocode/kilo-ui/tag" +import { Icon } from "@kilocode/kilo-ui/icon" import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import { useProvider, EnrichedModel } from "../../context/provider" -import { useSession } from "../../context/session" +import { useProvider } from "../../context/provider" +import type { EnrichedModel } from "../../context/provider" +import { useSession, SessionContext } from "../../context/session" import { useLanguage } from "../../context/language" import type { ModelSelection } from "../../types/messages" import { @@ -27,9 +30,42 @@ import { } from "./model-selector-utils" import { ModelPreview } from "./ModelPreview" +// --------------------------------------------------------------------------- +// Row / group key helpers — single source of truth for key formatting +// --------------------------------------------------------------------------- + +const CLEAR_KEY = "clear" +const FAVORITES_KEY = "favorites" +const RECOMMENDED_KEY = "recommended" + +function modelKey(providerID: string, modelID: string) { + return `${providerID}/${modelID}` +} + +function rowKey(kind: "model" | "favorite", providerID: string, modelID: string) { + return `${kind}:${providerID}/${modelID}` +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ModelRow { + key: string + kind: "clear" | "favorite" | "model" + model?: EnrichedModel +} + interface ModelGroup { - providerName: string - models: EnrichedModel[] + key: string + label: string + rows: ModelRow[] +} + +interface ScrollAnchor { + key: string + top: number | undefined + scroll: number } // --------------------------------------------------------------------------- @@ -54,16 +90,24 @@ export interface ModelSelectorBaseProps { export const ModelSelectorBase: Component = (props) => { const { connected, models, findModel } = useProvider() const language = useLanguage() + // 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 [open, setOpen] = createSignal(false) const [expanded, setExpanded] = createSignal(false) const [search, setSearch] = createSignal("") const [debouncedSearch, setDebouncedSearch] = createSignal("") - const [selectedIndex, setSelectedIndex] = createSignal(0) - const [preActiveIdx, setPreActiveIdx] = createSignal(-1) - const [previewIdx, setPreviewIdx] = createSignal(-1) + const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY) + const [preActiveKey, setPreActiveKey] = createSignal(null) + const [previewKey, setPreviewKey] = createSignal(null) const [previewHeight, setPreviewHeight] = createSignal(500) + // Snapshot of the active model key captured when the popover opens. + // Used to reorder favorites so the current model appears first — but only + // based on the state at open-time, not reactively, to avoid list jumps + // when the user picks a different model while the popover is still open. + const [openSnapshot, setOpenSnapshot] = createSignal(null) let searchRef: HTMLInputElement | undefined let searchWrapperRef: HTMLDivElement | undefined @@ -72,6 +116,9 @@ export const ModelSelectorBase: Component = (props) => { let bodyRef: HTMLDivElement | undefined let previewTimer: ReturnType | undefined const [pointer, setPointer] = createSignal(true) + // Ref map: row key → DOM element. Populated by each row's ref callback, + // avoids DOM queries for scroll anchoring and scrollIntoView. + const refs = new Map() function onSplitterMouseDown(e: MouseEvent) { e.preventDefault() @@ -127,84 +174,183 @@ export const ModelSelectorBase: Component = (props) => { return visibleModels().filter((m) => m.name.toLowerCase().includes(q)) }) - // Grouped for rendering — recommended models float to the top as their own group + // 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 (!session) return new Set() + return new Set(session.favoriteModels().map((f) => modelKey(f.providerID, f.modelID))) + }) + + const favoriteModels = createMemo(() => { + if (!session || debouncedSearch()) return [] + const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m])) + const list = session + .favoriteModels() + .map((f) => map.get(modelKey(f.providerID, f.modelID))) + .filter((m): m is EnrichedModel => !!m) + const snap = openSnapshot() + if (!snap) return list + const idx = list.findIndex((m) => modelKey(m.providerID, m.id) === snap) + if (idx <= 0) return list + const item = list[idx] + if (!item) return list + return [item, ...list.slice(0, idx), ...list.slice(idx + 1)] + }) + const groups = createMemo(() => { const recommended: EnrichedModel[] = [] - const map = new Map() + const map = new Map() for (const m of filtered()) { if (m.recommendedIndex !== undefined) { recommended.push(m) - } else { - const group = map.get(m.providerID) ?? { providerName: m.providerName, models: [] } - group.models.push(m) - map.set(m.providerID, group) + continue } + const list = map.get(m.providerID) ?? [] + list.push(m) + map.set(m.providerID, list) } recommended.sort((a, b) => (a.recommendedIndex ?? Infinity) - (b.recommendedIndex ?? Infinity)) - for (const group of map.values()) { - group.models.sort((a, b) => a.name.localeCompare(b.name)) + const result: ModelGroup[] = [] + + const favorites = favoriteModels() + + if (favorites.length > 0) { + result.push({ + key: FAVORITES_KEY, + label: language.t("model.group.favorites"), + rows: favorites.map((m) => ({ + key: rowKey("favorite", m.providerID, m.id), + kind: "favorite", + model: m, + })), + }) } - const rest = [...map.entries()].sort(([a], [b]) => providerSortKey(a) - providerSortKey(b)).map(([, g]) => g) + if (recommended.length > 0) { + result.push({ + key: RECOMMENDED_KEY, + label: language.t("model.group.recommended"), + rows: recommended.map((m) => ({ + key: rowKey("model", m.providerID, m.id), + kind: "model", + model: m, + })), + }) + } - return recommended.length > 0 - ? [{ providerName: language.t("model.group.recommended"), models: recommended }, ...rest] - : rest + const rest: ModelGroup[] = [...map.entries()] + .sort(([a], [b]) => providerSortKey(a) - providerSortKey(b)) + .map(([id, list]) => { + list.sort((a, b) => a.name.localeCompare(b.name)) + return { + key: id, + label: list[0]?.providerName ?? id, + rows: list.map((m) => ({ + key: rowKey("model", m.providerID, m.id), + kind: "model", + model: m, + })), + } + }) + + return [...result, ...rest] }) - // Flat list for keyboard indexing (mirrors render order) - const flatFiltered = createMemo(() => groups().flatMap((g) => g.models)) - - // Offset for "clear" option at the top of the list - const clearOffset = () => (props.allowClear ? 1 : 0) - - // Pre-computed index map — avoids O(n) indexOf on every hover event - const flatIndexMap = createMemo(() => { - const map = new Map() - const offset = clearOffset() - flatFiltered().forEach((m, i) => map.set(m, i + offset)) - return map + const rows = createMemo(() => { + const list = groups().flatMap((g) => g.rows) + if (!props.allowClear) return list + return [{ key: CLEAR_KEY, kind: "clear" }, ...list] }) - // Preview shows the debounced previewIdx — smooth keyboard nav, buffered render - const previewModel = createMemo(() => { - const idx = previewIdx() - return idx >= 0 ? (flatFiltered()[idx - clearOffset()] ?? null) : null + const rowMap = createMemo(() => new Map(rows().map((row) => [row.key, row] as const))) + const rowIndex = createMemo(() => new Map(rows().map((row, i) => [row.key, i] as const))) + const canonicalKey = (m: EnrichedModel) => rowKey("model", m.providerID, m.id) + const favoriteKey = (m: EnrichedModel) => rowKey("favorite", m.providerID, m.id) + const defaultKey = () => rows()[0]?.key ?? CLEAR_KEY + const activeKey = (m?: EnrichedModel | null) => { + if (!m) return props.allowClear ? CLEAR_KEY : defaultKey() + const key = modelKey(m.providerID, m.id) + if (!debouncedSearch() && favoriteKeys().has(key)) return favoriteKey(m) + return canonicalKey(m) + } + const [anchor, setAnchor] = createSignal(null) + + const previewModel = createMemo(() => rowMap().get(previewKey() ?? "")?.model ?? null) + + const isSelected = createSelector(selectedKey) + const isPreActive = createSelector(preActiveKey) + + // When the row list changes (filter, favorite toggle, provider connect), + // preserve the current selection if it still exists; otherwise fall back + // to the first row. This must NOT read activeModel() — doing so would + // cause a reactive loop where picking a model triggers a rows rebuild + // which resets selection. + createEffect(() => { + rows() // track + setSelectedKey((prev) => (rowMap().has(prev) ? prev : defaultKey())) + setPreActiveKey((prev) => (prev && rowMap().has(prev) ? prev : null)) + setPreviewKey((prev) => (prev && rowMap().has(prev) ? prev : null)) }) - // createSelector gives fine-grained reactivity: only the two items that - // change (old selected → new selected) re-render, not the entire list. - const isSelected = createSelector(selectedIndex) - const isPreActive = createSelector(preActiveIdx) + createEffect(() => { + const saved = anchor() + rows() + if (!saved || !listRef) return + requestAnimationFrame(() => { + if (!listRef) { + setAnchor(null) + return + } + const el = refs.get(saved.key) + if (el && saved.top !== undefined) { + listRef.scrollTop += el.getBoundingClientRect().top - saved.top + } else { + listRef.scrollTop = saved.scroll + } + setAnchor(null) + }) + }) - // Reset selection when filter changes + // Reset selection when the search filter changes. Uses canonicalKey + // directly (not activeKey) to avoid a reactive dependency on favoriteKeys, + // which would cause star/unstar to reset selection mid-interaction. + // Falls back to defaultKey when the active model is filtered out. createEffect(() => { filtered() // track - setSelectedIndex(0) - setPreActiveIdx(-1) - setPreviewIdx(-1) + const active = activeModel() + const canon = active ? canonicalKey(active) : null + const next = canon && rowMap().has(canon) ? canon : props.allowClear ? CLEAR_KEY : defaultKey() + setSelectedKey(next) + setPreActiveKey(next) + setPreviewKey(next) }) - // Focus search input, set selectedIndex to active model, and scroll it into view when popover opens createEffect(() => { if (open()) { const active = activeModel() - const activeIdx = active ? (flatIndexMap().get(active) ?? 0) : 0 - setSelectedIndex(activeIdx) - setPreActiveIdx(activeIdx) - setPreviewIdx(activeIdx) - requestAnimationFrame(() => { - searchRef?.focus() - listRef?.querySelector(".model-selector-item.active")?.scrollIntoView({ block: "center" }) + const snap = active ? modelKey(active.providerID, active.id) : null + setOpenSnapshot(snap) + // Defer key resolution to next microtask so favoriteModels/groups/rows + // recompute with the snapshot before we try to resolve the key. + queueMicrotask(() => { + const next = activeKey(activeModel()) + setSelectedKey(next ?? CLEAR_KEY) + setPreActiveKey(next) + setPreviewKey(next) + requestAnimationFrame(() => { + searchRef?.focus() + scrollRow(next ?? CLEAR_KEY, "center") + }) }) - } else { - setSearch("") - setDebouncedSearch("") - clearTimeout(previewTimer) + return } + setOpenSnapshot(null) + setSearch("") + setDebouncedSearch("") + clearTimeout(previewTimer) }) // Listen for slash command trigger @@ -218,13 +364,71 @@ export const ModelSelectorBase: Component = (props) => { } function pickClear() { + setSelectedKey(CLEAR_KEY) + setPreActiveKey(CLEAR_KEY) + setPreviewKey(CLEAR_KEY) props.onSelect("", "") setOpen(false) } + function setRow(key: string) { + setSelectedKey(key) + setPreActiveKey(key) + } + + function schedulePreview(key: string | null) { + clearTimeout(previewTimer) + previewTimer = setTimeout(() => setPreviewKey(key), 200) + } + + function scrollRow(key: string | null | undefined, block: ScrollLogicalPosition = "nearest") { + if (key) refs.get(key)?.scrollIntoView({ block }) + } + + function move(step: number) { + const list = rows() + if (list.length === 0) return + const idx = rowIndex().get(selectedKey()) ?? 0 + const next = Math.max(0, Math.min(idx + step, list.length - 1)) + const key = list[next]?.key ?? CLEAR_KEY + setRow(key) + schedulePreview(key) + scrollSelectedIntoView() + } + + function selectRow(row: ModelRow) { + if (row.kind === "clear") { + pickClear() + return + } + if (!row.model) return + setRow(row.key) + setPreviewKey(row.key) + pick(row.model) + } + + function toggleFavorite(model: EnrichedModel, row: ModelRow) { + if (!session) return + const canon = canonicalKey(model) + // When unfavoriting from the top duplicate, the favorite row will + // disappear — anchor to the canonical provider row instead so the + // scroll restore finds an element that still exists after rerender. + const key = row.kind === "favorite" ? canon : row.key + const el = refs.get(key) + setAnchor({ + key, + top: el?.getBoundingClientRect().top, + scroll: listRef?.scrollTop ?? 0, + }) + if (row.kind === "favorite") { + setRow(canon) + setPreviewKey(canon) + } + session.toggleFavorite(model.providerID, model.id) + } + function handleKeyDown(e: KeyboardEvent) { - const items = flatFiltered() - const totalLen = items.length + clearOffset() + const list = rows() if (e.key === "Escape") { e.preventDefault() @@ -232,46 +436,34 @@ export const ModelSelectorBase: Component = (props) => { return } - if (totalLen === 0) { + if (list.length === 0) { return } if (e.key === "ArrowDown") { e.preventDefault() setPointer(false) - const next = Math.min(selectedIndex() + 1, totalLen - 1) - setSelectedIndex(next) - setPreActiveIdx(next) - clearTimeout(previewTimer) - previewTimer = setTimeout(() => setPreviewIdx(next), 200) - scrollSelectedIntoView() - } else if (e.key === "ArrowUp") { + move(1) + return + } + + if (e.key === "ArrowUp") { e.preventDefault() setPointer(false) - const next = Math.max(selectedIndex() - 1, 0) - setSelectedIndex(next) - setPreActiveIdx(next) - clearTimeout(previewTimer) - previewTimer = setTimeout(() => setPreviewIdx(next), 200) - scrollSelectedIntoView() - } else if (e.key === "Enter") { + move(-1) + return + } + + if (e.key === "Enter") { e.preventDefault() - const idx = selectedIndex() - if (props.allowClear && idx === 0) { - pickClear() - } else { - const item = items[idx - clearOffset()] - if (item) { - pick(item) - } - } + const row = rowMap().get(selectedKey()) + if (row) selectRow(row) } } function scrollSelectedIntoView() { requestAnimationFrame(() => { - const el = listRef?.querySelector(".model-selector-item.keyboard-focused") - el?.scrollIntoView({ block: "nearest" }) + scrollRow(preActiveKey() ?? selectedKey(), "nearest") }) } @@ -359,14 +551,14 @@ export const ModelSelectorBase: Component = (props) => { onClick={() => { setExpanded((v) => { if (v) { - setPreActiveIdx(-1) - setPreviewIdx(-1) + setPreActiveKey(null) + setPreviewKey(null) } return !v }) requestAnimationFrame(() => { searchRef?.focus() - listRef?.querySelector(".model-selector-item.active")?.scrollIntoView({ block: "nearest" }) + scrollRow(preActiveKey() ?? selectedKey(), "nearest") }) }} /> @@ -374,13 +566,13 @@ export const ModelSelectorBase: Component = (props) => {
- +
{language.t("dialog.model.empty")}
pickClear()} @@ -388,7 +580,7 @@ export const ModelSelectorBase: Component = (props) => { setPointer(true) }} onMouseEnter={() => { - if (pointer()) setSelectedIndex(0) + if (pointer()) setSelectedKey(CLEAR_KEY) }} > @@ -400,33 +592,39 @@ export const ModelSelectorBase: Component = (props) => { {(group) => ( <> -
{group.providerName}
- - {(model) => { - const idx = () => flatIndexMap().get(model) ?? 0 - const hovered = () => isSelected(idx()) - const preActive = () => isPreActive(idx()) +
{group.label}
+ + {(row) => { + if (!row.model) return null + const model = row.model + const hovered = () => isSelected(row.key) + const preActive = () => isPreActive(row.key) const showSelectBtn = () => expanded() && preActive() && !isActive(model) + const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) + const showProvider = () => row.kind === "favorite" return (
{ + refs.set(row.key, el) + onCleanup(() => refs.delete(row.key)) + }} + class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) && row.kind === "model" ? " active" : ""}`} role="option" - aria-selected={isActive(model)} + aria-selected={isActive(model) && row.kind === "model"} onClick={() => { - setSelectedIndex(idx()) - setPreActiveIdx(idx()) - setPreviewIdx(idx()) - if (!expanded()) pick(model) + setRow(row.key) + setPreviewKey(row.key) + if (!expanded()) selectRow(row) searchRef?.focus() }} onDblClick={() => { - if (expanded()) pick(model) + if (expanded()) selectRow(row) }} onMouseMove={() => { setPointer(true) }} onMouseEnter={() => { - if (pointer()) setSelectedIndex(idx()) + if (pointer()) setSelectedKey(row.key) }} >
@@ -446,13 +644,32 @@ export const ModelSelectorBase: Component = (props) => { {language.t("model.tag.free")} + + {model.providerName} +
+ + +