diff --git a/.changeset/agent-manager-project-reorder.md b/.changeset/agent-manager-project-reorder.md new file mode 100644 index 00000000000..bde727ca1ee --- /dev/null +++ b/.changeset/agent-manager-project-reorder.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Reorder projects in the Agent Manager sidebar with drag and drop. The order is kept after a restart. diff --git a/.changeset/agent-session-explicit-intent.md b/.changeset/agent-session-explicit-intent.md new file mode 100644 index 00000000000..6d1a1c9715b --- /dev/null +++ b/.changeset/agent-session-explicit-intent.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Tune the `agent_manager` and `task` tool instructions so agents only start new Agent Manager sessions or worktrees when the user explicitly asks, or after confirming with the user first. diff --git a/.changeset/browser-shift-click-external.md b/.changeset/browser-shift-click-external.md new file mode 100644 index 00000000000..f0d2489aa2d --- /dev/null +++ b/.changeset/browser-shift-click-external.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Open chat links in the system browser with Shift-click when the Integrated Browser handles link clicks diff --git a/.changeset/goal-command-alias.md b/.changeset/goal-command-alias.md new file mode 100644 index 00000000000..e2192b11de0 --- /dev/null +++ b/.changeset/goal-command-alias.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Expose a custom or plugin command named `goal` as `/goal:command` instead of dropping it. `/goal` still starts a session goal, and the configuration warning now names the alias so the command stays reachable. diff --git a/.changeset/native-browser-feedback.md b/.changeset/native-browser-feedback.md new file mode 100644 index 00000000000..e971bafd640 --- /dev/null +++ b/.changeset/native-browser-feedback.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show page cursor feedback in the Integrated Browser, including text fields, resize handles, and drag controls, without changing page scrolling or compositing. diff --git a/.changeset/openai-compatible-dimension-fallback.md b/.changeset/openai-compatible-dimension-fallback.md new file mode 100644 index 00000000000..1db6b3b865d --- /dev/null +++ b/.changeset/openai-compatible-dimension-fallback.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-indexing": patch +--- + +Support OpenAI-compatible indexing endpoints that reject the optional `dimensions` parameter when their native vectors match the configured size. Report a clear error when the endpoint returns a different vector size. diff --git a/.changeset/pr-panel-open-browser-icon.md b/.changeset/pr-panel-open-browser-icon.md new file mode 100644 index 00000000000..76eadd1e9de --- /dev/null +++ b/.changeset/pr-panel-open-browser-icon.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the standard open-in-browser icon for the Agent Manager PR panel's external link button, so it matches the open-in-browser affordance used elsewhere in the app. diff --git a/.changeset/segmented-prompt-selectors.md b/.changeset/segmented-prompt-selectors.md new file mode 100644 index 00000000000..a5b17b2166f --- /dev/null +++ b/.changeset/segmented-prompt-selectors.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show the agent, model, and reasoning selectors as one split control in the prompt toolbar and the Agent Manager New Worktree dialog. While one picker is open, move the pointer to another part to switch to it, or use the Left and Right arrow keys. The reasoning level shows only when it is set and there is room. Otherwise it collapses into the trailing arrow, and a changed level shows for a moment. On narrow sidebars, less used actions move into a "More actions" menu one at a time, while voice input and send stay visible. The toolbar always stays one row, so a long model name truncates and does not hide actions. diff --git a/.changeset/subagent-open-tooltip.md b/.changeset/subagent-open-tooltip.md new file mode 100644 index 00000000000..b2a3ad3a984 --- /dev/null +++ b/.changeset/subagent-open-tooltip.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Add a tooltip to the subagent open button in the chat transcript. It shows whether the subagent opens in an editor tab (sidebar) or in the Agent Manager panel. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index b2655cabae6..791769bf9b1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -2,6 +2,26 @@ name: Bug report description: Report an issue that should be fixed labels: ["bug"] body: + # kilocode_change start + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Kilo is this about? It helps us route your report. + options: + - VS Code extension + - CLI / TUI + - Agent Manager + - JetBrains plugin + - Desktop or web app + - Documentation + - Kilo Gateway or account + - SDK + - Other / not sure + validations: + required: true + # kilocode_change end + - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c47570a..0e679c00ae7 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -18,3 +18,23 @@ body: description: What do you want to change or add? What are the benefits of implementing this? Try to be detailed so we can understand your request better :) validations: required: true + + # kilocode_change start + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Kilo is this about? It helps us route your request. + options: + - VS Code extension + - CLI / TUI + - Agent Manager + - JetBrains plugin + - Desktop or web app + - Documentation + - Kilo Gateway or account + - SDK + - Other / not sure + validations: + required: true + # kilocode_change end diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index a7b774a40b1..250f4efa7db 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -10,3 +10,9 @@ query-filters: - path-alert tags contain: security security-severity: /^(9(\.[0-9])?|10(\.0)?)$/ + +# CodeQL classifies extensionless files with a `bun`/`node` shebang as plain +# JavaScript, so TypeScript syntax in these dev-only scripts (e.g. `as const`) +# is reported as "Could not process some files due to syntax errors". +paths-ignore: + - packages/opencode/script/run-workspace-server diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a8415564209..5c752da4e4d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ + + ## Issue diff --git a/.github/workflows/community-label.yml b/.github/workflows/community-label.yml new file mode 100644 index 00000000000..9a0ebd32383 --- /dev/null +++ b/.github/workflows/community-label.yml @@ -0,0 +1,47 @@ +# kilocode_change - new file +name: Community label + +# Labels new PRs and issues so maintainers can tell community work from team +# work and see which part of the repo it touches. See docs/community-triage.md. +# +# pull_request_target runs with write access, even for fork PRs. That is safe +# here because the job only checks out the base branch and never runs code from +# the PR. It reads PR metadata (title, author, file names) as data. + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, edited] + issues: + types: [opened, edited] + +concurrency: + group: community-label-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + # Optional. Lets the job see private org members. See the docs. + MEMBER_TOKEN: ${{ secrets.COMMUNITY_TRIAGE_MEMBER_TOKEN }} + MAINTAINERS: ${{ vars.COMMUNITY_MAINTAINERS }} + run: bun run script/kilocode/community-triage.ts label diff --git a/.github/workflows/community-sweep.yml b/.github/workflows/community-sweep.yml new file mode 100644 index 00000000000..ae88b0e2f02 --- /dev/null +++ b/.github/workflows/community-sweep.yml @@ -0,0 +1,67 @@ +# kilocode_change - new file +name: Community sweep + +# Twice a week, the evening before the maintainer triage slots (Friday and +# Tuesday). Works out whose turn it is on each community PR, keeps the status +# labels in sync, closes PRs that got no reply for 14 days and posts the queue. +# See docs/community-triage.md. + +on: + schedule: + - cron: "0 17 * * 1,4" # Monday and Thursday, 17:00 UTC + workflow_dispatch: + inputs: + close: + description: "Close PRs with no reply for 14 days. Off means log only." + type: boolean + default: false + +concurrency: + group: community-sweep + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sweep: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Sweep + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + MEMBER_TOKEN: ${{ secrets.COMMUNITY_TRIAGE_MEMBER_TOKEN }} + MAINTAINERS: ${{ vars.COMMUNITY_MAINTAINERS }} + # Closing stays off until the repo variable is set to "true". + CLOSE_ENABLED: ${{ github.event_name == 'workflow_dispatch' && format('{0}', inputs.close) || vars.COMMUNITY_AUTO_CLOSE_ENABLED }} + SLACK_PAYLOAD: ${{ runner.temp }}/slack-payload.json + run: bun run script/kilocode/community-triage.ts sweep + + # A step cannot read its own env in `if`, so a small step exposes the + # secret to itself only and passes on whether it is set. + - name: Check Slack webhook + id: slack + env: + SLACK_WEBHOOK: ${{ secrets.COMMUNITY_TRIAGE_SLACK_WEBHOOK }} + run: echo "enabled=${{ env.SLACK_WEBHOOK != '' }}" >> "$GITHUB_OUTPUT" + + - name: Notify Slack + if: steps.slack.outputs.enabled == 'true' + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.COMMUNITY_TRIAGE_SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload-file-path: ${{ runner.temp }}/slack-payload.json diff --git a/.github/workflows/community-triage-test.yml b/.github/workflows/community-triage-test.yml new file mode 100644 index 00000000000..6577c809fa6 --- /dev/null +++ b/.github/workflows/community-triage-test.yml @@ -0,0 +1,36 @@ +# kilocode_change - new file +name: Community triage test + +# Runs the unit tests for the community triage script. Only runs when the +# script, its tests or the workflows that use it change. + +on: + pull_request: + paths: + - script/kilocode/community-triage*.ts + - .github/workflows/community-*.yml + workflow_dispatch: + +concurrency: + group: community-triage-test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Test + working-directory: script/kilocode + run: bun test community-triage.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8400e2ee3b..b473c33a092 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,8 @@ There are lots of ways to contribute to the project: - **Feature Requests:** Suggest new features or improvements - **Community Support:** Help other users in the community +Community pull requests are triaged twice a week. See [Community triage](docs/community-triage.md) for what to expect and how long we wait for replies. + The Kilo Community is [on Discord](https://kilo.ai/discord). ## Prerequisites diff --git a/bun.lock b/bun.lock index cdb63ddb8e5..e243e6fd00a 100644 --- a/bun.lock +++ b/bun.lock @@ -266,7 +266,7 @@ "@xyflow/react": "12.10.2", "js-yaml": "^4.3.2", "mermaid": "11.17.2", - "next": "^16.3.6", + "next": "^16.3.8", "posthog-js": "^1.335.3", "prismjs": "^1.30.0", "react": "^19.2.4", @@ -1837,23 +1837,23 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], - "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="], + "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="], "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], @@ -3987,7 +3987,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="], + "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], diff --git a/docs/community-triage.md b/docs/community-triage.md new file mode 100644 index 00000000000..2d3d649642e --- /dev/null +++ b/docs/community-triage.md @@ -0,0 +1,157 @@ +# Community triage + +How we handle pull requests and issues from people outside the Kilo team. + +The goal is simple. Maintainers see the community work that matters, and contributors know what happens next. + +Background: [Slack discussion](https://anaconda.slack.com/archives/C0BJ2GQ40HM/p1791377022461359). + +## Overview + +```mermaid +flowchart LR + A[PR or issue opened] --> B[community-label.yml] + B --> C[Labels: community, needs-triage, area:*] + C --> D[community-sweep.yml
Mon + Thu 17:00 UTC] + D --> E[Status labels synced] + D --> F[Queue posted to job summary and Slack] + F --> G[Tuesday: Bruno] + F --> H[Friday: Andrea] + G --> I[Review, label, comment] + H --> I + I --> D +``` + +Two workflows do the work. Both use `script/kilocode/community-triage.ts`. + +| Workflow | Runs | Does | +|---|---|---| +| `community-label.yml` | When a PR or issue opens or is edited | Adds `community`, `needs-triage` (new items only, not reopened ones) and `area:*` labels | +| `community-sweep.yml` | Monday and Thursday, 17:00 UTC | Syncs status labels, closes stale PRs, posts the queue | + +The sweep runs the evening before each maintainer slot. Andrea starts Friday and Bruno starts Tuesday with a fresh queue. + +## Who is a maintainer + +An author or commenter is a maintainer when one of these is true: + +- GitHub shows them as `OWNER`, `MEMBER` or `COLLABORATOR`. +- Their login is in the repo variable `COMMUNITY_MAINTAINERS` (comma separated). +- The optional secret `COMMUNITY_TRIAGE_MEMBER_TOKEN` (`read:org`) shows they are in the org. + +The default token cannot see private org members. They look like normal contributors. Set the variable or the secret so their PRs are not labeled `community`. + +Bots are ignored. + +## Labels + +Set by the workflows: + +| Label | Meaning | +|---|---| +| `community` | Author is not a maintainer | +| `area:vscode`, `area:cli`, `area:agent-manager`, `area:jetbrains`, `area:desktop`, `area:docs`, `area:gateway`, `area:sdk`, `area:ui`, `area:i18n` | Part of the repo. Comes from the title scope, the changed paths, or the issue "Component" dropdown | +| `needs-triage` | No maintainer has reacted yet | +| `needs-review` | The contributor replied or pushed. A maintainer should look again | +| `awaiting-contributor` | A maintainer reacted last. The 14 day clock runs | +| `ci-failing` | Checks on the last commit failed | +| `needs-reimplementation` | `high-value` PR with no reply for 14 days. The team takes it over | + +Set by hand: + +| Label | Meaning | +|---|---| +| `high-value` | We want this change. The sweep never closes it | +| `low-value` | Triaged. Not a priority | +| `keep-open` | The sweep never closes this PR | + +The sweep owns the status labels. It adds and removes them on its own. Do not manage them by hand. + +`area:*` labels are only added, never removed. Fix a wrong one by hand. + +## Whose turn is it + +The sweep reads the comments, reviews, review replies, commits and reopen events of each open community PR. Drafts are skipped. A reopen by the author counts as a reply. The sweep ignores commit dates more than 5 minutes in the future. + +The sweep reads only the latest events. If it finds no maintainer reaction on a PR with more events than it can read, it labels the PR `needs-review` so a person decides. + +```mermaid +stateDiagram-v2 + [*] --> triage: opened, no maintainer reaction + triage --> awaiting: maintainer comments or requests changes + triage --> approved: maintainer approves + awaiting --> review: contributor comments or pushes + review --> awaiting: maintainer comments or requests changes + review --> approved: maintainer approves + approved --> review: contributor pushes again + awaiting --> closed: 14 days, not high-value + awaiting --> adopted: 14 days, high-value + adopted --> [*]: team re-implements or fixes it + closed --> [*] +``` + +| State | Label | Rule | +|---|---|---| +| triage | `needs-triage` | No maintainer reaction yet, and no `high-value` or `low-value` label | +| review | `needs-review` | The contributor acted after the last maintainer reaction, or a `high-value` or `low-value` label exists | +| approved | none | The last maintainer reaction is an approval | +| awaiting | `awaiting-contributor` | The last maintainer reaction asked for something and the contributor has not answered | + +The 14 day clock starts at the time of the last maintainer reaction. + +## The 14 day rule + +When a PR has been `awaiting-contributor` for 14 days: + +| PR | Result | +|---|---| +| Has `high-value` | Gets `needs-reimplementation` and shows in the digest under "Adopt". Add it to the standup doc and pick an owner | +| Has `keep-open` | Nothing happens | +| Anything else | Closed with a comment | + +Before the sweep closes or adopts a PR, it checks the full history for a contributor reply. This catches replies on long PRs. + +Closing is **off by default**. Until it is on, the sweep only logs what it would close. To turn it on, set the repo variable `COMMUNITY_AUTO_CLOSE_ENABLED` to `true`. To try it by hand, run the sweep from the Actions tab and tick "close". + +When a PR first gets `awaiting-contributor`, the sweep also posts one comment that gives the due date. That comment is also off until closing is on. The sweep posts at most 20 comments per run (`MAX_COMMENTS`). + +`kilo-auto-close.yml` is separate. It does not read these labels. It still closes any PR with no activity for 30 days, including `high-value` and `keep-open` PRs. + +## The maintainer routine + +Open the latest "Community sweep" run. The job summary has the queue. A copy goes to Slack when `COMMUNITY_TRIAGE_SLACK_WEBHOOK` is set. + +1. **Adopt**: pick an owner and add the PR to the standup doc. +2. **Needs triage**: decide. Add `high-value` or `low-value`, or comment on it. Not sure? Put it in the standup doc. +3. **Needs review**: the contributor replied. Review again. +4. **Approved**: merge. +5. **Waiting for contributor**: nothing to do. + +Friday and Tuesday are three to four days apart. This gives contributors time to reply between reviews. + +Useful searches: + +- All community PRs: `is:pr is:open label:community` +- Needs a maintainer: `is:pr is:open label:needs-triage,needs-review` +- One area: `is:pr is:open label:community label:area:vscode` + +## Issues + +New issues get the `community` label, `needs-triage` and an `area:*` label. The area comes from the required "Component" dropdown in the bug and feature templates. The answer "Other / not sure" adds no `area:*` label. The sweep only handles PRs. + +## Setup + +| Name | Kind | Needed | Purpose | +|---|---|---|---| +| `COMMUNITY_AUTO_CLOSE_ENABLED` | Variable | No | `true` turns on closing and comments | +| `COMMUNITY_MAINTAINERS` | Variable | No | Logins treated as maintainers | +| `COMMUNITY_TRIAGE_MEMBER_TOKEN` | Secret | No | Token with `read:org`. Finds private org members | +| `COMMUNITY_TRIAGE_SLACK_WEBHOOK` | Secret | No | Posts the queue to Slack | + +Labels are created by the workflows. Run "Community sweep" once by hand after merge to create them and label the open PRs. + +## Safety + +- `community-label.yml` uses `pull_request_target`. It checks out the base branch only. It never runs PR code. +- The sweep never closes drafts, `high-value` PRs or `keep-open` PRs. +- Closed PRs can be reopened by the contributor. diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index d8a2bed642f..27c3ba00caa 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -20,7 +20,7 @@ "@xyflow/react": "12.10.2", "js-yaml": "^4.3.2", "mermaid": "11.17.2", - "next": "^16.3.6", + "next": "^16.3.8", "posthog-js": "^1.335.3", "prismjs": "^1.30.0", "react": "^19.2.4", diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md index 34f731753c2..52884f11aa7 100644 --- a/packages/kilo-docs/pages/automate/extending/plugins.md +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -363,7 +363,7 @@ Every hook is optional. Return only the ones you care about. | Hook | Description | |---|---| -| `config` | Receives the fully-resolved config at startup. Read-only — useful for inspection. | +| `config` | Receives the fully-resolved config at startup. Mutate it in place to add or change agents, commands, providers, and other config — this is how a plugin contributes its own slash commands. A command named `goal` is reserved for [session goals](/docs/code-with-ai/agents/goals): Kilo ignores it under that name, warns, and exposes it as `/goal:command` instead. | | `event` | Called for **every** event on the internal bus (see [Events](#events)). | ### Tools diff --git a/packages/kilo-docs/pages/code-with-ai/agents/goals.md b/packages/kilo-docs/pages/code-with-ai/agents/goals.md index 3beb82202d7..4fa522a5597 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/goals.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/goals.md @@ -103,7 +103,7 @@ Goal-composer mode accepts multiline objectives and file or image attachments. I ## Limits and integration -- A custom command or an MCP prompt named `goal` is reserved. Kilo rejects it and reports an error; rename it. +- A custom command named `goal` (from your config or a plugin) is reserved for this feature. Kilo ignores it, reports a configuration warning, and makes it available as `/goal:command` instead of running it as `/goal`. - The goal is stored under the `kilo.goal` session metadata key. - Headless mode supports only status and controls through `kilo run --command goal`, which accepts no argument, `pause`, or `clear`. The agent can still start or resume a goal itself with the `goal` tool. diff --git a/packages/kilo-docs/pages/customize/workflows.md b/packages/kilo-docs/pages/customize/workflows.md index 8b3e4777ad0..cbc22e7d48d 100644 --- a/packages/kilo-docs/pages/customize/workflows.md +++ b/packages/kilo-docs/pages/customize/workflows.md @@ -60,6 +60,10 @@ You are helping submit a pull request... | `variant` | Reasoning effort variant override (for example `low` or `high`), for models that support variants | | `subtask` | When `true`, runs as a sub-agent session | +### Reserved Names + +`goal` is reserved for [session goals](/docs/code-with-ai/agents/goals). A command named `goal` in your config or from a plugin is ignored under that name — Kilo reports a configuration warning and keeps `/goal` for its own command, but the command you defined is still reachable as `/goal:command`, the same way a skill that collides with a command is offered as `/name:skill`. + ### Model and Reasoning Variant Each workflow can run with its own model and reasoning effort variant. In the VS Code extension, open **Settings → Agent Behaviour → Workflows**, expand a workflow, and choose a model and variant. The selection is saved as a command override in your global config, so the workflow's template file stays unchanged. diff --git a/packages/kilo-docs/pnpm-lock.yaml b/packages/kilo-docs/pnpm-lock.yaml index edae3d0c738..96daf776b66 100644 --- a/packages/kilo-docs/pnpm-lock.yaml +++ b/packages/kilo-docs/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 0.5.9(@types/react@19.2.18)(react@19.2.8) '@markdoc/next.js': specifier: ^0.5.0 - version: 0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@vscode/codicons': specifier: ^0.0.44 version: 0.0.44 @@ -36,8 +36,8 @@ importers: specifier: 11.17.2 version: 11.17.2 next: - specifier: ^16.3.6 - version: 16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^16.3.8 + version: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) posthog-js: specifier: ^1.335.3 version: 1.413.3 @@ -309,57 +309,57 @@ packages: '@mermaid-js/parser@1.2.1': resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} - '@next/env@16.3.6': - resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/swc-darwin-arm64@16.3.6': - resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.6': - resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.6': - resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.6': - resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.6': - resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.6': - resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.6': - resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.6': - resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1229,8 +1229,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next@16.3.6: - resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -1709,41 +1709,41 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@markdoc/next.js@0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@markdoc/next.js@0.5.0(@markdoc/markdoc@0.5.9(@types/react@19.2.18)(react@19.2.8))(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: '@markdoc/markdoc': 0.5.9(@types/react@19.2.18)(react@19.2.8) js-yaml: 4.3.2 - next: 16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 '@mermaid-js/parser@1.2.1': dependencies: '@chevrotain/types': 11.1.2 - '@next/env@16.3.6': {} + '@next/env@16.3.8': {} - '@next/swc-darwin-arm64@16.3.6': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.6': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.6': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.6': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.6': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.6': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.6': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.6': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@oxc-project/types@0.150.0': {} @@ -2549,9 +2549,9 @@ snapshots: nanoid@3.3.19: {} - next@16.3.6(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.6 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 @@ -2560,14 +2560,14 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.6 - '@next/swc-darwin-x64': 16.3.6 - '@next/swc-linux-arm64-gnu': 16.3.6 - '@next/swc-linux-arm64-musl': 16.3.6 - '@next/swc-linux-x64-gnu': 16.3.6 - '@next/swc-linux-x64-musl': 16.3.6 - '@next/swc-win32-arm64-msvc': 16.3.6 - '@next/swc-win32-x64-msvc': 16.3.6 + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 sharp: 0.35.5(@types/node@25.9.5) transitivePeerDependencies: - '@babel/core' diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png index 3749ab07b0a..d4d17bd9132 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28a0ba0675cdfb62f6f80d63a3fadcaacf215c2ad65f12f2fa04774bfdb33028 -size 4940 +oid sha256:352dfe36ed8a02365c18313281a048283adcf58593431384910676d2c147b9c5 +size 3109 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png index 33fdb217470..d9874f298a9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00746923c55f811fba5e823ff8e35205f6f6c70d1e7ce440514636ce8a4d0791 -size 5364 +oid sha256:94937c48bf1380567e8a1084aa97303584361ad8e42e9bddbe4fd1d4a1ea050f +size 4938 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png index 83c5507bd19..1eec4b09893 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-code-context-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fa512d7a35f92d135fb7f029936186dd86e8514ac9956ca99f7a8d2852f36d2 -size 13981 +oid sha256:18576213b3aa90acd6114cd92a4b3861240c0c14889e9486921afde5e76f015f +size 12230 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png index cbaeac4ae1c..af515b5e166 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10b8abe679042e3ccd36f7baca0cdcf14ceae61faadd597aa94c7471e17ffd7c -size 5209 +oid sha256:7ad974c5a1795d6d8f199614fefd923c316b169414441fd7d907b5c84b70c563 +size 3165 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png index 47cd701459f..46006da543f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-prompt-training-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76f28776968833286e0dc6c2610fa7a9cc32652615b1d5dbd0d7fef092b28afc -size 5620 +oid sha256:751e89229eeaf0f30aed769be94509fc66230b38e5e7373e10b5ea5f535b5ef8 +size 5135 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png index 69ca3e934c3..eb7afa863ce 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdb59fc167c642e9c6a5e8298fe06393cc8b4ce67bfc4c588245b748d2661615 -size 5584 +oid sha256:539e854b91b550275df4748253147091913b3e0240b30a7141b2dd7381b21643 +size 3057 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png index 91653c96dea..0aac9a4ac7f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da4aac6eededda5642e50d4482a684242ddcd2502dd660140c61bafacdabbea7 -size 6039 +oid sha256:bffc5d14bc63e0ba18c2b4f43f36ac408c1add96f926dfdff13073b835686589 +size 5672 diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts index 100d2fe6ff0..93ce4d6184b 100644 --- a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -9,7 +9,12 @@ import { REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../model-registry" -import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { + withValidationErrorHandling, + extractStatusCode, + type HttpError, + formatEmbeddingError, +} from "../shared/validation-helpers" import { applyQueryPrefix, embedBatches } from "../shared/embedder-helpers" import { createRateLimitState, @@ -54,6 +59,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly maxItemTokens: number private readonly headers: Record private readonly dimensions?: number + private readonly omitted = new Set() // Global rate limiting state shared across all instances private static globalRateLimitState = createRateLimitState() @@ -157,12 +163,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @param url The full endpoint URL * @param batchTexts Array of texts to embed * @param model Model identifier to use + * @param dimensions Optional embedding dimensions to request + * @param signal Optional abort signal * @returns Promise resolving to OpenAI-compatible response */ private async makeDirectEmbeddingRequest( url: string, batchTexts: string[], model: string, + dimensions: number | undefined, signal?: AbortSignal, ): Promise { const response = await fetch(url, { @@ -181,7 +190,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { input: batchTexts, model: model, encoding_format: "base64", - ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), + ...(dimensions !== undefined ? { dimensions } : {}), }), signal, }) @@ -213,6 +222,84 @@ export class OpenAICompatibleEmbedder implements IEmbedder { } } + private async request( + texts: string[], + model: string, + options: { signal?: AbortSignal; timeout?: number; maxRetries?: number } = {}, + ): Promise<{ + response: OpenAIEmbeddingResponse + projected?: { embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } } + }> { + const send = async (dimensions: number | undefined): Promise => { + if (this.isFullUrl) { + return this.makeDirectEmbeddingRequest(this.baseUrl, texts, model, dimensions, options.signal) + } + const body = { + input: texts, + model: model, + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + encoding_format: "base64" as const, + ...(dimensions !== undefined ? { dimensions } : {}), + } + if (options.timeout === undefined && options.maxRetries === undefined) { + return this.embeddingsClient.embeddings.create(body) + } + return this.embeddingsClient.embeddings.create(body, { + timeout: options.timeout, + maxRetries: options.maxRetries, + }) + } + + const check = (response: OpenAIEmbeddingResponse) => { + // Guard the shape before decoding. A 200 response with an error body has no + // data, so decoding it first would throw a raw TypeError. Do not throw here + // either: validation reads response.error for that case, and the batch caller + // reports the missing data when it needs the vectors. + const hasData = Array.isArray(response?.data) + const projected = hasData ? projectEmbeddingResponse(response) : undefined + if (this.dimensions !== undefined && projected) { + if (response.data.length !== texts.length) { + throw new Error("Invalid response from embedding endpoint") + } + for (const vector of projected.embeddings) { + if (Array.isArray(vector) && vector.length === this.dimensions) continue + throw new Error( + `Embedding endpoint returned ${Array.isArray(vector) ? vector.length : 0} dimensions, but ${this.dimensions} are configured. ` + + "Set the configured dimension to match the model output or use an endpoint that supports the requested dimensions.", + ) + } + } + return { response, projected } + } + const dimensions = this.omitted.has(model) ? undefined : this.dimensions + + try { + return check(await send(dimensions)) + } catch (error) { + const status = extractStatusCode(error) + const param = error instanceof Error && "param" in error && error.param === "dimensions" + if ( + dimensions === undefined || + (status !== 400 && status !== 422) || + !(error instanceof Error) || + (!param && !/\bdimensions\b/i.test(error.message)) || + !/unsupported|not support|not allowed|not permitted|unknown|unrecognized|unexpected|extra|does not|doesn't/i.test( + error.message, + ) + ) { + throw error + } + + log.warn("Embedding endpoint rejected the dimensions parameter, retrying without it") + // Keep the configured store size. Omit dimensions only after a matching response succeeds. + const result = check(await send(undefined)) + if (result.projected) this.omitted.add(model) + return result + } + } + /** * Helper method to handle batch embedding with retries and exponential backoff * @param batchTexts Array of texts to embed in this batch @@ -223,33 +310,15 @@ export class OpenAICompatibleEmbedder implements IEmbedder { batchTexts: string[], model: string, ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { - // Use cached value for performance - const isFullUrl = this.isFullUrl - for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { // Check global rate limit before attempting request await this.waitForGlobalRateLimit() try { - let response: OpenAIEmbeddingResponse + const { projected } = await this.request(batchTexts, model) + if (!projected) throw new Error("Invalid response from embedding endpoint") - if (isFullUrl) { - // Use direct HTTP request for full endpoint URLs - response = await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model) - } else { - // Use OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create({ - input: batchTexts, - model: model, - // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 - // when processing numeric arrays, which breaks compatibility with models using larger dimensions. - // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. - encoding_format: "base64", - ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), - })) as OpenAIEmbeddingResponse - } - - return projectEmbeddingResponse(response) + return projected } catch (error) { log.error("OpenAI Compatible embedder batch error", { err: error instanceof Error ? error.message : String(error), @@ -295,32 +364,21 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Test with a minimal embedding request const testTexts = ["test"] const modelToUse = this.defaultModelId - let response: OpenAIEmbeddingResponse - if (this.isFullUrl) { - // Test direct HTTP request for full endpoint URLs - const ctl = new AbortController() - const timer = setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS) - try { - response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse, ctl.signal) - } finally { - clearTimeout(timer) - } - } else { - // Test using OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create( - { - input: testTexts, - model: modelToUse, - encoding_format: "base64", - ...(this.dimensions !== undefined ? { dimensions: this.dimensions } : {}), - }, - { - timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, - maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, - }, - )) as OpenAIEmbeddingResponse + // Abort full-endpoint requests on timeout. Base URLs get an SDK timeout instead. + const ctl = this.isFullUrl ? new AbortController() : undefined + const timer = ctl ? setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS) : undefined + + try { + const result = await this.request(testTexts, modelToUse, { + signal: ctl?.signal, + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }) + response = result.response + } finally { + if (timer) clearTimeout(timer) } const error = (response as { error?: string | { message?: string } }).error diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-http.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-http.test.ts new file mode 100644 index 00000000000..ee19c1a6600 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-http.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +for (const endpoint of ["/v1", "/v1/embeddings"]) { + for (const mode of ["supported", "unsupported", "unprocessable", "structured", "mismatch", "invalid"]) { + test(`${endpoint}: preserves configured dimensions with ${mode} endpoint`, async () => { + const root = await mkdtemp(join(tmpdir(), "kilo-dimensions-")) + const requests: Array<{ input: string[]; dimensions?: number }> = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const body: { input: string[]; dimensions?: number } = await req.json() + requests.push(body) + if (mode === "invalid") { + return Response.json( + { error: { message: "Invalid model", type: "invalid_request_error" } }, + { status: 400 }, + ) + } + if (mode !== "supported" && body.dimensions !== undefined) { + return Response.json( + { + error: { + message: + mode === "structured" + ? "Extra inputs are not permitted" + : "Unrecognized request argument: dimensions", + param: "dimensions", + }, + }, + { status: mode === "unprocessable" ? 422 : 400 }, + ) + } + const size = mode === "supported" ? (body.dimensions ?? 768) : mode === "mismatch" ? 768 : 4096 + const vector = new Float32Array(size).fill(0.25) + return Response.json({ + object: "list", + data: body.input.map((_, index) => ({ + object: "embedding", + index, + embedding: Buffer.from(vector.buffer).toString("base64"), + })), + model: "qwen3-embedding-8b", + usage: { prompt_tokens: body.input.length, total_tokens: body.input.length }, + }) + }, + }) + + try { + // A subprocess keeps the real SDK and LanceDB isolated from module mocks in other test files. + const script = ` + import assert from "node:assert/strict" + import { CodeIndexConfigManager } from "./src/indexing/config-manager.ts" + import { CodeIndexServiceFactory } from "./src/indexing/service-factory.ts" + import { CacheManager } from "./src/indexing/cache-manager.ts" + const root = ${JSON.stringify(root)} + const cfg = new CodeIndexConfigManager({ + enabled: true, + embedderProvider: "openai-compatible", + vectorStoreProvider: "lancedb", + modelId: "qwen3-embedding-8b", + modelDimension: 4096, + openAiCompatibleApiKey: "fixture-key", + openAiCompatibleBaseUrl: ${JSON.stringify(`http://127.0.0.1:${server.port}${endpoint}`)}, + }) + const factory = new CodeIndexServiceFactory(cfg, root, new CacheManager(root, root), root) + const embedder = factory.createEmbedder() + const validation = await factory.validateEmbedder(embedder) + const mode = ${JSON.stringify(mode)} + if (mode === "mismatch" || mode === "invalid") { + assert.equal(validation.valid, false) + if (mode === "mismatch") { + assert.match(validation.error, /768.*4096/) + await assert.rejects(embedder.createEmbeddings(["hello"]), /768.*4096/) + } + process.exit(0) + } + assert.equal(validation.valid, true, validation.error) + const store = factory.createVectorStore() + try { + await store.initialize() + const document = await embedder.createEmbeddings(["hello"]) + assert.equal(document.embeddings[0].length, 4096) + await store.upsertPoints([{ + id: "00000000-0000-4000-8000-000000000001", + vector: document.embeddings[0], + payload: { filePath: "fixture.ts", fileHash: "fixture", codeChunk: "hello", startLine: 1, endLine: 1 }, + }]) + const query = await embedder.createEmbeddings(["hello"]) + assert.equal(query.embeddings[0].length, 4096) + const results = await store.search(query.embeddings[0], undefined, 0, 5) + assert.equal(results[0]?.payload?.filePath, "fixture.ts") + } finally { + await store.close?.() + } + ` + const child = Bun.spawn([process.execPath, "-e", script], { + cwd: join(import.meta.dir, "../../../.."), + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const gate = Promise.withResolvers() + const timer = setTimeout(() => { + child.kill() + gate.reject(new Error("Dimension validation subprocess timed out")) + }, 15_000) + const [exit, , stderr] = await Promise.race([ + Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]), + gate.promise, + ]).finally(() => clearTimeout(timer)) + if (exit !== 0) throw new Error(stderr) + + expect(requests.at(0)?.dimensions).toBe(4096) + if (mode === "supported") { + expect(requests).toHaveLength(3) + expect(requests.every((req) => req.dimensions === 4096)).toBe(true) + } + if (mode === "unsupported" || mode === "unprocessable" || mode === "structured") { + expect(requests).toHaveLength(4) + expect(requests.slice(1).every((req) => req.dimensions === undefined)).toBe(true) + } + if (mode === "invalid") expect(requests).toHaveLength(1) + } finally { + server.stop(true) + await rm(root, { recursive: true, force: true }) + } + }, 20_000) + } +} diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts index cfc8c7d9a73..4c2c7b3c507 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts @@ -840,6 +840,195 @@ describe("OpenAICompatibleEmbedder", () => { }) }) + describe("dimension fallback", () => { + const rejection = () => Object.assign(new Error("Unrecognized request argument: dimensions"), { status: 400 }) + + test("retries without dimensions when the endpoint rejects them", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + mockEmbeddingsCreate.mockRejectedValueOnce(rejection()).mockResolvedValueOnce({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + const result = await embedder.createEmbeddings(["hello"]) + + expect(result.embeddings).toEqual([[0.25, 0.5]]) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2) + expect(mockEmbeddingsCreate.mock.calls.at(0)?.at(0)).toEqual({ + input: ["hello"], + model: "custom-embed", + encoding_format: "base64", + dimensions: 2, + }) + expect(mockEmbeddingsCreate.mock.calls.at(1)?.at(0)).toEqual({ + input: ["hello"], + model: "custom-embed", + encoding_format: "base64", + }) + }) + + test("stops sending dimensions after the first rejection", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + const success = { data: [{ embedding: base64String }], usage: { prompt_tokens: 1, total_tokens: 1 } } + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce(success) + .mockResolvedValueOnce(success) + + await embedder.createEmbeddings(["hello"]) + await embedder.createEmbeddings(["hello"]) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0)).toEqual({ + input: ["hello"], + model: "custom-embed", + encoding_format: "base64", + }) + }) + + test("validates successfully when dimensions are rejected", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 3, + }) + + mockEmbeddingsCreate.mockRejectedValueOnce(rejection()).mockResolvedValueOnce({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2) + }) + + test("keeps the server error for a 200 response without data", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + mockEmbeddingsCreate.mockResolvedValue({ error: { message: "model not found" } } as never) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("model not found") + }) + + test("keeps the server error for a 200 response without data when dimensions are configured", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + mockEmbeddingsCreate.mockResolvedValue({ error: { message: "model not found" } } as never) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("model not found") + }) + + test("reports a clear error when a batch response has no data", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { + dimensions: 2, + }) + + mockEmbeddingsCreate.mockResolvedValue({ error: { message: "model not found" } } as never) + + await expect(embedder.createEmbeddings(["hello"])).rejects.toThrow(/Invalid response from embedding endpoint/) + }) + + test("does not retry a 400 when no dimensions are configured", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + mockEmbeddingsCreate.mockRejectedValue(rejection()) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Configuration error. Please verify your embedder settings.") + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test.each([401, 403, 429, 500])("does not retry a %s error without dimensions", async (status) => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate.mockRejectedValue(Object.assign(new Error("Unsupported dimensions"), { status })) + + expect((await embedder.validateConfiguration()).valid).toBe(false) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test("does not cache omission when the fallback fails", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockRejectedValueOnce(Object.assign(new Error("Server error"), { status: 500 })) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + expect((await embedder.validateConfiguration()).valid).toBe(false) + expect((await embedder.validateConfiguration()).valid).toBe(true) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0).dimensions).toBe(2) + }) + + test("does not cache omission when the fallback returns no usable data", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce({ error: { message: "model not found" } } as never) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + expect((await embedder.validateConfiguration()).valid).toBe(false) + expect((await embedder.validateConfiguration()).valid).toBe(true) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0).dimensions).toBe(2) + }) + + test("does not apply one model's omission to another model", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + await embedder.createEmbeddings(["hello"]) + await embedder.createEmbeddings(["hello"], "another-model") + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0)).toEqual({ + input: ["hello"], + model: "another-model", + encoding_format: "base64", + dimensions: 2, + }) + }) + + test.each(["numeric", "base64"])("rejects a mismatched %s fallback without caching omission", async (format) => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + const vector = new Float32Array([0.25, 0.5, 0.75]) + const embedding = format === "numeric" ? Array.from(vector) : Buffer.from(vector.buffer).toString("base64") + mockEmbeddingsCreate + .mockRejectedValueOnce(rejection()) + .mockResolvedValueOnce({ data: [{ embedding }] }) + .mockResolvedValueOnce({ data: [{ embedding: [0.25, 0.5] }] }) + + expect((await embedder.validateConfiguration()).error).toContain("3 dimensions, but 2 are configured") + expect((await embedder.validateConfiguration()).valid).toBe(true) + expect(mockEmbeddingsCreate.mock.calls.at(2)?.at(0).dimensions).toBe(2) + }) + + test("checks every vector in a batch", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, "custom-embed", undefined, { dimensions: 2 }) + mockEmbeddingsCreate.mockResolvedValue({ data: [{ embedding: [0.25, 0.5] }, { embedding: [0.25] }] }) + + await expect(embedder.createEmbeddings(["hello", "world"])).rejects.toThrow("1 dimensions, but 2 are configured") + }) + }) + describe("validateConfiguration", () => { let embedder: OpenAICompatibleEmbedder diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts index 7b7d5f1539b..953f967f0f0 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -68,7 +68,7 @@ describe("CodeIndexServiceFactory", () => { modelDimension: 4096, }) - const testEmbedding = new Float32Array([0.25, 0.5]) + const testEmbedding = new Float32Array(4096).fill(0.25) const base64String = Buffer.from(testEmbedding.buffer).toString("base64") mockEmbeddingsCreate.mockResolvedValue({ @@ -330,7 +330,7 @@ describe("CodeIndexServiceFactory", () => { }) mockEmbeddingsCreate.mockResolvedValue({ - data: [{ embedding: [0.1, 0.2] }], + data: [{ embedding: Array.from({ length: 1024 }, () => 0.25) }], usage: { prompt_tokens: 1, total_tokens: 1 }, }) diff --git a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts index b78be6d8162..520f95f1ab2 100644 --- a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts @@ -51,6 +51,10 @@ export function createBrowserLifecycle(input: { if (!frame.projectId) return input.post({ type: "agentManager.browserFrame", ...frame }) }) + const cursors = browser.cursors((cursor) => { + if (!cursor.projectId) return + input.post({ type: "agentManager.browserCursor", ...cursor }) + }) let current: Panel | undefined return { attach(panel: Panel): void { @@ -91,6 +95,7 @@ export function createBrowserLifecycle(input: { current = undefined off() frames() + cursors() browser.unbind(owner) return browser.disposeAsync() }, diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 986a2ba407e..65baabe557b 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -129,6 +129,15 @@ export async function handleProjectMessage(m: AgentManagerInMessage, deps: Proje await setExpanded(m.projectId, m.expanded, deps) return true } + if (m.type === "agentManager.setProjectOrder") { + // Always push, so a failed write replaces the webview's optimistic order with the stored one. + if (Array.isArray(m.order)) + await deps.registry + .reorder(m.order.filter((id) => typeof id === "string")) + .catch((err) => deps.log("Failed to save project order:", err)) + deps.push() + return true + } return false } diff --git a/packages/kilo-vscode/src/agent-manager/project/registry.ts b/packages/kilo-vscode/src/agent-manager/project/registry.ts index a8099711b8e..1ca0f764e5e 100644 --- a/packages/kilo-vscode/src/agent-manager/project/registry.ts +++ b/packages/kilo-vscode/src/agent-manager/project/registry.ts @@ -201,6 +201,29 @@ export class ProjectRegistry { return true } + /** + * Apply a new catalog order. Unknown ids are ignored. Projects that are not + * in `ids` (for example added by another window) keep their relative order + * after the listed ones. + */ + reorder(ids: string[]): Promise { + return this.run(() => this.doReorder(ids)) + } + + private async doReorder(ids: string[]): Promise { + const current = this.fresh() + const wanted = new Set(ids) + const listed = [...wanted].flatMap((id) => current.projects.filter((p) => p.id === id)) + const next = [...listed, ...current.projects.filter((p) => !wanted.has(p.id))].map((p, index) => ({ + ...p, + order: index + 1, + })) + if (next.every((p, index) => p.id === current.projects[index]?.id && p.order === current.projects[index]?.order)) + return false + await this.write(next, current.pinnedExpanded) + return true + } + setExpanded(id: string, expanded: boolean): Promise { return this.run(() => this.doSetExpanded(id, expanded)) } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 03cf14356fb..72a44cf25d1 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -22,7 +22,13 @@ import type { SidebarTarget } from "./project/route" import type { TerminalDestination } from "./terminal-destination" import type { ScriptTerminalView } from "./ScriptTerminalManager" import type { BrowserFeedbackData } from "../shared/browser-feedback" -import type { BrowserFrame, BrowserInteraction, BrowserViewport, BrowserViewIdentity } from "../shared/browser-stream" +import type { + BrowserCursor, + BrowserFrame, + BrowserInteraction, + BrowserViewport, + BrowserViewIdentity, +} from "../shared/browser-stream" export type { TerminalFont } export type { ProjectSnapshot } @@ -507,6 +513,12 @@ interface BrowserFrameMessage extends BrowserFrame { sessionId: string } +interface BrowserCursorMessage extends BrowserCursor { + type: "agentManager.browserCursor" + projectId?: string + sessionId: string +} + interface BrowserDevtoolsMessage { type: "agentManager.browserDevtools" browserId: string @@ -562,6 +574,7 @@ export type AgentManagerOutMessage = | BrowserInspectionMessage | BrowserDevtoolsMessage | BrowserFrameMessage + | BrowserCursorMessage | RunStatusMessage | TerminalCreatedMessage | TerminalRestartedMessage @@ -651,6 +664,12 @@ interface SetProjectExpandedIn { expanded: boolean } +/** Persist the sidebar order of the additional (not pinned) projects. */ +interface SetProjectOrderIn { + type: "agentManager.setProjectOrder" + order: string[] +} + interface DeleteWorktreeIn { type: "agentManager.deleteWorktree" projectId?: string @@ -1288,6 +1307,7 @@ export type AgentManagerInMessage = | ActivateSelectionIn | RememberTargetIn | SetProjectExpandedIn + | SetProjectOrderIn | DeleteWorktreeIn | RemoveStaleWorktreeIn | RestoreWorktreeIn diff --git a/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts b/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts index 60169e19e04..5958ada7ae1 100644 --- a/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts +++ b/packages/kilo-vscode/src/browser-tab/BrowserTabProvider.ts @@ -16,7 +16,7 @@ import { type BrowserControlMessage, type BrowserControlSurface, } from "../services/browser-automation/browser-control" -import type { BrowserInteraction, BrowserViewIdentity, BrowserViewport } from "../shared/browser-stream" +import type { BrowserCursor, BrowserInteraction, BrowserViewIdentity, BrowserViewport } from "../shared/browser-stream" import type { BrowserReference } from "../shared/browser-feedback" import { buildWebviewHtml } from "../utils" @@ -91,6 +91,9 @@ export class BrowserTabProvider { vscode.Disposable.from({ dispose: this.opts.browser.subscribe((state) => this.state(state)) }), ) this.disposables.push(vscode.Disposable.from({ dispose: this.opts.browser.frames((frame) => this.frame(frame)) })) + this.disposables.push( + vscode.Disposable.from({ dispose: this.opts.browser.cursors((cursor) => this.cursor(cursor)) }), + ) } } @@ -216,6 +219,13 @@ export class BrowserTabProvider { entry.panel.webview.postMessage({ ...frame, type: "browserTab.frame" }) } + private cursor(cursor: BrowserCursor & { sessionId: string; projectId?: string }): void { + if (cursor.projectId) return + const entry = this.panels.get(cursor.sessionId) + if (!entry?.ready) return + entry.panel.webview.postMessage({ ...cursor, type: "browserTab.cursor" }) + } + private stateMessage(state: BrowserState) { return { type: "browserTab.state" as const, ...browserStatePayload(state) } } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts index e2e34e2e91a..8a9b7ffeede 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts @@ -19,6 +19,7 @@ import { BrowserNetwork } from "./browser-network" import { BrowserProxy, UNREACHABLE } from "./browser-proxy" import { parse } from "./browser-policy" import type { + BrowserCursor, BrowserFrame, BrowserInteraction, BrowserViewport, @@ -248,6 +249,7 @@ export class BrowserBroker { private readonly claims = new Map() private readonly listeners = new Set<(state: BrowserState) => void>() private readonly viewers = new Set<(frame: BrowserFrame & Pick) => void>() + private readonly pointers = new Set<(cursor: BrowserCursor & Pick) => void>() private readonly token = randomBytes(32).toString("hex") private readonly proxies = new Set() private gateway: BrowserProxy | undefined @@ -393,6 +395,11 @@ export class BrowserBroker { return () => this.viewers.delete(listener) } + cursors(listener: (cursor: BrowserCursor & Pick) => void): () => void { + this.pointers.add(listener) + return () => this.pointers.delete(listener) + } + async viewport( sessionId: string, projectId: string | undefined, @@ -419,6 +426,11 @@ export class BrowserBroker { for (const viewer of this.viewers) viewer({ ...frame, projectId: route.projectId, sessionId: route.sessionId }) }, this.opts.log, + (cursor) => { + if (!this.accepts(route.sessionId, route.projectId, cursor)) return + for (const listener of this.pointers) + listener({ ...cursor, projectId: route.projectId, sessionId: route.sessionId }) + }, ) return entry.stream } @@ -817,6 +829,7 @@ export class BrowserBroker { this.port = undefined this.listeners.clear() this.viewers.clear() + this.pointers.clear() } dispose(): void { diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts index bcfc8f5cade..ab3eefc3856 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts @@ -1,8 +1,10 @@ import { setTimeout as wait } from "node:timers/promises" import type { CDPSession, Page } from "playwright-core" import { + CURSORS, mergeWheel, VIEWPORT_LIMIT, + type BrowserCursor, type BrowserFrame, type BrowserInteraction, type BrowserViewport, @@ -23,6 +25,8 @@ type Cast = { const PAYLOAD = 2 * 1024 * 1024 const TEXT = 64 * 1024 const SETTLE = 250 +const BINDING = "__kiloCursor" +const WORLD = "kilo-cursor" const BUTTONS = { left: 1, right: 2, middle: 4 } as const const MODIFIERS = [ { key: "Alt", code: "AltLeft", keyCode: 18, mask: 1 }, @@ -201,6 +205,92 @@ function kept(current: BrowserViewport | undefined, next: BrowserViewport) { ) } +// Runs only in an isolated world. Read the hovered element, not the page's full text or layout. +function reporter(name: string, epoch: number, keywords: string[]) { + const globals = globalThis as unknown as Record + const cleanup = `${name}Cleanup` + const previous = globals[cleanup] + if (typeof previous === "function") previous() + const report = globals[name] + if (typeof report !== "function") return + const fields = ["text", "search", "email", "url", "tel", "password", "number"] + const resolve = (event: MouseEvent, node: unknown = event.composedPath()[0]) => { + if (!(node instanceof Element)) return "default" + const style = getComputedStyle(node) + const keyword = style.cursor.slice(style.cursor.lastIndexOf(",") + 1).trim() + if (keyword !== "auto") return keywords.includes(keyword) ? keyword : "default" + if (node instanceof HTMLTextAreaElement) return node.disabled ? "default" : "text" + if (node instanceof HTMLElement && node.isContentEditable) return "text" + if (node instanceof HTMLInputElement) return !node.disabled && fields.includes(node.type) ? "text" : "default" + if (node.closest("button, select")) return "default" + if (style.userSelect === "none") return "default" + const caret = document.caretRangeFromPoint?.(event.clientX, event.clientY) + const text = caret?.startContainer + if (!caret || !(text instanceof Text) || !text.length) return "default" + // Check only the characters next to the caret, which can fall on either side of the hovered glyph. + const range = document.createRange() + range.setStart(text, Math.max(0, caret.startOffset - 1)) + range.setEnd(text, Math.min(text.length, caret.startOffset + 1)) + return [...range.getClientRects()].some( + (rect) => + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom, + ) + ? "text" + : "default" + } + let last = "" + const emit = (value: string) => { + if (value === last) return + last = value + report(`${epoch}:${value}`) + } + const event = typeof PointerEvent === "undefined" ? "mousemove" : "pointermove" + const enter = event === "pointermove" ? "pointerover" : "mouseover" + const presses = + event === "pointermove" + ? (["pointerdown", "pointerup", "pointercancel"] as const) + : (["mousedown", "mouseup"] as const) + let scheduled = 0 + const move = (event: MouseEvent) => { + if (event.type === enter) last = "" + emit(resolve(event)) + } + const press = (event: MouseEvent) => { + const node = event.composedPath()[0] + cancelAnimationFrame(scheduled) + // Read pressed styles after the page's event handlers, without observing or changing the document. + scheduled = requestAnimationFrame(() => { + scheduled = 0 + emit( + resolve( + event, + node instanceof Element && node.isConnected ? node : document.elementFromPoint(event.clientX, event.clientY), + ), + ) + }) + } + const leave = () => { + cancelAnimationFrame(scheduled) + scheduled = 0 + emit("default") + } + addEventListener(event, move, { capture: true, passive: true }) + addEventListener(enter, move, { capture: true, passive: true }) + for (const event of presses) addEventListener(event, press, { capture: true, passive: true }) + document.addEventListener("mouseleave", leave, { passive: true }) + globals[cleanup] = () => { + cancelAnimationFrame(scheduled) + removeEventListener(event, move, true) + removeEventListener(enter, move, true) + for (const event of presses) removeEventListener(event, press, true) + document.removeEventListener("mouseleave", leave) + delete globals[cleanup] + } +} + export class BrowserStream { private session?: CDPSession private view?: BrowserViewport @@ -222,12 +312,17 @@ export class BrowserStream { private y = 0 private composing = false private readonly keys = new Map() + private readonly contexts = new Set() + private reporter: Promise = Promise.resolve() + private script?: string + private keyword?: string constructor( private readonly page: Page, private readonly identity: () => Scope, private readonly emit: (frame: BrowserFrame) => void, private readonly log: (...args: unknown[]) => void, + private readonly cursor?: (value: BrowserCursor) => void, ) { this.scope = { ...identity() } page.on("close", this.ended) @@ -268,6 +363,7 @@ export class BrowserStream { // the preview does not wait for a stream restart and a resize settle on each page load. if (this.casting === current && kept(current, next)) { this.casting = next + await this.install() return } await this.serial(async () => { @@ -305,6 +401,7 @@ export class BrowserStream { maxHeight: Math.round(next.height * (next.scale ?? 1)), everyNthFrame: 1, }) + await this.install() }).catch((error: unknown) => { this.casting = undefined if (!this.closed) throw error @@ -440,8 +537,34 @@ export class BrowserStream { const session = this.session if (!session) return await this.stop().catch(() => this.report("stop failed")) + await this.reporter + if (this.script) { + await session + .send("Page.removeScriptToEvaluateOnNewDocument", { identifier: this.script }) + .catch(() => this.report("cursor script removal failed")) + this.script = undefined + } + await Promise.all( + [...this.contexts].map((contextId) => + session + .send("Runtime.evaluate", { + contextId, + expression: `globalThis.${BINDING}Cleanup?.(); delete globalThis.${BINDING}`, + }) + .catch(() => this.report("cursor listener removal failed")), + ), + ) + if (this.cursor) + await session + .send("Runtime.removeBinding", { name: BINDING }) + .catch(() => this.report("cursor binding removal failed")) session.off("Page.screencastFrame", this.receive) session.off("Page.frameNavigated", this.navigated) + session.off("Runtime.bindingCalled", this.pointed) + session.off("Runtime.executionContextCreated", this.created) + session.off("Runtime.executionContextDestroyed", this.destroyed) + session.off("Runtime.executionContextsCleared", this.cleared) + this.contexts.clear() this.session = undefined await session.detach().catch(() => this.report("detach failed")) }) @@ -457,6 +580,7 @@ export class BrowserStream { if (this.closed || event.frame.parentId) return this.synchronize() this.reset() + void this.install() void this.serial(() => this.release()).catch(() => this.report("navigation release failed")) } @@ -480,6 +604,7 @@ export class BrowserStream { this.wheel = undefined this.outstanding = undefined this.buffered = undefined + this.keyword = undefined } private synchronize(): void { @@ -496,9 +621,65 @@ export class BrowserStream { session.on("Page.screencastFrame", this.receive) session.on("Page.frameNavigated", this.navigated) if (!this.closed) await session.send("Page.enable") + if (!this.closed && this.cursor) { + session.on("Runtime.bindingCalled", this.pointed) + session.on("Runtime.executionContextCreated", this.created) + session.on("Runtime.executionContextDestroyed", this.destroyed) + session.on("Runtime.executionContextsCleared", this.cleared) + await session + .send("Runtime.enable") + .then(() => session.send("Runtime.addBinding", { name: BINDING, executionContextName: WORLD })) + .catch(() => this.report("cursor binding setup failed")) + } return session } + private readonly created = (event: { context: { id: number; name: string } }): void => { + if (event.context.name === WORLD) this.contexts.add(event.context.id) + } + + private readonly destroyed = (event: { executionContextId: number }): void => { + this.contexts.delete(event.executionContextId) + } + + private readonly cleared = (): void => { + this.contexts.clear() + } + + private install(): Promise { + if (!this.cursor || this.closed || !this.session) return Promise.resolve() + this.synchronize() + const view = this.view + const epoch = this.epoch + this.reporter = this.reporter + .then(async () => { + const session = this.session + if (this.closed || !session || !view?.active || this.view !== view || this.epoch !== epoch) return + if (this.script) await session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: this.script }) + const result = await session.send("Page.addScriptToEvaluateOnNewDocument", { + source: `(${reporter.toString()})(${JSON.stringify(BINDING)}, ${epoch}, ${JSON.stringify([...CURSORS])})`, + worldName: WORLD, + runImmediately: true, + }) + this.script = result.identifier + }) + .catch(() => this.report("cursor reporter setup failed")) + return this.reporter + } + + private readonly pointed = (event: { name: string; payload: string; executionContextId: number }): void => { + if (this.closed || event.name !== BINDING || !this.contexts.has(event.executionContextId)) return + this.synchronize() + const view = this.casting + if (!view?.active || this.view !== view || typeof event.payload !== "string" || event.payload.length > 64) return + const prefix = `${this.epoch}:` + if (!event.payload.startsWith(prefix)) return + const cursor = event.payload.slice(prefix.length) + if (!CURSORS.has(cursor) || cursor === this.keyword) return + this.keyword = cursor + this.cursor?.({ ...this.scope, revision: view.revision, cursor }) + } + private async stop(): Promise { this.casting = undefined if (!this.started || !this.session) return diff --git a/packages/kilo-vscode/src/shared/browser-stream.ts b/packages/kilo-vscode/src/shared/browser-stream.ts index 75d956bb9db..235c89b75ad 100644 --- a/packages/kilo-vscode/src/shared/browser-stream.ts +++ b/packages/kilo-vscode/src/shared/browser-stream.ts @@ -24,6 +24,49 @@ export interface BrowserFrame extends BrowserViewIdentity { data: string } +export interface BrowserCursor extends BrowserViewIdentity { + cursor: string +} + +// Only CSS keywords can cross the browser boundary, never page-provided cursor images or URLs. +export const CURSORS = new Set([ + "default", + "none", + "context-menu", + "help", + "pointer", + "progress", + "wait", + "cell", + "crosshair", + "text", + "vertical-text", + "alias", + "copy", + "move", + "no-drop", + "not-allowed", + "grab", + "grabbing", + "all-scroll", + "col-resize", + "row-resize", + "n-resize", + "e-resize", + "s-resize", + "w-resize", + "ne-resize", + "nw-resize", + "se-resize", + "sw-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", + "zoom-in", + "zoom-out", +]) + export type BrowserInteraction = | { kind: "pointer" diff --git a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx index 97bef6e7d19..7504c631258 100644 --- a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx +++ b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx @@ -24,9 +24,11 @@ Object.assign(globalThis, { const { createSignal } = await import("solid-js") const { render } = await import("solid-js/web") -const { BrowserPanel } = await import("../../webview-ui/browser") +const { BrowserPanel, browserCursorEvent } = await import("../../webview-ui/browser") +const { StreamViewport } = await import("../../webview-ui/browser/StreamViewport") import type { BrowserCommand, BrowserEvent, BrowserLabels } from "../../webview-ui/browser/types" import type { BrowserReference } from "../../src/shared/browser-feedback" +import type { BrowserCursor } from "../../src/shared/browser-stream" const root = document.createElement("div") document.body.append(root) @@ -271,4 +273,85 @@ assert.equal(emptyRoot.querySelector(".am-browser-empty"), null) assert.equal((emptyRoot.querySelector("input") as HTMLInputElement | null)?.disabled, true) assert.equal((emptyRoot.querySelector(`button[aria-label="${labels().open}"]`) as HTMLButtonElement)?.disabled, true) disposeEmpty() + +// Exercise cursor identity checks with a visible viewport, without decoding or modifying any streamed image. +const bounds = { left: 0, top: 0, width: 400, height: 200, right: 400, bottom: 200 } as DOMRect +Object.assign(globalThis, { + ResizeObserver: class { + observe(target: Element) { + target.getBoundingClientRect = () => bounds + } + disconnect() {} + }, + IntersectionObserver: class { + constructor(private readonly notify: IntersectionObserverCallback) {} + observe(target: Element) { + this.notify( + [{ target, isIntersecting: true, intersectionRect: bounds } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ) + } + disconnect() {} + }, +}) +const viewport = document.createElement("div") +document.body.append(viewport) +const commands: BrowserCommand[] = [] +const readers = new Set<(event: BrowserEvent) => void>() +const [page, setPage] = createSignal(state) +const [inspecting, inspect] = createSignal(false) +const stop = render( + () => ( + scope} + state={page} + inspecting={inspecting} + label="Cursor viewport" + transport={{ + send: (command) => commands.push(command), + subscribe: (listener) => { + readers.add(listener) + return () => readers.delete(listener) + }, + }} + /> + ), + viewport, +) +const command = commands.findLast((command) => command.type === "viewport") +assert.ok(command?.type === "viewport" && command.viewport.active) +const identity = { browserId: command.browserId, navigation: command.navigation, revision: command.viewport.revision } +const canvas = viewport.querySelector("canvas")! +const cursor = (value: Partial = {}) => { + const event: BrowserEvent = { type: "cursor", value: { ...identity, scope, cursor: "pointer", ...value } } + for (const reader of readers) reader(event) +} +assert.equal(canvas.style.cursor, "default") +for (const value of [ + { browserId: "retired" }, + { navigation: identity.navigation + 1 }, + { revision: identity.revision - 1 }, + { scope: { ...scope, sessionId: "other" } }, + { scope: { ...scope, projectId: "other" } }, + { cursor: "url(https://example.com/cursor), pointer" }, + { cursor: "invalid" }, +]) { + cursor(value) + assert.equal(canvas.style.cursor, "default") +} +assert.equal(browserCursorEvent(scope, { ...identity, cursor: "url(https://example.com/cursor), pointer" }), undefined) +cursor() +assert.equal(canvas.style.cursor, "pointer") +inspect(true) +assert.equal(canvas.style.cursor, "crosshair") +cursor({ cursor: "grab" }) +assert.equal(canvas.style.cursor, "crosshair") +inspect(false) +assert.equal(canvas.style.cursor, "grab") +setPage({ ...state, navigation: 1 }) +assert.equal(canvas.style.cursor, "default") +cursor() +assert.equal(canvas.style.cursor, "default") +stop() +assert.equal(readers.size, 0) await window.happyDOM.close() diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts index aa6a3d9fc4c..00577b2d9e2 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts @@ -451,6 +451,35 @@ describe("handleProjectMessage", () => { expect(registry.list()).toEqual([]) }) + it("persists the project order and keeps unlisted projects last", async () => { + const { deps, registry, contexts, storage, calls } = setup() + for (const id of ["a", "b", "c", "d"]) await registry.add({ id, root: `/${id}` }) + + await handleProjectMessage(msg("agentManager.setProjectOrder", { order: ["c", "unknown", "a", "c", "b"] }), deps) + + expect(new ProjectRegistry(storage).list().map((p) => [p.id, p.order])).toEqual([ + ["c", 1], + ["a", 2], + ["b", 3], + ["d", 4], + ]) + expect(contexts.snapshots().map((p) => p.id)).toEqual([projectIdFor(WORKSPACE), "c", "a", "b", "d"]) + expect(calls.push).toBe(1) + }) + + it("pushes the stored order when saving the project order fails", async () => { + const { deps, registry, storage, calls } = setup() + for (const id of ["a", "b"]) await registry.add({ id, root: `/${id}` }) + storage.write = () => { + throw new Error("disk full") + } + + await handleProjectMessage(msg("agentManager.setProjectOrder", { order: ["b", "a"] }), deps) + + expect(registry.list().map((p) => p.id)).toEqual(["a", "b"]) + expect(calls.push).toBe(1) + }) + it("does not initialize missing projects on expand", async () => { const repo = gitRepo() const { deps, registry, calls } = setup() diff --git a/packages/kilo-vscode/tests/unit/browser-message.test.ts b/packages/kilo-vscode/tests/unit/browser-message.test.ts index 0c25dc63059..184149edee0 100644 --- a/packages/kilo-vscode/tests/unit/browser-message.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-message.test.ts @@ -85,6 +85,51 @@ const request = (id: string): AgentManagerInMessage => ({ }) describe("browser inspection responses", () => { + test("routes cursor reports with project, session, and viewport identity and drops revoked ownership", async () => { + const view = await fixture() + const messages: AgentManagerOutMessage[] = [] + const lifecycle = createBrowserLifecycle({ + browser: view.broker, + host: view.host, + contexts: () => view.contexts, + post: (message) => messages.push(message), + openPanel: () => {}, + log: () => {}, + }) + let source = "" + view.protocol.on("Page.addScriptToEvaluateOnNewDocument", (params: { source: string }) => { + source = params.source + }) + const viewport = { width: 400, height: 300, revision: 1, active: true } + await view.broker.viewport("session", "project", view.current.browserId, view.current.navigation, viewport) + view.protocol.emit("Runtime.executionContextCreated", { context: { id: 7, name: "kilo-cursor" } }) + const report = (cursor: string) => + view.protocol.emit("Runtime.bindingCalled", { + name: "__kiloCursor", + payload: `${source.match(/\)\("__kiloCursor", (\d+),/)?.at(1)}:${cursor}`, + executionContextId: 7, + }) + report("pointer") + report("url(https://example.com/cursor), text") + expect(messages).toEqual([ + { + type: "agentManager.browserCursor", + projectId: "project", + sessionId: "session", + browserId: view.current.browserId, + navigation: view.current.navigation, + revision: 1, + cursor: "pointer", + }, + ]) + view.contexts.byDirectory = () => undefined + report("text") + expect(messages).toHaveLength(1) + await lifecycle.dispose() + report("grab") + expect(messages.filter((message) => message.type === "agentManager.browserCursor")).toHaveLength(1) + }) + test.each([true, false])("forwards a missing browser before an entry exists (system Chrome: %s)", async (system) => { const view = await fixture() const broker = new BrowserBroker({ diff --git a/packages/kilo-vscode/tests/unit/browser-stream.test.ts b/packages/kilo-vscode/tests/unit/browser-stream.test.ts index fe67bdd4203..52fbe38b915 100644 --- a/packages/kilo-vscode/tests/unit/browser-stream.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-stream.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "node:events" import { existsSync } from "node:fs" import { chromium, type Browser, type Page } from "playwright-core" import { BrowserStream } from "../../src/services/browser-automation/browser-stream" -import { source, type BrowserFrame, type BrowserInteraction } from "../../src/shared/browser-stream" +import { source, type BrowserCursor, type BrowserFrame, type BrowserInteraction } from "../../src/shared/browser-stream" const streams: BrowserStream[] = [] const pages: Page[] = [] @@ -24,7 +24,7 @@ function jpeg(width = view.width, height = view.height, length = 17) { return data } -function protocol() { +function protocol(cursor?: (value: BrowserCursor) => void) { const calls: Array<{ method: string; params?: Record }> = [] const hooks = new Map Promise>() const frames: BrowserFrame[] = [] @@ -35,6 +35,7 @@ function protocol() { send: async (method: string, params?: Record) => { calls.push({ method, params }) await hooks.get(method)?.() + if (method === "Page.addScriptToEvaluateOnNewDocument") return { identifier: "cursor-script" } return {} }, detach: async () => { @@ -69,6 +70,7 @@ function protocol() { frames.push(frame) }, (...args) => logs.push(args), + cursor, ) streams.push(stream) const send = (id: number, data?: string, size = view) => { @@ -87,6 +89,60 @@ afterEach(async () => { }) describe("BrowserStream protocol lifecycle", () => { + test("accepts only changed cursor keywords from the current isolated reporter and cleans it up", async () => { + const cursors: BrowserCursor[] = [] + const fixture = protocol((value) => cursors.push(value)) + await fixture.stream.configure(view) + fixture.session.emit("Runtime.executionContextCreated", { context: { id: 7, name: "kilo-cursor" } }) + const epoch = () => { + const source = fixture.calls.findLast((call) => call.method === "Page.addScriptToEvaluateOnNewDocument")?.params + ?.source + return String(source) + .match(/\)\("__kiloCursor", (\d+),/) + ?.at(1) + } + const report = (cursor: string, stamp = epoch(), context = 7) => + fixture.session.emit("Runtime.bindingCalled", { + name: "__kiloCursor", + payload: `${stamp}:${cursor}`, + executionContextId: context, + }) + report("pointer") + report("pointer") + for (const cursor of ["url(https://example.com/cursor), pointer", "invalid", "auto", "pointer:extra"]) + report(cursor) + report("text", epoch(), 8) + expect(cursors).toEqual([{ browserId: "browser", navigation: 1, revision: 1, cursor: "pointer" }]) + const stale = epoch() + await fixture.stream.configure({ ...view, revision: 2 }) + report("text", stale) + report("pointer") + expect(cursors.at(-1)).toMatchObject({ navigation: 1, revision: 2, cursor: "pointer" }) + expect(cursors).toHaveLength(2) + fixture.scope.navigation++ + report("text") + expect(cursors).toHaveLength(2) + fixture.session.emit("Page.frameNavigated", { frame: { id: "main" } }) + await fixture.stream.configure({ ...view, revision: 3 }) + report("pointer") + expect(cursors.at(-1)).toMatchObject({ navigation: 2, revision: 3, cursor: "pointer" }) + fixture.session.emit("Runtime.executionContextDestroyed", { executionContextId: 7 }) + report("text") + expect(cursors).toHaveLength(3) + fixture.session.emit("Runtime.executionContextCreated", { context: { id: 9, name: "kilo-cursor" } }) + await fixture.stream.close() + expect(fixture.calls).toContainEqual({ + method: "Runtime.evaluate", + params: { contextId: 9, expression: "globalThis.__kiloCursorCleanup?.(); delete globalThis.__kiloCursor" }, + }) + expect(fixture.calls).toContainEqual({ method: "Runtime.removeBinding", params: { name: "__kiloCursor" } }) + expect(fixture.calls.at(-2)?.method).toBe("Runtime.removeBinding") + expect(fixture.session.listenerCount("Runtime.bindingCalled")).toBe(0) + expect(fixture.session.listenerCount("Runtime.executionContextCreated")).toBe(0) + report("text") + expect(cursors).toHaveLength(3) + }) + test("keeps frame sources in data images without treating payloads as external URLs", () => { const payload = jpeg().toString("base64") expect(source(payload)).toBe(`data:image/jpeg;base64,${payload}`) @@ -485,7 +541,7 @@ describe.skipIf(!executable)("BrowserStream Chromium", () => { await browser?.close() }) - async function fixture() { + async function fixture(cursor?: (value: BrowserCursor) => void) { const page = await browser.newPage({ viewport: { width: 640, height: 480 }, deviceScaleFactor: 2 }) pages.push(page) await page.setContent(` @@ -541,6 +597,7 @@ describe.skipIf(!executable)("BrowserStream Chromium", () => { events.emit("frame", frame) }, (...args) => logs.push(args), + cursor, ) streams.push(stream) const next = (predicate: (frame: BrowserFrame) => boolean = () => true) => { @@ -578,6 +635,143 @@ describe.skipIf(!executable)("BrowserStream Chromium", () => { }) } + test("reports real passive cursors, preserves explicit default, and removes listeners on an existing page", async () => { + const cursors: BrowserCursor[] = [] + const events = new EventEmitter() + const { stream, page, scope } = await fixture((value) => { + cursors.push(value) + events.emit("cursor", value) + }) + const html = `
+ Link + +

Explicit default text

+

Native text

+
Resize
+
Custom cursor fallback
+ +
` + const origin = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { "content-type": "text/html" } }) }) + await page.goto(origin.url.href).finally(() => origin.stop(true)) + const before = await page.locator("#cursors").evaluate((node) => node.outerHTML) + const probe = await page.context().newCDPSession(page) + const contexts: number[] = [] + probe.on("Runtime.executionContextCreated", ({ context }) => { + if (context.name === "kilo-cursor") contexts.push(context.id) + }) + await probe.send("Runtime.enable") + await stream.configure(view) + const check = async (cursor: string, action: () => Promise) => { + const result = Promise.withResolvers() + const receive = (value: BrowserCursor) => { + events.off("cursor", receive) + result.resolve(value) + } + events.on("cursor", receive) + await action() + const value = await Promise.race([ + result.promise, + Bun.sleep(1000).then(() => { + throw new Error(`Expected cursor ${cursor}, last report ${cursors.at(-1)?.cursor}`) + }), + ]) + expect(value).toMatchObject({ + browserId: scope.browserId, + navigation: scope.navigation, + revision: view.revision, + cursor, + }) + } + try { + await check("pointer", () => page.mouse.move(10, 20)) + await page.mouse.move(15, 20) + expect(cursors).toHaveLength(1) + await check("text", () => page.mouse.move(10, 60)) + await check("default", () => page.mouse.move(10, 100)) + await check("text", () => page.mouse.move(10, 140)) + await check("ew-resize", () => page.mouse.move(10, 180)) + await check("grab", () => page.mouse.move(10, 220)) + await check("grabbing", () => page.mouse.down()) + await check("grab", () => page.mouse.up()) + await check("default", () => page.mouse.move(130, 260)) + expect(await page.locator("#cursors").evaluate((node) => node.outerHTML)).toBe(before) + await page.evaluate(() => { + const frame = document.createElement("iframe") + frame.srcdoc = 'Child' + frame.style.cssText = "position:absolute;top:300px;left:0;width:300px;height:60px;border:0" + document.body.append(frame) + }) + await page.waitForFunction(() => document.querySelector("iframe")?.contentDocument?.body?.style.cursor === "grab") + await check("pointer", () => page.mouse.move(10, 20)) + await check("default", () => page.mouse.move(400, 20)) + await check("grab", () => page.mouse.move(10, 320)) + await check("default", async () => { + await page.locator("iframe").evaluate((node) => node.remove()) + await page.mouse.move(400, 20) + }) + expect(await page.evaluate(() => typeof (globalThis as unknown as Record).__kiloCursor)).toBe( + "undefined", + ) + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) scope.navigation++ + }) + await page.goto(`data:text/html,${encodeURIComponent(html)}`) + await stream.configure({ ...view, revision: 2 }) + await page.mouse.move(10, 20) + await page.evaluate(() => undefined) + expect(cursors.at(-1)).toMatchObject({ navigation: 2, revision: 2, cursor: "pointer" }) + const contextId = contexts.at(-1) + expect(contextId).toBeDefined() + const expression = "[typeof globalThis.__kiloCursorCleanup, typeof globalThis.__kiloCursor]" + const objectId = (await probe.send("Runtime.evaluate", { contextId, expression: "window" })).result.objectId! + expect( + (await probe.send("DOMDebugger.getEventListeners", { objectId })).listeners.filter( + (event) => event.type === "pointermove", + ), + ).toHaveLength(1) + expect( + (await probe.send("Runtime.evaluate", { contextId, expression, returnByValue: true })).result.value, + ).toEqual(["function", "function"]) + await stream.close() + expect( + (await probe.send("DOMDebugger.getEventListeners", { objectId })).listeners.filter( + (event) => event.type === "pointermove", + ), + ).toHaveLength(0) + expect( + (await probe.send("Runtime.evaluate", { contextId, expression, returnByValue: true })).result.value, + ).toEqual(["undefined", "undefined"]) + const count = cursors.length + await page.mouse.move(10, 180) + await page.evaluate(() => undefined) + expect(cursors).toHaveLength(count) + expect(page.isClosed()).toBe(false) + const reports: BrowserCursor[] = [] + const received = Promise.withResolvers() + const reopened = new BrowserStream( + page, + () => scope, + () => {}, + () => {}, + (value) => { + reports.push(value) + received.resolve(value) + }, + ) + streams.push(reopened) + await reopened.configure({ ...view, revision: 3 }) + await page.mouse.move(10, 20) + expect(await received.promise).toMatchObject({ navigation: 2, revision: 3, cursor: "pointer" }) + expect(reports).toHaveLength(1) + expect(cursors).toHaveLength(count) + } finally { + await probe.detach() + } + }, 10000) + test("streams bounded JPEGs from the existing page and honors monotonic viewport revisions", async () => { const { stream, page, next, frames } = await fixture() await stream.configure({ ...view, width: 1, height: 9999, revision: 0, active: false }) diff --git a/packages/kilo-vscode/tests/unit/link-modifier.test.ts b/packages/kilo-vscode/tests/unit/link-modifier.test.ts new file mode 100644 index 00000000000..65263a29a68 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/link-modifier.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { forcesExternalBrowser } from "../../webview-ui/src/utils/link-modifier" + +describe("forcesExternalBrowser", () => { + test("forces the system browser on shift-click while the Integrated Browser handles links", () => { + expect(forcesExternalBrowser({ shiftKey: true }, true)).toBe(true) + }) + + test("leaves a plain click to the Integrated Browser", () => { + expect(forcesExternalBrowser({ shiftKey: false }, true)).toBe(false) + }) + + test("ignores shift when links do not route through the Integrated Browser", () => { + expect(forcesExternalBrowser({ shiftKey: true }, false)).toBe(false) + expect(forcesExternalBrowser({ shiftKey: true }, undefined)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-order.test.ts b/packages/kilo-vscode/tests/unit/project-order.test.ts new file mode 100644 index 00000000000..43e137f3206 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-order.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test" +import { applyProjectOrder, moveProject, projectDrop } from "../../webview-ui/agent-manager/project-order" + +const rows = [ + { id: "a", top: 0, bottom: 30 }, + // An expanded project is much taller than a collapsed one. + { id: "b", top: 30, bottom: 330 }, + { id: "c", top: 330, bottom: 360 }, +] + +describe("projectDrop", () => { + it("uses the row half under the pointer", () => { + expect(projectDrop(rows, 10)).toEqual({ id: "a", after: false }) + expect(projectDrop(rows, 100)).toEqual({ id: "b", after: false }) + expect(projectDrop(rows, 200)).toEqual({ id: "b", after: true }) + }) + + it("clamps positions outside the rows", () => { + expect(projectDrop(rows, -50)).toEqual({ id: "a", after: false }) + expect(projectDrop(rows, 900)).toEqual({ id: "c", after: true }) + expect(projectDrop([], 10)).toBeUndefined() + }) +}) + +describe("moveProject", () => { + it("moves a project before or after the target", () => { + expect(moveProject(["a", "b", "c"], "a", { id: "c", after: true })).toEqual(["b", "c", "a"]) + expect(moveProject(["a", "b", "c"], "c", { id: "a", after: false })).toEqual(["c", "a", "b"]) + expect(moveProject(["a", "b", "c"], "a", { id: "b", after: true })).toEqual(["b", "a", "c"]) + }) + + it("returns undefined when the order does not change", () => { + expect(moveProject(["a", "b", "c"], "a", { id: "b", after: false })).toBeUndefined() + expect(moveProject(["a", "b", "c"], "b", { id: "a", after: true })).toBeUndefined() + expect(moveProject(["a", "b", "c"], "b", { id: "b", after: true })).toBeUndefined() + expect(moveProject(["a", "b"], "x", { id: "a", after: false })).toBeUndefined() + }) +}) + +describe("applyProjectOrder", () => { + const projects = [ + { id: "p", pinned: true }, + { id: "a", pinned: false }, + { id: "b", pinned: false }, + { id: "c", pinned: false }, + ] + + it("keeps the pinned project first and unlisted projects last", () => { + expect(applyProjectOrder(projects, ["c", "p", "a"]).map((p) => p.id)).toEqual(["p", "c", "a", "b"]) + }) + + it("returns the snapshots without a local order", () => { + expect(applyProjectOrder(projects)).toBe(projects) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-fold.test.ts b/packages/kilo-vscode/tests/unit/prompt-fold.test.ts new file mode 100644 index 00000000000..308ffd9ad9c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-fold.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test" +import { fold } from "../../webview-ui/src/components/chat/prompt-fold" + +const base = { pinned: 48, reserve: 210, slot: 26 } +const edge = base.reserve + base.pinned + +describe("fold", () => { + it("keeps every action when they fit", () => { + expect(fold({ ...base, width: edge + 4 * base.slot, count: 4 })).toBe(4) + }) + + it("folds progressively and reserves a slot for the overflow button", () => { + expect(fold({ ...base, width: edge + 4 * base.slot - 1, count: 4 })).toBe(2) + expect(fold({ ...base, width: edge + 3 * base.slot, count: 4 })).toBe(2) + expect(fold({ ...base, width: edge + 2 * base.slot, count: 4 })).toBe(1) + expect(fold({ ...base, width: edge + base.slot, count: 4 })).toBe(0) + }) + + it("never goes below zero or above the count", () => { + expect(fold({ ...base, width: 100, count: 4 })).toBe(0) + expect(fold({ ...base, width: 2000, count: 3 })).toBe(3) + }) + + it("counts wider pinned actions, such as the goal send button", () => { + expect(fold({ ...base, width: edge + 4 * base.slot, pinned: base.pinned + 60, count: 4 })).toBeLessThan(4) + }) + + it("scales with a larger font", () => { + const big = { pinned: 64, reserve: 280, slot: 34 } + expect(fold({ ...big, width: big.reserve + big.pinned + 4 * big.slot, count: 4 })).toBe(4) + expect(fold({ ...big, width: big.reserve + big.pinned + 4 * big.slot - 1, count: 4 })).toBe(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts index 0b8b376baad..ec4db3e22cb 100644 --- a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts +++ b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts @@ -148,7 +148,7 @@ describe("Agent Manager sandbox startup", () => { }) test("places the sandbox toggle with prompt actions instead of model selectors", () => { - const selectors = dialog.indexOf('
') + const selectors = dialog.indexOf("', selectors) const sandbox = dialog.indexOf("
-
- 1}> - - - - - selectVariant(DEFAULT_VARIANT)} - allowClear - clearLabel={t("common.default")} - trigger={WORKTREE_PROMPT_SCOPE} - portal={false} - deferDismiss - cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false} - /> - -
+ 1}> + + + } + model={ + + + + } + variant={ + + selectVariant(DEFAULT_VARIANT)} + allowClear + clearLabel={t("common.default")} + heading={t("prompt.thinking.tooltip")} + trigger={WORKTREE_PROMPT_SCOPE} + portal={false} + deferDismiss + cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false} + /> + + } + />
= (props) => { onExpand={(projectId, expanded) => vscode.postMessage({ type: "agentManager.setProjectExpanded", projectId, expanded }) } + onReorder={(order) => vscode.postMessage({ type: "agentManager.setProjectOrder", order })} count={(projectId) => { const state = props.states[projectId] return state ? state.worktrees.length + 1 : undefined diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx index 1e2d8b7ebe8..6e881473e59 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx @@ -1,6 +1,19 @@ /** @jsxImportSource solid-js */ -import { For, Show, untrack, type Accessor, type Component, type JSX } from "solid-js" +import { + For, + Show, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + untrack, + type Accessor, + type Component, + type JSX, +} from "solid-js" +import { DragDropProvider, DragDropSensors, DragOverlay, createDraggable, type DragEvent } from "@thisbeyond/solid-dnd" import { Icon } from "@kilocode/kilo-ui/icon" import type { LanguageContextValue } from "../src/context/language" import type { AgentProjectSnapshot } from "../src/types/messages" @@ -8,6 +21,8 @@ import { ProjectsFooter } from "./ProjectsFooter" import { SidebarSectionHeader } from "./SidebarSectionHeader" import { ProjectRowActions } from "./ProjectRowActions" import { ProjectAvatar } from "./ProjectAvatar" +import { ConstrainDragXAxis } from "./constrain-drag-x" +import { applyProjectOrder, moveProject, projectDrop, type ProjectDrop } from "./project-order" interface ProjectsSectionProps { projects: AgentProjectSnapshot[] @@ -19,6 +34,7 @@ interface ProjectsSectionProps { onSelect: (id: string) => void onRemove: (id: string) => void onExpand: (id: string, expanded: boolean) => void + onReorder: (order: string[]) => void onHistory: (id: string) => void onNew: (id: string) => void onCreate: (id: string) => void @@ -37,70 +53,171 @@ const ProjectBodySlot: Component<{ /** * Stable project accordion. Every expanded project renders the same real body; - * active state only controls detail-pane emphasis. + * active state only controls detail-pane emphasis. Additional projects can be + * reordered by dragging their header. The pinned workspace project stays first. */ -export const ProjectsSection: Component = (props) => ( -
- {props.t("agentManager.projects")}} - actions={props.tools} - /> -
- project.id)}> - {(id) => { - const project = () => props.projects.find((item) => item.id === id)! - return ( -
- } - title={project().missing ? props.t("agentManager.project.missing") : project().root} - label={ - <> - {project().label} - - ({props.count(project().id)}) - - - - - - } - actions={ - props.onCreate(project().id)} - onNew={() => props.onNew(project().id)} - onSection={() => props.onSection(project().id)} - onHistory={() => props.onHistory(project().id)} - onSettings={() => props.onSettings(project().id)} - onRemove={() => props.onRemove(project().id)} +export const ProjectsSection: Component = (props) => { + // Optimistic order after a drop, until the extension pushes new snapshots. + const [local, setLocal] = createSignal() + const [dragging, setDragging] = createSignal() + const [drop, setDrop] = createSignal() + const nodes = new Map() + let list: HTMLDivElement | undefined + const projects = createMemo(() => applyProjectOrder(props.projects, local())) + const movable = createMemo(() => + projects() + .filter((project) => !project.pinned) + .map((project) => project.id), + ) + + createEffect( + on( + () => props.projects, + () => setLocal(undefined), + { defer: true }, + ), + ) + + const finish = () => { + setDragging(undefined) + setDrop(undefined) + document.body.classList.remove("am-project-dragging-active") + } + onCleanup(finish) + + const onDragStart = (event: DragEvent) => { + setDragging(String(event.draggable.id)) + document.body.classList.add("am-project-dragging-active") + } + + const onDragMove = (event: DragEvent) => { + // With an overlay, solid-dnd reports a zero transform for the draggable, so read the overlay. + const center = (event.overlay ?? event.draggable).transformed.center + // A release outside the sidebar cancels the move. + if (list && center.x > list.getBoundingClientRect().right) { + setDrop(undefined) + return + } + const from = String(event.draggable.id) + const y = center.y + const rows = movable().flatMap((id) => { + const rect = nodes.get(id)?.getBoundingClientRect() + return rect ? [{ id, top: rect.top, bottom: rect.bottom }] : [] + }) + const next = projectDrop(rows, y) + setDrop(next && moveProject(movable(), from, next) ? next : undefined) + } + + const onDragEnd = (event: DragEvent) => { + const target = drop() + finish() + if (!target) return + const order = moveProject(movable(), String(event.draggable.id), target) + if (!order) return + setLocal(order) + props.onReorder(order) + } + + return ( +
+ {props.t("agentManager.projects")}} + actions={props.tools} + /> + + + +
+ project.id)}> + {(id) => { + const project = () => projects().find((item) => item.id === id)! + // The pinned workspace project is not part of the stored catalog order. + const draggable = untrack(project).pinned ? undefined : createDraggable(id) + onCleanup(() => nodes.delete(id)) + return ( +
nodes.set(id, el)} + class="am-project" + classList={{ + "am-project-dragging": dragging() === id, + "am-project-drop-before": drop()?.id === id && !drop()?.after, + "am-project-drop-after": drop()?.id === id && drop()?.after, + }} + > + { + if (!draggable) return + draggable.ref(el) + // The row action buttons and menus keep their own pointer behavior. + el.addEventListener("pointerdown", (event) => { + if (event.target instanceof Element && event.target.closest(".am-sidebar-header-actions")) + return + draggable.dragActivators.onpointerdown?.(event) + }) + }} + class="am-project-item" + expanded={project().expanded} + ariaLabel={project().label} + icon={} + title={project().missing ? props.t("agentManager.project.missing") : project().root} + label={ + <> + {project().label} + + ({props.count(project().id)}) + + + + + + } + actions={ + props.onCreate(project().id)} + onNew={() => props.onNew(project().id)} + onSection={() => props.onSection(project().id)} + onHistory={() => props.onHistory(project().id)} + onSettings={() => props.onSettings(project().id)} + onRemove={() => props.onRemove(project().id)} + /> + } + onToggle={() => { + if (project().missing) return + const expanded = !project().expanded + props.onExpand(project().id, expanded) + }} + onClick={() => { + if (project().missing) return + if (!project().active) props.onSelect(project().id) + }} /> - } - onToggle={() => { - if (project().missing) return - const expanded = !project().expanded - props.onExpand(project().id, expanded) - }} - onClick={() => { - if (project().missing) return - if (!project().active) props.onSelect(project().id) - }} - /> - - - -
- ) - }} -
+ + + +
+ ) + }} + +
+ + {(() => { + const project = projects().find((item) => item.id === dragging()) + if (!project) return null + return ( +
+ + {project.label} +
+ ) + })()} +
+ +
- -
-) + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx index 74fa58d291c..0cc488d2589 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarSectionHeader.tsx @@ -15,12 +15,14 @@ interface Props { title?: string ariaLabel?: string disabled?: boolean + ref?: (el: HTMLDivElement) => void } /** Shared layout for sidebar headings with a fixed leading control column. */ export const SidebarSectionHeader: Component = (props) => { return (
{ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index a82b9c71de4..96f2a5e5daf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -490,6 +490,45 @@ html[data-theme="kilo-vscode"] border-bottom: 0; } +/* Project drag and drop: dim the dragged project and draw the insertion line. */ +.am-project-dragging { + opacity: 0.4; +} + +.am-project-drop-before::before, +.am-project-drop-after::after { + content: ""; + position: absolute; + left: 0; + right: 0; + z-index: 2; + height: 2px; + background: var(--border-focus); + pointer-events: none; +} + +.am-project-drop-before::before { + top: -1px; +} + +.am-project-drop-after::after { + bottom: -1px; +} + +body.am-project-dragging-active, +body.am-project-dragging-active * { + cursor: grabbing !important; +} + +/* The overlay is portalled to the body. Keep it above the sticky project headers. */ +.am-project-drag-layer { + z-index: 10; +} + +.am-project-overlay { + font-weight: 500; +} + .am-project-item { min-height: 34px; /* Leading column aligns with the card icons below it (.am-local-item and diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index eb0082b5da5..cd76d3e4538 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -267,7 +267,7 @@ export const PRPanel: Component = (props) => { y <= item.bottom) ?? last + return { id: row.id, after: y > (row.top + row.bottom) / 2 } +} + +/** Return the order after moving `from` to `drop`, or undefined when the order does not change. */ +export function moveProject(ids: string[], from: string, drop: ProjectDrop): string[] | undefined { + if (from === drop.id || !ids.includes(from) || !ids.includes(drop.id)) return + const rest = ids.filter((id) => id !== from) + const index = rest.indexOf(drop.id) + (drop.after ? 1 : 0) + const next = [...rest.slice(0, index), from, ...rest.slice(index)] + if (next.every((id, i) => id === ids[i])) return + return next +} + +/** + * Apply a local project order on top of the latest snapshots. Pinned projects + * stay first. Projects missing from `order` keep their snapshot order at the end. + */ +export function applyProjectOrder(projects: T[], order?: string[]): T[] { + if (!order) return projects + const movable = projects.filter((project) => !project.pinned) + const listed = order.flatMap((id) => movable.filter((project) => project.id === id)) + const rest = movable.filter((project) => !order.includes(project.id)) + return [...projects.filter((project) => project.pinned), ...listed, ...rest] +} diff --git a/packages/kilo-vscode/webview-ui/browser-tab/messages.ts b/packages/kilo-vscode/webview-ui/browser-tab/messages.ts index 20a38340c81..be5340ba881 100644 --- a/packages/kilo-vscode/webview-ui/browser-tab/messages.ts +++ b/packages/kilo-vscode/webview-ui/browser-tab/messages.ts @@ -1,5 +1,6 @@ import type { ExtensionMessage, WebviewMessage } from "../src/types/messages" import { + browserCursorEvent, browserDevtoolsEvent, browserFrameEvent, browserInspectionEvent, @@ -60,6 +61,9 @@ export function command(value: BrowserCommand): WebviewMessage { } export function event(message: ExtensionMessage): BrowserEvent | undefined { + if (message.type === "browserTab.cursor") { + return browserCursorEvent(browserScope(message.sessionId), message) + } if (message.type === "browserTab.frame") { return browserFrameEvent(browserScope(message.sessionId), message) } diff --git a/packages/kilo-vscode/webview-ui/browser/StreamViewport.tsx b/packages/kilo-vscode/webview-ui/browser/StreamViewport.tsx index 685da1b5522..f05306f5a0c 100644 --- a/packages/kilo-vscode/webview-ui/browser/StreamViewport.tsx +++ b/packages/kilo-vscode/webview-ui/browser/StreamViewport.tsx @@ -1,5 +1,6 @@ -import { createEffect, onCleanup, onMount, type Accessor, type Component } from "solid-js" +import { createEffect, createSignal, onCleanup, onMount, type Accessor, type Component } from "solid-js" import { + CURSORS, mergeWheel, source, VIEWPORT_LIMIT, @@ -85,6 +86,7 @@ export const StreamViewport: Component<{ const keyboard = new Map>() const clicker = clicks() const text = typing() + const [cursor, update] = createSignal("default") const publish = (view: View, viewport = view.viewport) => { props.transport.send({ @@ -136,6 +138,7 @@ export const StreamViewport: Component<{ // Keep the last image on a resize or a new document of the same page, so the preview does not go blank until the // next frame. const clear = (keep = false) => { + update("default") release() const queued = pending pending = undefined @@ -521,6 +524,11 @@ export const StreamViewport: Component<{ const unsubscribe = props.transport.subscribe((event) => { if (event.type === "frame") receive(event.value) + if (event.type !== "cursor" || disposed) return + sync() + const value = event.value + if (!current?.viewport.active || !same(current.scope, value.scope) || !matches(value, current.identity)) return + if (CURSORS.has(value.cursor)) update(value.cursor) }) createEffect(sync) @@ -580,6 +588,7 @@ export const StreamViewport: Component<{ aria-label={props.label} aria-description="Press Enter to interact. Press Shift+Escape to leave page input, then Shift+Tab to return to the toolbar." aria-keyshortcuts="Enter" + style={{ cursor: inspecting() ? "crosshair" : cursor() }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return event.preventDefault() diff --git a/packages/kilo-vscode/webview-ui/browser/adapter.ts b/packages/kilo-vscode/webview-ui/browser/adapter.ts index fd6b9004e81..5b50119612b 100644 --- a/packages/kilo-vscode/webview-ui/browser/adapter.ts +++ b/packages/kilo-vscode/webview-ui/browser/adapter.ts @@ -1,5 +1,5 @@ import type { UiI18nParams } from "@kilocode/kilo-ui/context" -import type { BrowserFrame } from "../../src/shared/browser-stream" +import { CURSORS, type BrowserCursor, type BrowserFrame } from "../../src/shared/browser-stream" import type { ExtensionMessage, WebviewMessage } from "../src/types/messages" import type { BrowserCommand, @@ -32,6 +32,11 @@ export function browserFrameEvent(scope: BrowserScope, frame: BrowserFrame): Bro return { type: "frame", value: { ...frame, scope } } } +export function browserCursorEvent(scope: BrowserScope, value: BrowserCursor): BrowserEvent | undefined { + if (!CURSORS.has(value.cursor)) return + return { type: "cursor", value: { ...value, scope } } +} + /** Shared browser panel labels. Both browser surfaces use the Agent Manager keys. */ export function browserLabels( t: (key: string, params?: UiI18nParams) => string, diff --git a/packages/kilo-vscode/webview-ui/browser/controller.ts b/packages/kilo-vscode/webview-ui/browser/controller.ts index 94539b0113d..3d7db8ca25f 100644 --- a/packages/kilo-vscode/webview-ui/browser/controller.ts +++ b/packages/kilo-vscode/webview-ui/browser/controller.ts @@ -227,7 +227,7 @@ export function createBrowserController(props: BrowserControllerOptions): Browse setTools({ browserId: event.value.browserId, url: event.value.url }) return } - receiveInspection(event.value) + if (event.type === "inspection") receiveInspection(event.value) } const attach = (scope: BrowserScope | undefined) => { diff --git a/packages/kilo-vscode/webview-ui/browser/index.ts b/packages/kilo-vscode/webview-ui/browser/index.ts index 3dd6b5b52a5..452f07592df 100644 --- a/packages/kilo-vscode/webview-ui/browser/index.ts +++ b/packages/kilo-vscode/webview-ui/browser/index.ts @@ -2,6 +2,7 @@ export { BrowserPanel } from "./BrowserPanel" export { createBrowserController } from "./controller" export type { BrowserController, BrowserControllerOptions } from "./controller" export { + browserCursorEvent, browserDevtoolsEvent, browserFrameEvent, browserInspectionEvent, diff --git a/packages/kilo-vscode/webview-ui/browser/types.ts b/packages/kilo-vscode/webview-ui/browser/types.ts index a73c0007918..933bc4d38de 100644 --- a/packages/kilo-vscode/webview-ui/browser/types.ts +++ b/packages/kilo-vscode/webview-ui/browser/types.ts @@ -1,4 +1,5 @@ import type { + BrowserCursor, BrowserFrame, BrowserInteraction, BrowserViewport, @@ -83,6 +84,7 @@ export type BrowserEvent = | { type: "inspection"; value: BrowserInspection } | { type: "devtools"; value: BrowserDevtools } | { type: "frame"; value: BrowserFrame & { scope: BrowserScope } } + | { type: "cursor"; value: BrowserCursor & { scope: BrowserScope } } export interface BrowserTransport { send(command: BrowserCommand): void 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 5737ab13057..309c47c5e9b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -33,6 +33,7 @@ import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-uti import { taskChildren } from "./background-agents" import { pollBackgroundJobs } from "./background-jobs" import { showTabStrip } from "../../utils/local-tabs" +import { forcesExternalBrowser } from "../../utils/link-modifier" import type { WorktreeReference } from "../../hooks/file-mention-utils" interface ChatViewProps { @@ -88,6 +89,10 @@ export const ChatView: Component = (props) => { // Claim the click before renderer handlers and VS Code's window listener. event.preventDefault() event.stopPropagation() + if (forcesExternalBrowser(event, data.browserLinks)) { + vscode.postMessage({ type: "openExternal", url }) + return + } data.openUrl(url, id()) } // Keeps the background job list fresh for the dock's agent stack and the 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 e34b73e5810..bf8434f7085 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -46,10 +46,13 @@ import { PromptHint } from "./PromptHint" import { useProvider } from "../../context/provider" import { ModelSelector, ModelSelectorBase } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" +import { ThinkingSelector } from "../shared/ThinkingSelector" import { SandboxButtonBase, SandboxTooltipContent } from "../shared/SandboxButton" import { SpeechToTextButton } from "../speech-to-text/SpeechToTextButton" import { canUseSpeechToText, selectedSpeechToTextModel } from "../speech-to-text/availability" -import { ThinkingSelector } from "../shared/ThinkingSelector" +import { PromptSelectors } from "./PromptSelectors" +import { PromptOverflow, type OverflowItem } from "./PromptOverflow" +import { fold, reserve } from "./prompt-fold" import { useFileMention } from "../../hooks/useFileMention" import { usePasteCollapse } from "../../hooks/usePasteCollapse" import type { MentionResult, WorktreeReference } from "../../hooks/file-mention-utils" @@ -1943,6 +1946,87 @@ export const PromptInput: Component = (props) => { onCleanup(() => registerPromptMentionDrop(undefined, undefined)) }) + // Progressive toolbar fold, driven by the toolbar width and font size only. + // The list goes from low to high priority, so low priority actions fold first + // and the actions next to voice and send stay in place. All sizes are read + // from the DOM, so a larger font scales the fold steps. + let hintRef: HTMLDivElement | undefined + let pinnedRef: HTMLDivElement | undefined + const [metrics, setMetrics] = createSignal({ width: Number.POSITIVE_INFINITY, pinned: 0, slot: 26, reserve: 210 }) + onMount(() => { + const measure = () => { + const hint = hintRef + const pinned = pinnedRef + if (!hint || !pinned) return + const box = getComputedStyle(hint) + const actions = pinned.parentElement + const button = actions?.querySelector(".prompt-action [data-component='icon-button']") + const gap = actions ? parseFloat(getComputedStyle(actions).columnGap) || 0 : 0 + setMetrics({ + width: + hint.clientWidth - + parseFloat(box.paddingLeft) - + parseFloat(box.paddingRight) - + (parseFloat(box.columnGap) || 0), + pinned: pinned.getBoundingClientRect().width, + slot: (button?.getBoundingClientRect().width ?? 22) + gap, + reserve: reserve(hint), + }) + } + const observer = new ResizeObserver(measure) + if (hintRef) observer.observe(hintRef) + if (pinnedRef) observer.observe(pinnedRef) + onCleanup(() => observer.disconnect()) + }) + const foldable = createMemo(() => { + const list: OverflowItem[] = [] + if (showIndexing()) { + const tone = indexing.tone() + list.push({ + key: "indexing", + icon: "database", + label: language.t("prompt.action.indexing"), + description: indexing.status().message || indexing.label(), + tone: tone === "muted" ? undefined : tone, + dot: tone === "error" || tone === "warning" ? tone : undefined, + run: handleOpenIndexingSettings, + }) + } + if (sandboxVisible()) + list.push({ + key: "sandbox", + icon: "lock", + label: language.t(sandboxEnabled() ? "prompt.action.sandbox.disable" : "prompt.action.sandbox.enable"), + description: + sandboxReady() && !sandboxAvailable() ? (sandboxReason() ?? language.t("common.requestFailed")) : undefined, + disabled: sandboxDisabled(), + tone: sandboxEnabled() ? "success" : undefined, + dot: sandboxEnabled() ? "success" : undefined, + run: toggleSandbox, + }) + list.push({ + key: "enhance", + icon: "wand-sparkles", + label: language.t("prompt.action.enhance"), + disabled: !canEnhance(), + busy: enhancing(), + run: handleEnhance, + }) + list.push({ + key: "approve", + icon: "shield", + label: language.t(autoApprove() ? "prompt.action.autoApprove.disable" : "prompt.action.autoApprove.enable"), + description: language.t("prompt.action.autoApprove.sandboxExcluded"), + tone: autoApprove() ? "success" : undefined, + dot: autoApprove() ? "success" : undefined, + run: () => vscode.postMessage({ type: "toggleAutoApprove" }), + }) + return list + }) + const shown = createMemo(() => fold({ ...metrics(), count: foldable().length })) + const hidden = createMemo(() => foldable().slice(0, foldable().length - shown())) + const folded = (key: string) => hidden().some((item) => item.key === key) + return (
= (props) => {
-
-
- - - -
+
+ } + model={} + variant={} + />
+
+ +
- +
+ + + +
+
+ +
+ } + tooltipClass="prompt-sandbox-tooltip-content" + onToggle={toggleSandbox} + /> +
+
+
+ - - - vscode.postMessage({ type: "toggleAutoApprove" })} - aria-label={ - autoApprove() - ? language.t("prompt.action.autoApprove.disable") - : language.t("prompt.action.autoApprove.enable") - } - aria-pressed={autoApprove()} - class={`prompt-status-button ${autoApprove() ? "prompt-status-button--active" : ""}`} - /> - - - } - tooltipClass="prompt-sandbox-tooltip-content" - onToggle={toggleSandbox} - /> - - - - - - - - - +
+ + vscode.postMessage({ type: "toggleAutoApprove" })} + aria-label={ + autoApprove() + ? language.t("prompt.action.autoApprove.disable") + : language.t("prompt.action.autoApprove.enable") + } + aria-pressed={autoApprove()} + class={`prompt-status-button ${autoApprove() ? "prompt-status-button--active" : ""}`} + /> + +
+
+ + + + + + } + > = (props) => { onClick={handleSendClick} disabled={!canSend()} aria-label={sendLabel()} - /> - } - > - - {language.t("prompt.goal.start")} - - + > + {language.t("prompt.goal.start")} + + + + } + > + + session.abort()} + aria-label={language.t("prompt.action.stop")} + /> - } - > - - session.abort()} - aria-label={language.t("prompt.action.stop")} - /> - - + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptOverflow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptOverflow.tsx new file mode 100644 index 00000000000..a233443d99a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptOverflow.tsx @@ -0,0 +1,82 @@ +import { For, Show, type Component } from "solid-js" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" +import { Icon, type IconProps } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../../context/language" + +type Tone = "success" | "warning" | "error" + +export interface OverflowItem { + key: string + icon: IconProps["name"] + label: string + /** The tooltip text of the toolbar button, so a folded action keeps its hint. */ + description?: string + /** Icon color, the same as the toolbar button (for example indexing status). */ + tone?: Tone + /** State that needs a dot on the menu button while the action is folded. */ + dot?: Tone + /** Work in progress (for example enhance). Shows a spinner. */ + busy?: boolean + disabled?: boolean + run: () => void +} + +interface Props { + items: OverflowItem[] +} + +const RANK: Record = { success: 1, warning: 2, error: 3 } + +/** The "..." button that holds the prompt actions folded out of the toolbar. */ +export const PromptOverflow: Component = (props) => { + const language = useLanguage() + // The most important folded state wins: error, then warning, then an enabled toggle. + const dot = () => + props.items.reduce((best, item) => { + if (!item.dot) return best + if (!best || RANK[item.dot] > RANK[best]) return item.dot + return best + }, undefined) + const busy = () => props.items.some((item) => item.busy) + + return ( + + + + + + + + {(item) => ( + + + }> + + + + + {item.label} + + {item.description} + + + + )} + + + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptSelectors.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptSelectors.tsx new file mode 100644 index 00000000000..c445a07d7de --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptSelectors.tsx @@ -0,0 +1,180 @@ +/** + * PromptSelectors - agent, model, and reasoning as one split control. + * + * A layout shell shared by the chat prompt and the Agent Manager New Worktree + * dialog. Callers pass their existing selectors as slots, so each popover, + * slash command, and keyboard shortcut keeps its own code path. The shell adds: + * - Menu bar behavior: while one popover is open, moving the pointer onto + * another segment opens that segment instead. + * - Left and Right arrows move focus between segments. + * - Reasoning collapses before the agent or model name truncates + * (`data-tight`), and a changed reasoning value peeks out for a moment + * (`data-peek`). + */ + +import { type Component, type JSX, onCleanup, onMount } from "solid-js" +import { reserve } from "./prompt-fold" + +interface Props { + agent?: JSX.Element + model?: JSX.Element + variant?: JSX.Element +} + +const TRIGGER = "[data-slot='popover-trigger']" +const PEEK = 1500 + +function triggers(root: HTMLElement) { + return Array.from(root.querySelectorAll(`.prompt-selector ${TRIGGER}`)).filter( + (el) => !el.disabled, + ) +} + +/** + * Width a label needs beyond the width it has now (0 when it is not + * truncated). A label that truncates at its segment's max-width (the agent + * cap) cannot use more room, so only the part below the cap counts. + */ +function cut(el: HTMLElement | null) { + if (!el) return 0 + const over = Math.max(0, el.scrollWidth - el.clientWidth) + const button = el.closest(TRIGGER) + const max = button ? getComputedStyle(button).maxWidth : "" + if (!button || !max.endsWith("px")) return over + return Math.max(0, Math.min(over, parseFloat(max) - button.getBoundingClientRect().width)) +} + +/** + * True when the control with full labels does not fit. The room is the + * control width now plus the free space in the toolbar. It does not change + * when the reasoning label collapses, so the result cannot oscillate. + * + * While prompt actions are folded, the room is capped to the space the fold + * keeps for the control. At each fold step the control gets exactly that + * space, and only the space between two steps is larger. Without the cap the + * label would show in that space and collapse again when the next action + * unfolds. With the cap the order is the same in both directions: actions + * unfold first, then the reasoning label shows. + */ +function tight(root: HTMLElement) { + const level = root.querySelector(".thinking-selector-trigger-label") + const hint = root.parentElement + if (!level || !hint) return false + const button = level.parentElement + const gap = button ? parseFloat(getComputedStyle(button).columnGap) || 0 : 0 + const used = level.getBoundingClientRect().width + gap + (parseFloat(getComputedStyle(level).marginInlineEnd) || 0) + const box = getComputedStyle(hint) + const inner = hint.clientWidth - parseFloat(box.paddingLeft) - parseFloat(box.paddingRight) + const others = Array.from(hint.children).reduce( + (sum, el) => sum + (el === root ? 0 : el.getBoundingClientRect().width), + 0, + ) + const gaps = (parseFloat(box.columnGap) || 0) * Math.max(0, hint.children.length - 1) + const width = root.getBoundingClientRect().width + const free = Math.max(0, inner - gaps - others - width) + const folded = !!hint.querySelector(".prompt-input-hint-actions > .prompt-action:first-child:not([data-folded])") + const room = folded ? Math.min(width + free, reserve(hint)) : width + free + const labels = + cut(root.querySelector(".mode-switcher-trigger-label")) + cut(root.querySelector(".model-selector-trigger-label")) + const need = width - used + labels + level.scrollWidth + gap + return need > room + 1 +} + +export const PromptSelectors: Component = (props) => { + let root: HTMLDivElement | undefined + + onMount(() => { + const el = root + if (!el) return + const state = { + frame: 0, + timer: undefined as ReturnType | undefined, + label: null as Element | null, + } + const fit = () => { + state.frame = 0 + el.toggleAttribute("data-tight", tight(el)) + } + const schedule = () => { + if (!state.frame) state.frame = requestAnimationFrame(fit) + } + const peek = () => { + const label = el.querySelector(".thinking-selector-trigger-label") + const prev = state.label + state.label = label + if (!label || !prev || label === prev) return + el.setAttribute("data-peek", "") + clearTimeout(state.timer) + state.timer = setTimeout(() => el.removeAttribute("data-peek"), PEEK) + } + const resize = new ResizeObserver(schedule) + resize.observe(el) + if (el.parentElement) { + resize.observe(el.parentElement) + for (const child of Array.from(el.parentElement.children)) resize.observe(child) + } + const mutation = new MutationObserver(() => { + peek() + schedule() + }) + mutation.observe(el, { childList: true, subtree: true, characterData: true }) + state.label = el.querySelector(".thinking-selector-trigger-label") + schedule() + onCleanup(() => { + resize.disconnect() + mutation.disconnect() + clearTimeout(state.timer) + cancelAnimationFrame(state.frame) + }) + }) + + // Pending hover switch, cancelled on a newer hover and on unmount. + let hover = 0 + onCleanup(() => cancelAnimationFrame(hover)) + + /** Another segment's popover is open, so hovering `target` should switch to it. */ + const switchable = (target: HTMLButtonElement) => { + if (!root || !target.isConnected || target.disabled || target.hasAttribute("data-expanded")) return false + return triggers(root).some((el) => el !== target && el.hasAttribute("data-expanded")) + } + + const onPointerOver = (e: PointerEvent) => { + if (e.pointerType === "touch") return + const target = (e.target as HTMLElement).closest(TRIGGER) + if (!target || !switchable(target)) return + // The new popover takes focus, so the open one closes as an outside focus. + // Wait one frame so the hover tooltip has opened first. Opening the popover + // then closes it, the same as a click does. Check again after the frame: + // the open popover may have closed, or the pointer may have left. + cancelAnimationFrame(hover) + hover = requestAnimationFrame(() => { + hover = 0 + if (!target.matches(":hover") || !switchable(target)) return + target.click() + }) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (!root || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return + const list = triggers(root) + const idx = list.indexOf(e.target as HTMLButtonElement) + if (idx < 0) return + e.preventDefault() + const step = e.key === "ArrowLeft" ? -1 : 1 + list[(idx + step + list.length) % list.length]?.focus() + } + + return ( +
+
+ {props.agent} +
+
+ {props.model} +
+
+ {props.variant} +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index a0c313e67ba..9f97c14b823 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -233,6 +233,8 @@ const TaskToolRenderer: Component = (props) => { } const stoppable = () => !props.readonly && !!childSessionId() && avatar() === "running" + const openLabel = createMemo(() => (worktree ? language.t("task.open.panel") : language.t("task.open.tab"))) + const trigger = () => (
@@ -272,13 +274,15 @@ const TaskToolRenderer: Component = (props) => { /> - + + +
) @@ -292,7 +296,7 @@ const TaskToolRenderer: Component = (props) => { data-slot="task-agent-avatar" data-clickable={childSessionId() ? "true" : undefined} data-resolve={resolved() ? "true" : undefined} - title={childSessionId() ? (worktree ? "Open sub-agent in panel" : "Open sub-agent in tab") : undefined} + title={childSessionId() ? openLabel() : undefined} onClick={childSessionId() ? openInTab : undefined} > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-fold.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-fold.ts new file mode 100644 index 00000000000..afb291eb708 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-fold.ts @@ -0,0 +1,32 @@ +/** + * Progressive fold for the prompt toolbar actions. + * + * The result depends on the toolbar width and font size only. It never + * depends on the agent, model, or reasoning labels, so a long model name + * truncates and does not push actions into the overflow menu, and a pick never + * moves an icon. + */ + +/** Space kept for the agent, model, and reasoning control, in toolbar ems (about 210px at 11px). */ +const RESERVE_EM = 19 + +/** Space kept for the selector control. It scales with the toolbar font size. */ +export function reserve(el: Element) { + return RESERVE_EM * (parseFloat(getComputedStyle(el).fontSize) || 11) +} + +/** + * Returns how many foldable actions stay visible. Callers order the actions + * from low to high priority and keep the last ones. When at least one action + * folds, the overflow button takes one slot. + * + * `width` is the toolbar content width minus the gap between the selectors and + * the actions, `pinned` is the width of the actions that never fold, and + * `slot` is one action button plus the gap after it. At every fold step the + * selector control gets exactly `reserve`. + */ +export function fold(input: { width: number; pinned: number; count: number; reserve: number; slot: number }) { + const room = input.width - input.reserve - input.pinned + if (room >= input.count * input.slot) return input.count + return Math.max(0, Math.min(input.count, Math.floor((room - input.slot) / input.slot))) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx index c729faabefb..7c306ae8473 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx @@ -164,8 +164,18 @@ export const ModeSwitcherBase: Component = (props) => { triggerProps={{ variant: "ghost", size: "small", disabled: props.blocked }} trigger={ <> - {triggerLabel()} - + {/* Keyed so the label remounts on change and the prompt pill can animate it. */} + + {(label) => {label}} + + 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 c3943773e9a..f61ae690603 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -881,7 +881,10 @@ export const ModelSelectorBase: Component = (props) => { }} trigger={ <> - {triggerLabel()} + {/* Keyed so the label remounts on change and the prompt pill can animate it. */} + + {(label) => {label}} + diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index 1b678d72382..43c2ec01acf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -50,6 +50,8 @@ export interface ThinkingSelectorBaseProps { label?: string /** Disable this prompt-scoped selector while a permission owns the prompt. */ blocked?: boolean + /** Short title shown above the options, so the list explains itself. */ + heading?: string } export const ThinkingSelectorBase: Component = (props) => { @@ -80,7 +82,13 @@ export const ThinkingSelectorBase: Component = (props const typeahead = createTypeahead(() => rows().map(display)) function refocus() { - requestAnimationFrame(() => window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } }))) + requestAnimationFrame(() => { + // Another picker took focus (for example the prompt pill switched to the + // agent or model popover on hover). Do not pull focus back to the prompt. + const el = document.activeElement + if (el?.closest("[data-component='popover-content']") && !listRef?.contains(el)) return + window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } })) + }) } function onOpen(val: boolean) { @@ -179,7 +187,7 @@ export const ThinkingSelectorBase: Component = (props - {language.t("prompt.thinking.tooltip")} + {`${language.t("prompt.thinking.tooltip")}: ${display(props.value)}`} Shift+Tab @@ -201,8 +209,22 @@ export const ThinkingSelectorBase: Component = (props triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label, disabled: props.blocked }} trigger={ <> - {display(props.value)} - + {/* Keyed so the label remounts on change and the prompt pill can animate it. */} + + {(label) => ( + + {label} + + )} + + @@ -212,10 +234,16 @@ export const ThinkingSelectorBase: Component = (props
+ + + {(v, i) => (
= (props) => { onClear={() => session.selectVariant(undefined, id())} allowClear clearLabel={language.t("common.default")} + heading={language.t("prompt.thinking.tooltip")} cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false} /> ) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 55081b4a9dc..2985385d6e1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -212,6 +212,7 @@ export const dict = { "prompt.action.stop.background": "إيقاف الوكيل الرئيسي. يستمر الوكلاء الخلفيون في العمل.", "prompt.agents.show": "إظهار الوكلاء الخلفيين", "prompt.action.enhance": "تحسين النص", + "prompt.action.more": "مزيد من الإجراءات", "prompt.paste.expand": "انقر لتوسيع النص الملصق", "prompt.issues.title": "مشكلات الجلسة", "prompt.mcp.provider": "خادم MCP لـ {{name}}", @@ -1351,6 +1352,8 @@ export const dict = { "task.backgroundAgents.stopAll": "إيقاف الكل ({{count}})", "task.backgroundAgents.finished": "انتهى الوكلاء الخلفيون", "task.stop": "إيقاف الوكيل الفرعي", + "task.open.panel": "فتح الوكيل الفرعي في اللوحة", + "task.open.tab": "فتح الوكيل الفرعي في علامة تبويب", "settings.saveBar.unsavedChanges": "تغييرات غير محفوظة", "settings.saveBar.discard": "تجاهل", "settings.saveBar.save": "حفظ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 8696eecedf5..b869661e7c6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -217,6 +217,7 @@ export const dict = { "prompt.action.stop.background": "Parar o agente principal. Os agentes em segundo plano continuam em execução.", "prompt.agents.show": "Mostrar agentes em segundo plano", "prompt.action.enhance": "Melhorar prompt", + "prompt.action.more": "Mais ações", "prompt.paste.expand": "Clique para expandir o texto colado", "prompt.issues.title": "Kudennoù estez", "prompt.mcp.provider": "MCP {{name}}", @@ -1400,6 +1401,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Parar todos ({{count}})", "task.backgroundAgents.finished": "Agentes em segundo plano concluídos", "task.stop": "Parar subagente", + "task.open.panel": "Abrir subagente no painel", + "task.open.tab": "Abrir subagente em uma aba", "settings.saveBar.unsavedChanges": "Alterações não salvas", "settings.saveBar.discard": "Descartar", "settings.saveBar.save": "Salvar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index fabb4654124..8d74274e6c2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -217,6 +217,7 @@ export const dict = { "prompt.action.stop.background": "Zaustavi glavnog agenta. Agenti u pozadini nastavljaju raditi.", "prompt.agents.show": "Prikaži agente u pozadini", "prompt.action.enhance": "Poboljšaj prompt", + "prompt.action.more": "Više akcija", "prompt.paste.expand": "Kliknite da proširite zalijepljeni tekst", "prompt.issues.title": "Problemi sesije", "prompt.mcp.provider": "MCP {{name}}", @@ -1389,6 +1390,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Zaustavi sve ({{count}})", "task.backgroundAgents.finished": "Agenti u pozadini su završili", "task.stop": "Zaustavi podagenta", + "task.open.panel": "Otvori podagenta u panelu", + "task.open.tab": "Otvori podagenta u kartici", "settings.saveBar.unsavedChanges": "Nespremljene promjene", "settings.saveBar.discard": "Odbaci", "settings.saveBar.save": "Spremi", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index b7c7556103c..410ec687e73 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -216,6 +216,7 @@ export const dict = { "prompt.action.stop.background": "Stop hovedagenten. Baggrundsagenter kører videre.", "prompt.agents.show": "Vis baggrundsagenter", "prompt.action.enhance": "Forbedr prompt", + "prompt.action.more": "Flere handlinger", "prompt.paste.expand": "Klik for at udvide den indsatte tekst", "prompt.issues.title": "Sessionsproblemer", "prompt.mcp.provider": "{{name}} MCP", @@ -1383,6 +1384,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Stop alle ({{count}})", "task.backgroundAgents.finished": "Baggrundsagenter er færdige", "task.stop": "Stop underagent", + "task.open.panel": "Åbn underagent i panel", + "task.open.tab": "Åbn underagent i fane", "settings.saveBar.unsavedChanges": "Ikke-gemte ændringer", "settings.saveBar.discard": "Kassér", "settings.saveBar.save": "Gem", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 1a89f202ef2..5ee54b2a0e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -223,6 +223,7 @@ export const dict = { "prompt.action.stop.background": "Hauptagent stoppen. Hintergrund-Agenten laufen weiter.", "prompt.agents.show": "Hintergrund-Agenten anzeigen", "prompt.action.enhance": "Prompt verbessern", + "prompt.action.more": "Weitere Aktionen", "prompt.paste.expand": "Klicken, um eingefügten Text zu erweitern", "prompt.issues.title": "Sitzungsprobleme", "prompt.mcp.provider": "{{name}} MCP", @@ -1414,6 +1415,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Alle stoppen ({{count}})", "task.backgroundAgents.finished": "Hintergrund-Agenten abgeschlossen", "task.stop": "Subagent stoppen", + "task.open.panel": "Subagent in Panel öffnen", + "task.open.tab": "Subagent in Tab öffnen", "settings.saveBar.unsavedChanges": "Nicht gespeicherte Änderungen", "settings.saveBar.discard": "Verwerfen", "settings.saveBar.save": "Speichern", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index ff926854f86..d9fbff87380 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -213,6 +213,7 @@ export const dict = { "prompt.action.stop.background": "Stop main agent. Background agents keep running.", "prompt.agents.show": "Show background agents", "prompt.action.enhance": "Enhance prompt", + "prompt.action.more": "More actions", "prompt.paste.expand": "Click to expand pasted text", "prompt.issues.title": "Session issues", "prompt.mcp.provider": "{{name}} MCP", @@ -1377,6 +1378,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Stop all ({{count}})", "task.backgroundAgents.finished": "Background agents finished", "task.stop": "Stop sub-agent", + "task.open.panel": "Open sub-agent in panel", + "task.open.tab": "Open sub-agent in tab", "settings.saveBar.unsavedChanges": "Unsaved changes", "settings.saveBar.discard": "Discard", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index f351f099835..ff189ed0673 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -218,6 +218,7 @@ export const dict = { "prompt.action.stop.background": "Detener el agente principal. Los agentes en segundo plano siguen en ejecución.", "prompt.agents.show": "Mostrar agentes en segundo plano", "prompt.action.enhance": "Mejorar prompt", + "prompt.action.more": "Más acciones", "prompt.paste.expand": "Haz clic para expandir el texto pegado", "prompt.issues.title": "Problemas de la sesión", "prompt.mcp.provider": "MCP de {{name}}", @@ -1404,6 +1405,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Detener todos ({{count}})", "task.backgroundAgents.finished": "Agentes en segundo plano finalizados", "task.stop": "Detener subagente", + "task.open.panel": "Abrir subagente en el panel", + "task.open.tab": "Abrir subagente en una pestaña", "settings.saveBar.unsavedChanges": "Cambios sin guardar", "settings.saveBar.discard": "Descartar", "settings.saveBar.save": "Guardar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index aa718a0a290..b58cc52776c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -218,6 +218,7 @@ export const dict = { "prompt.action.stop.background": "توقف عامل اصلی. عامل‌های پس‌زمینه به اجرا ادامه می‌دهند.", "prompt.agents.show": "نمایش عامل‌های پس‌زمینه", "prompt.action.enhance": "بهبود پرامپت", + "prompt.action.more": "اقدامات بیشتر", "prompt.paste.expand": "برای بازکردن متن جایگذاری‌شده کلیک کنید", "prompt.issues.title": "مشکلات نشست", "prompt.mcp.provider": "MCP {{name}}", @@ -1378,6 +1379,8 @@ export const dict = { "task.backgroundAgents.stopAll": "توقف همه ({{count}})", "task.backgroundAgents.finished": "عامل‌های پس‌زمینه به پایان رسیدند", "task.stop": "توقف زیرعامل", + "task.open.panel": "باز کردن زیرعامل در پنل", + "task.open.tab": "باز کردن زیرعامل در تب", "settings.saveBar.unsavedChanges": "تغییرات ذخیره‌نشده", "settings.saveBar.discard": "رد کردن", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index b1ae69cd80c..fa1168be06e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -218,6 +218,7 @@ export const dict = { "prompt.action.stop.background": "Arrêter l'agent principal. Les agents en arrière-plan continuent de s'exécuter.", "prompt.agents.show": "Afficher les agents en arrière-plan", "prompt.action.enhance": "Améliorer le prompt", + "prompt.action.more": "Autres actions", "prompt.paste.expand": "Cliquez pour développer le texte collé", "prompt.issues.title": "Problèmes de session", "prompt.mcp.provider": "MCP {{name}}", @@ -1423,6 +1424,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Tout arrêter ({{count}})", "task.backgroundAgents.finished": "Agents en arrière-plan terminés", "task.stop": "Arrêter le sous-agent", + "task.open.panel": "Ouvrir le sous-agent dans le panneau", + "task.open.tab": "Ouvrir le sous-agent dans un onglet", "settings.saveBar.unsavedChanges": "Modifications non enregistrées", "settings.saveBar.discard": "Ignorer", "settings.saveBar.save": "Enregistrer", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index b914fd81341..762e4460f39 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -194,6 +194,7 @@ export const dict = { "prompt.action.stop.background": "Ferma l'agente principale. Gli agenti in background continuano a funzionare.", "prompt.agents.show": "Mostra agenti in background", "prompt.action.enhance": "Migliora prompt", + "prompt.action.more": "Altre azioni", "prompt.paste.expand": "Fai clic per espandere il testo incollato", "prompt.issues.title": "Problemi della sessione", "prompt.mcp.provider": "MCP {{name}}", @@ -1243,6 +1244,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Arresta tutti ({{count}})", "task.backgroundAgents.finished": "Agenti in background completati", "task.stop": "Arresta sub-agent", + "task.open.panel": "Apri sub-agent nel pannello", + "task.open.tab": "Apri sub-agent in una scheda", "settings.saveBar.unsavedChanges": "Modifiche non salvate", "settings.saveBar.discard": "Scarta", "settings.saveBar.save": "Salva", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 720c0b104b5..6141b5e6f93 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -217,6 +217,7 @@ export const dict = { "prompt.action.stop.background": "メインエージェントを停止します。バックグラウンドエージェントは実行を続けます。", "prompt.agents.show": "バックグラウンドエージェントを表示", "prompt.action.enhance": "プロンプトを改善", + "prompt.action.more": "その他の操作", "prompt.paste.expand": "クリックして貼り付けたテキストを展開", "prompt.issues.title": "セッションの問題", "prompt.mcp.provider": "{{name}} MCP", @@ -1376,6 +1377,8 @@ export const dict = { "task.backgroundAgents.stopAll": "すべて停止 ({{count}})", "task.backgroundAgents.finished": "バックグラウンドエージェントが完了しました", "task.stop": "サブエージェントを停止", + "task.open.panel": "サブエージェントをパネルで開く", + "task.open.tab": "サブエージェントをタブで開く", "settings.saveBar.unsavedChanges": "未保存の変更", "settings.saveBar.discard": "破棄", "settings.saveBar.save": "保存", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 195a63d639d..cbb4c8fa809 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -219,6 +219,7 @@ export const dict = { "prompt.action.stop.background": "메인 에이전트를 중지합니다. 백그라운드 에이전트는 계속 실행됩니다.", "prompt.agents.show": "백그라운드 에이전트 표시", "prompt.action.enhance": "프롬프트 개선", + "prompt.action.more": "기타 작업", "prompt.paste.expand": "붙여넣은 텍스트를 확장하려면 클릭", "prompt.issues.title": "세션 문제", "prompt.mcp.provider": "{{name}} MCP", @@ -1360,6 +1361,8 @@ export const dict = { "task.backgroundAgents.stopAll": "모두 중지 ({{count}})", "task.backgroundAgents.finished": "백그라운드 에이전트 완료", "task.stop": "하위 에이전트 중지", + "task.open.panel": "패널에서 하위 에이전트 열기", + "task.open.tab": "탭에서 하위 에이전트 열기", "settings.saveBar.unsavedChanges": "저장되지 않은 변경 사항", "settings.saveBar.discard": "취소", "settings.saveBar.save": "저장", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 2ed77c597fa..c700709869f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -217,6 +217,7 @@ export const dict = { "prompt.action.stop.background": "Hoofdagent stoppen. Achtergrondagenten blijven actief.", "prompt.agents.show": "Achtergrondagenten tonen", "prompt.action.enhance": "Prompt verbeteren", + "prompt.action.more": "Meer acties", "prompt.paste.expand": "Klik om geplakte tekst uit te vouwen", "prompt.issues.title": "Sessieproblemen", "prompt.mcp.provider": "{{name}} MCP", @@ -1371,6 +1372,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Alles stoppen ({{count}})", "task.backgroundAgents.finished": "Achtergrondagenten voltooid", "task.stop": "Subagent stoppen", + "task.open.panel": "Subagent openen in paneel", + "task.open.tab": "Subagent openen in tabblad", "settings.saveBar.unsavedChanges": "Niet-opgeslagen wijzigingen", "settings.saveBar.discard": "Verwerpen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index a73c10feb84..ee7bbe1858f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -219,6 +219,7 @@ export const dict = { "prompt.action.stop.background": "Stopp hovedagenten. Bakgrunnsagenter fortsetter å kjøre.", "prompt.agents.show": "Vis bakgrunnsagenter", "prompt.action.enhance": "Forbedre prompt", + "prompt.action.more": "Flere handlinger", "prompt.paste.expand": "Klikk for å utvide den innlimte teksten", "prompt.issues.title": "Sesjonsproblemer", "prompt.mcp.provider": "{{name}} MCP", @@ -1380,6 +1381,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Stopp alle ({{count}})", "task.backgroundAgents.finished": "Bakgrunnsagenter er ferdige", "task.stop": "Stopp underagent", + "task.open.panel": "Åpne underagent i panel", + "task.open.tab": "Åpne underagent i fane", "settings.saveBar.unsavedChanges": "Ulagrede endringer", "settings.saveBar.discard": "Forkast", "settings.saveBar.save": "Lagre", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 624932666c0..ce7ddecd922 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -217,6 +217,7 @@ export const dict = { "prompt.action.stop.background": "Zatrzymaj głównego agenta. Agenci w tle nadal działają.", "prompt.agents.show": "Pokaż agentów w tle", "prompt.action.enhance": "Ulepsz prompt", + "prompt.action.more": "Więcej akcji", "prompt.paste.expand": "Kliknij, aby rozwinąć wklejony tekst", "prompt.issues.title": "Problemy sesji", "prompt.mcp.provider": "MCP {{name}}", @@ -1390,6 +1391,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Zatrzymaj wszystkich ({{count}})", "task.backgroundAgents.finished": "Agenci w tle zakończyli pracę", "task.stop": "Zatrzymaj podagenta", + "task.open.panel": "Otwórz podagenta w panelu", + "task.open.tab": "Otwórz podagenta w karcie", "settings.saveBar.unsavedChanges": "Niezapisane zmiany", "settings.saveBar.discard": "Odrzuć", "settings.saveBar.save": "Zapisz", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 79baadaa894..9bbe0bd0627 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -215,6 +215,7 @@ export const dict = { "prompt.action.stop.background": "Остановить основного агента. Фоновые агенты продолжат работу.", "prompt.agents.show": "Показать фоновых агентов", "prompt.action.enhance": "Улучшить промпт", + "prompt.action.more": "Другие действия", "prompt.paste.expand": "Нажмите, чтобы развернуть вставленный текст", "prompt.issues.title": "Проблемы сессии", "prompt.mcp.provider": "MCP {{name}}", @@ -1382,6 +1383,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Остановить всех ({{count}})", "task.backgroundAgents.finished": "Фоновые агенты завершили работу", "task.stop": "Остановить субагента", + "task.open.panel": "Открыть субагента на панели", + "task.open.tab": "Открыть субагента во вкладке", "settings.saveBar.unsavedChanges": "Несохранённые изменения", "settings.saveBar.discard": "Отменить", "settings.saveBar.save": "Сохранить", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index e2c55ffea91..47be0121cd9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -215,6 +215,7 @@ export const dict = { "prompt.action.stop.background": "หยุดเอเจนต์หลัก เอเจนต์เบื้องหลังจะยังคงทำงานต่อ", "prompt.agents.show": "แสดงเอเจนต์เบื้องหลัง", "prompt.action.enhance": "ปรับปรุงพรอมต์", + "prompt.action.more": "การดำเนินการเพิ่มเติม", "prompt.paste.expand": "คลิกเพื่อขยายข้อความที่วาง", "prompt.issues.title": "ปัญหาของเซสชัน", "prompt.mcp.provider": "MCP {{name}}", @@ -1357,6 +1358,8 @@ export const dict = { "task.backgroundAgents.stopAll": "หยุดทั้งหมด ({{count}})", "task.backgroundAgents.finished": "เอเจนต์เบื้องหลังทำงานเสร็จแล้ว", "task.stop": "หยุดเอเจนต์ย่อย", + "task.open.panel": "เปิดเอเจนต์ย่อยในแผง", + "task.open.tab": "เปิดเอเจนต์ย่อยในแท็บ", "settings.saveBar.unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่ได้บันทึก", "settings.saveBar.discard": "ยกเลิก", "settings.saveBar.save": "บันทึก", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index f392c8e42d5..f2c5462770d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -216,6 +216,7 @@ export const dict = { "prompt.action.stop.background": "Ana ajanı durdur. Arka plan ajanları çalışmaya devam eder.", "prompt.agents.show": "Arka plan ajanlarını göster", "prompt.action.enhance": "Komutu geliştir", + "prompt.action.more": "Diğer eylemler", "prompt.paste.expand": "Yapıştırılan metni genişletmek için tıklayın", "prompt.issues.title": "Oturum sorunları", "prompt.mcp.provider": "{{name}} MCP", @@ -1357,6 +1358,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Tümünü durdur ({{count}})", "task.backgroundAgents.finished": "Arka plan ajanları tamamlandı", "task.stop": "Alt ajanı durdur", + "task.open.panel": "Alt ajanı panelde aç", + "task.open.tab": "Alt ajanı sekmede aç", "settings.saveBar.unsavedChanges": "Kaydedilmemiş değişiklikler", "settings.saveBar.discard": "Geri Al", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index ea6deeb9cba..4bfac5cedb8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -217,6 +217,7 @@ export const dict = { "prompt.action.stop.background": "Зупинити головного агента. Фонові агенти продовжать роботу.", "prompt.agents.show": "Показати фонових агентів", "prompt.action.enhance": "Покращити запит", + "prompt.action.more": "Інші дії", "prompt.paste.expand": "Натисніть, щоб розгорнути вставлений текст", "prompt.issues.title": "Проблеми сесії", "prompt.mcp.provider": "MCP {{name}}", @@ -1356,6 +1357,8 @@ export const dict = { "task.backgroundAgents.stopAll": "Зупинити всіх ({{count}})", "task.backgroundAgents.finished": "Фонові агенти завершили роботу", "task.stop": "Зупинити підагента", + "task.open.panel": "Відкрити підагента на панелі", + "task.open.tab": "Відкрити підагента у вкладці", "settings.saveBar.unsavedChanges": "Незбережені зміни", "settings.saveBar.discard": "Скасувати", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 41115f8fdab..dcf41c1d5f0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -215,6 +215,7 @@ export const dict = { "prompt.action.stop.background": "停止主智能体。后台智能体将继续运行。", "prompt.agents.show": "显示后台智能体", "prompt.action.enhance": "优化提示词", + "prompt.action.more": "更多操作", "prompt.paste.expand": "点击展开粘贴的文本", "prompt.issues.title": "会话问题", "prompt.mcp.provider": "{{name}} MCP", @@ -1307,6 +1308,8 @@ export const dict = { "task.backgroundAgents.stopAll": "全部停止 ({{count}})", "task.backgroundAgents.finished": "后台智能体已完成", "task.stop": "停止子智能体", + "task.open.panel": "在面板中打开子智能体", + "task.open.tab": "在标签页中打开子智能体", "settings.saveBar.unsavedChanges": "未保存的更改", "settings.saveBar.discard": "放弃", "settings.saveBar.save": "保存", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index b579e7671d6..b8553340a7c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -209,6 +209,7 @@ export const dict = { "prompt.action.stop.background": "停止主 Agent。背景 Agent 會繼續執行。", "prompt.agents.show": "顯示背景 Agent", "prompt.action.enhance": "改善提示詞", + "prompt.action.more": "更多動作", "prompt.paste.expand": "點擊展開貼上的文字", "prompt.issues.title": "會話問題", "prompt.mcp.provider": "{{name}} MCP", @@ -1311,6 +1312,8 @@ export const dict = { "task.backgroundAgents.stopAll": "全部停止 ({{count}})", "task.backgroundAgents.finished": "背景 Agent 已完成", "task.stop": "停止子代理", + "task.open.panel": "在面板中開啟子代理", + "task.open.tab": "在分頁中開啟子代理", "settings.saveBar.unsavedChanges": "未儲存的變更", "settings.saveBar.discard": "捨棄", "settings.saveBar.save": "儲存", diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index c11f97352bc..3331c21d585 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -2453,6 +2453,7 @@ const ProjectsStory = () => ( onSelect={() => {}} onRemove={() => {}} onExpand={() => {}} + onReorder={() => {}} onHistory={() => {}} onNew={() => {}} onCreate={() => {}} diff --git a/packages/kilo-vscode/webview-ui/src/styles/high-contrast.css b/packages/kilo-vscode/webview-ui/src/styles/high-contrast.css index 14d6fdcddf8..98016b1726a 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/high-contrast.css +++ b/packages/kilo-vscode/webview-ui/src/styles/high-contrast.css @@ -104,6 +104,15 @@ body.vscode-high-contrast-light { border-color: var(--vscode-contrastBorder, transparent) !important; } + .prompt-selector::before { + background: var(--vscode-contrastBorder, var(--border-base)) !important; + } + + .prompt-selectors .prompt-selector [data-component="button"]:is(:hover, [data-expanded]):not(:disabled) { + outline: 1px dashed var(--vscode-contrastActiveBorder, transparent); + outline-offset: -1px; + } + /* Prompt review rows */ .prompt-review-row { border-color: var(--vscode-contrastBorder, var(--vscode-input-border)); diff --git a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css index aaa39717d26..9d1f31ad2fe 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css +++ b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css @@ -596,6 +596,13 @@ min-width: 120px; } +.thinking-selector-heading { + padding: 8px 12px 4px; + font-size: var(--kilo-font-size-11); + color: var(--text-weak, var(--vscode-descriptionForeground)); + user-select: none; +} + .thinking-selector-item { padding: 8px 12px; cursor: pointer; diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css index 0dce5b04d16..3a5b18171eb 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css @@ -543,6 +543,218 @@ min-width: 0; } +/* Agent | Model | Reasoning as one split control, styled like the Agent + Manager terminal split button (.am-split-button): ghost segments sit flush, + only the outer corners are rounded, and the hovered segment fills up to its + square inner edge. Short separators and one trailing arrow show at rest that + these are controls, not status text. The toolbar stays one row at every + width: only the model label truncates, so a pick never changes the prompt + height. */ +.prompt-input-hint:has(> .prompt-selectors) { + align-items: center; +} + +.prompt-selectors { + --prompt-selectors-ease: cubic-bezier(0.2, 0.9, 0.3, 1); + display: flex; + align-items: stretch; + flex: 0 1 auto; + min-width: 0; + height: 24px; + border-radius: 5px; +} + +.prompt-selector { + position: relative; + display: flex; + flex-shrink: 0; + min-width: 0; +} + +.prompt-selector:not(:has([data-slot="popover-trigger"])) { + display: none; +} + +.prompt-selector[data-part="model"] { + flex: 0 1 auto; + min-width: 6em; +} + +/* A long agent name truncates. It is capped, and it shrinks much slower than + the model, so the model gives up its room first and a short agent name like + "Code" stays readable. */ +.prompt-selector[data-part="agent"] { + flex: 0 0.1 auto; + min-width: 3em; +} + +.prompt-selectors .prompt-selector[data-part="agent"] [data-slot="popover-trigger"] { + max-width: 10em; +} + +.prompt-selector > [data-component="tooltip-trigger"] { + flex: 1 1 auto; + min-width: 0; +} + +/* Separator before every visible part that follows another. It hides next to + a filled part, so the fill reads as one clean segment. */ +.prompt-selector:has([data-slot="popover-trigger"]) ~ .prompt-selector:has([data-slot="popover-trigger"])::before { + content: ""; + position: absolute; + top: 6px; + bottom: 6px; + inset-inline-start: 0; + z-index: 1; + width: 1px; + background: color-mix(in srgb, var(--vscode-foreground) 22%, transparent); + pointer-events: none; + transition: opacity 120ms ease; +} + +.prompt-selector:is(:hover, :has([data-slot="popover-trigger"]:is([data-expanded], :focus-visible)))::before, +.prompt-selector:is(:hover, :has([data-slot="popover-trigger"]:is([data-expanded], :focus-visible))) + + .prompt-selector::before { + opacity: 0; +} + +.prompt-selectors .prompt-selector [data-slot="popover-trigger"] { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + height: 24px; + min-height: 24px; + padding: 0 7px; + gap: 4px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--text-base, var(--vscode-foreground)); + font-size: var(--kilo-font-size-12); + line-height: normal; + white-space: nowrap; + transition: background-color 120ms ease; +} + +.prompt-selectors .prompt-selector [data-slot="popover-trigger"]:hover:not(:disabled) { + background: var(--button-ghost-hover, var(--surface-base-hover, rgba(128, 128, 128, 0.2))); +} + +.prompt-selectors .prompt-selector [data-slot="popover-trigger"][data-expanded]:not(:disabled) { + background: var(--vscode-toolbar-activeBackground, var(--button-ghost-hover, var(--surface-base-hover))); +} + +.prompt-selectors .prompt-selector [data-slot="popover-trigger"]:active:not(:disabled) { + opacity: 0.8; +} + +/* Focus shows only the ring. The outer parts take the group radius, so the + ring and the fills follow the rounded corners instead of being clipped. */ +.prompt-selectors .prompt-selector [data-slot="popover-trigger"]:focus-visible { + position: relative; + z-index: 2; + outline: 1px solid var(--border-focus); + outline-offset: -1px; +} + +.prompt-selector:not(.prompt-selector:has([data-slot="popover-trigger"]) ~ .prompt-selector) + [data-slot="popover-trigger"] { + border-start-start-radius: 5px; + border-end-start-radius: 5px; +} + +.prompt-selector:not(:has(~ .prompt-selector [data-slot="popover-trigger"])) [data-slot="popover-trigger"] { + border-start-end-radius: 5px; + border-end-end-radius: 5px; +} + +.prompt-selectors .prompt-selector[data-part="variant"] [data-slot="popover-trigger"] { + padding: 0 6px; + color: var(--text-weak, var(--vscode-descriptionForeground)); +} + +/* One trailing arrow: on the reasoning part, or on the model part when the + model has no reasoning levels. It turns while its popover is open. */ +.prompt-selectors .selector-trigger-chevron, +.prompt-selectors .model-selector-trigger-chevron { + display: none; + color: var(--text-weak, var(--vscode-descriptionForeground)); + transition: transform 160ms var(--prompt-selectors-ease); +} + +.prompt-selectors .prompt-selector[data-part="variant"] .selector-trigger-chevron, +.prompt-selectors:not(:has([data-part="variant"] [data-slot="popover-trigger"])) .model-selector-trigger-chevron, +.prompt-selectors:not(:has(:is([data-part="model"], [data-part="variant"]) [data-slot="popover-trigger"])) + [data-part="agent"] + .selector-trigger-chevron { + display: block; +} + +.prompt-selectors + [data-slot="popover-trigger"][data-expanded] + :is(.selector-trigger-chevron, .model-selector-trigger-chevron) { + transform: rotate(180deg); +} + +/* A changed value rises in, so a change made with a shortcut is easy to see. */ +.prompt-selectors :is(.mode-switcher-trigger-label, .model-selector-trigger-label, .thinking-selector-trigger-label) { + display: inline-block; + min-width: 0; + animation: prompt-selector-value-in 200ms var(--prompt-selectors-ease); +} + +@keyframes prompt-selector-value-in { + from { + opacity: 0; + transform: translateY(3px); + } +} + +/* The reasoning label collapses into the arrow when it says nothing useful + ("Default"), or before the model name would truncate (data-tight, set by + PromptSelectors). A changed value, for example from Shift+Tab, peeks out for + a moment (data-peek). The label stays in the accessibility tree, and the + tooltip names the value. */ +.prompt-selectors .thinking-selector-trigger-label { + max-width: 8em; + transition: + max-width 200ms var(--prompt-selectors-ease), + opacity 160ms ease, + margin 200ms var(--prompt-selectors-ease); +} + +.prompt-selectors[data-tight] .thinking-selector-trigger-label, +.prompt-selectors .thinking-selector-trigger-label[data-unset] { + max-width: 0; + margin-inline-end: -4px; + opacity: 0; +} + +.prompt-selectors[data-peek] .thinking-selector-trigger-label { + max-width: 8em; + margin-inline-end: 0; + opacity: 1; +} + +body.vscode-reduce-motion .prompt-selectors, +body.vscode-reduce-motion .prompt-selectors *, +body.vscode-reduce-motion .prompt-selectors *::before, +body.vscode-reduce-motion .prompt-action { + animation: none !important; + transition: none !important; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-selectors, + .prompt-selectors *, + .prompt-selectors *::before, + .prompt-action { + animation: none !important; + transition: none !important; + } +} + /* Remote Settings */ [data-component="remote-settings"] { display: flex; @@ -610,6 +822,164 @@ flex-shrink: 0; } +.prompt-input-hint-pinned { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Foldable prompt action. Folding collapses the width, so the actions to the + right keep their place. The -4px margin removes the flex gap of a folded + action. Folded actions are hidden, so they leave the tab order. */ +.prompt-action { + display: flex; + flex-shrink: 0; + max-width: 24px; + overflow: clip; + overflow-clip-margin: 3px; + transition: + max-width 180ms cubic-bezier(0.2, 0.9, 0.3, 1), + margin 180ms cubic-bezier(0.2, 0.9, 0.3, 1), + opacity 140ms ease, + visibility 0s linear 0s; +} + +.prompt-action[data-folded] { + max-width: 0; + margin-inline-start: -4px; + opacity: 0; + visibility: hidden; + transition: + max-width 180ms cubic-bezier(0.2, 0.9, 0.3, 1), + margin 180ms cubic-bezier(0.2, 0.9, 0.3, 1), + opacity 100ms ease, + visibility 0s linear 180ms; +} + +.prompt-action:first-child[data-folded] { + margin-inline-start: 0; + margin-inline-end: -4px; +} + +.prompt-more-button { + position: relative; +} + +/* A folded action is on (auto-approve, sandbox). Same dot as the status buttons. */ +/* A folded action has state (a toggle that is on, indexing progress or an + error). Same dot as the status buttons, colored by the most important state. */ +.prompt-more-button:is([data-dot], [data-busy])::after { + content: ""; + position: absolute; + top: 1px; + right: 1px; + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--vscode-testing-iconPassed, #73c991); + box-shadow: 0 0 0 1px var(--surface-base, var(--vscode-editor-background)); +} + +.prompt-more-button[data-dot="warning"]::after { + background: var(--vscode-charts-yellow, #cca700); +} + +.prompt-more-button[data-dot="error"]::after { + background: var(--vscode-errorForeground, #f48771); +} + +/* A folded action is working (for example enhance). The dot pulses. */ +.prompt-more-button[data-busy]:not([data-dot])::after { + background: var(--border-focus, var(--vscode-focusBorder)); +} + +.prompt-more-button[data-busy]::after { + animation: prompt-more-pulse 1200ms ease-in-out infinite; +} + +@keyframes prompt-more-pulse { + 50% { + opacity: 0.35; + } +} + +.prompt-more-menu[data-component="dropdown-menu-content"] { + min-width: 200px; + max-width: min(300px, calc(100vw - 16px)); +} + +.prompt-more-menu[data-component="dropdown-menu-content"] [data-slot="dropdown-menu-item"] { + gap: 8px; + padding: 6px 10px; +} + +.prompt-more-menu [data-slot="dropdown-menu-item"][data-disabled] { + opacity: 0.5; +} + +.prompt-more-icon { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + color: var(--icon-base, var(--vscode-descriptionForeground)); +} + +.prompt-more-icon [data-component="icon"], +.prompt-more-icon [data-slot="icon-svg"] { + width: 16px; + height: 16px; + color: inherit; +} + +.prompt-more-menu [data-slot="dropdown-menu-item"][data-highlighted] .prompt-more-icon { + color: inherit; +} + +.prompt-more-menu [data-slot="dropdown-menu-item"][data-tone="success"] .prompt-more-icon { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.prompt-more-menu [data-slot="dropdown-menu-item"][data-tone="warning"] .prompt-more-icon { + color: var(--vscode-charts-yellow, #cca700); +} + +.prompt-more-menu [data-slot="dropdown-menu-item"][data-tone="error"] .prompt-more-icon { + color: var(--vscode-errorForeground, #f48771); +} + +.prompt-more-menu [data-slot="dropdown-menu-item"] { + align-items: flex-start; +} + +.prompt-more-icon { + margin-top: 1px; +} + +.prompt-more-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +/* The tooltip text of the folded button, so the menu keeps its hint. */ +.prompt-more-menu [data-slot="dropdown-menu-item-description"] { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: var(--kilo-font-size-11); + color: var(--text-weak, var(--vscode-descriptionForeground)); + white-space: normal; +} + +.prompt-more-menu [data-slot="dropdown-menu-item"][data-highlighted] [data-slot="dropdown-menu-item-description"] { + color: inherit; + opacity: 0.8; +} + .prompt-indexing-button { position: relative; } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index a5191ecc338..57a5e3db5cb 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -19,7 +19,7 @@ import type { AgentManagerSidebarTarget } from "./webview-messages" import type { PermissionRequest } from "./permissions" import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/anaconda-desktop-messages" import type { BrowserFeedbackData, BrowserReference } from "../../../../src/shared/browser-feedback" -import type { BrowserFrame } from "../../../../src/shared/browser-stream" +import type { BrowserCursor, BrowserFrame } from "../../../../src/shared/browser-stream" import type { CodeContext } from "../../../../src/shared/code-context" import type { PRMergeResult, PRReviewResult } from "../../../../src/shared/pr-comment-actions" @@ -1730,6 +1730,11 @@ export interface BrowserTabFrameMessage extends BrowserFrame { sessionId: string } +interface BrowserTabCursorMessage extends BrowserCursor { + type: "browserTab.cursor" + sessionId: string +} + export interface AgentManagerBrowserInspectionMessage { type: "agentManager.browserInspection" error?: string @@ -1760,6 +1765,12 @@ interface AgentManagerBrowserFrameMessage extends BrowserFrame { sessionId: string } +interface AgentManagerBrowserCursorMessage extends BrowserCursor { + type: "agentManager.browserCursor" + projectId?: string + sessionId: string +} + export interface AgentManagerBrowserDevtoolsMessage { type: "agentManager.browserDevtools" browserId: string @@ -1786,11 +1797,13 @@ export type ExtensionMessage = | AgentManagerBrowserInspectionMessage | AgentManagerBrowserDevtoolsMessage | AgentManagerBrowserFrameMessage + | AgentManagerBrowserCursorMessage | BrowserTabScopeMessage | BrowserTabStateMessage | BrowserTabInspectionMessage | BrowserTabDevtoolsMessage | BrowserTabFrameMessage + | BrowserTabCursorMessage | ReadyMessage | FontSizeChangedMessage | GitStatusMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 9c698051955..72257065b4a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -908,6 +908,12 @@ export interface SetProjectExpandedMessage { expanded: boolean } +// Persist the sidebar order of the additional (not pinned) projects +export interface SetProjectOrderMessage { + type: "agentManager.setProjectOrder" + order: string[] +} + // Configure worktree setup script export interface ConfigureSetupScriptRequest { type: "agentManager.configureSetupScript" @@ -1885,6 +1891,7 @@ export type WebviewMessage = | ActivateSelectionMessage | RememberTargetMessage | SetProjectExpandedMessage + | SetProjectOrderMessage | ConfigureSetupScriptRequest | ConfigureRunScriptRequest | RunScriptRequest diff --git a/packages/kilo-vscode/webview-ui/src/utils/link-modifier.ts b/packages/kilo-vscode/webview-ui/src/utils/link-modifier.ts new file mode 100644 index 00000000000..6c630fdf90e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/link-modifier.ts @@ -0,0 +1,9 @@ +/** + * Shift-click forces a chat web link into the system browser while the + * Integrated Browser handles link clicks. When the Integrated Browser does + * not handle links, every link already opens externally, so the modifier + * changes nothing and this stays false. + */ +export function forcesExternalBrowser(event: { shiftKey: boolean }, browserLinks: boolean | undefined): boolean { + return browserLinks === true && event.shiftKey +} diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dc342810aac..cc3bb3cc222 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -132,7 +132,9 @@ const layer = Layer.effect( const overrides: Array<{ name: string; command: Override }> = [] for (const [name, command] of Object.entries(cfg.command ?? {})) { if (Reserved.reserved(name)) { - yield* Effect.logWarning(Reserved.notice(name)) + const key = Reserved.rename(cfg.command, name) + if (key) applyOverride(commands, key, command, hints) + yield* Effect.logWarning(Reserved.notice(name, key)) continue } if (!applyOverride(commands, name, command, hints)) overrides.push({ name, command }) // kilocode_change diff --git a/packages/opencode/src/kilocode/command/reserved.ts b/packages/opencode/src/kilocode/command/reserved.ts index c024ca71f85..b1006d9a61b 100644 --- a/packages/opencode/src/kilocode/command/reserved.ts +++ b/packages/opencode/src/kilocode/command/reserved.ts @@ -1,8 +1,8 @@ // kilocode_change - new file // SessionPrompt.command intercepts `/goal` before it looks a command up, so a command -// registered under that name can never run. Skip it instead of failing: building the -// command list used to throw on a clash, and a single clashing name from config or a -// plugin then hid every slash command from the client. +// registered under that name can never run under that name. Rather than dropping it, +// expose it as `name:command` -- the same source-suffix convention skills (`:skill`) and +// MCP prompts (`:mcp`) already use -- so the workflow stays reachable. // // `goal` is the only such name. The other built-in session commands (`compact`, // `summarize`) are resolved through the registry rather than intercepted, so a command @@ -12,12 +12,47 @@ export function reserved(name: string) { return name === "goal" } -export function notice(name: string) { +export function alias(name: string) { + return `${name}:command` +} + +type Entry = { + template?: string + description?: string + agent?: string + model?: string + variant?: string + subtask?: boolean +} + +/** + * The alias key a reserved command should register under, or undefined when there is + * nothing to register. + * + * Undefined when the entry has no `template`: that is a partial override (it only tweaks + * fields on an existing command), and there is no existing `goal:command` target to apply + * it to. + * + * Undefined when an explicit `name:command` entry already defines its own `template`: that + * entry owns the alias, so the auto-registered one must not replace it. Checked against the + * config record rather than the registry so the result does not depend on object key order. + */ +export function rename(commands: Record | undefined, name: string) { + const entry = commands?.[name] + if (entry?.template === undefined) return undefined + if (commands?.[alias(name)]?.template !== undefined) return undefined + return alias(name) +} + +export function notice(name: string, key?: string) { return [ `Ignoring the "${name}" command registered by your config or a plugin:`, `/${name} is reserved for Kilo's own command.`, + key ? `Run yours as /${key} instead.` : "", "Rename it, or turn it off in the plugin that registers it, to stop this warning.", - ].join(" ") + ] + .filter(Boolean) + .join(" ") } /** @@ -31,8 +66,8 @@ export function notice(name: string) { * `plugin.init()` before an instance is handed out, so plugin commands are already in * config by the time any client can ask for warnings. */ -export function warnings(commands: Record | undefined) { +export function warnings(commands: Record | undefined) { return Object.keys(commands ?? {}) .filter(reserved) - .map((name) => ({ path: `command.${name}`, message: notice(name) })) + .map((name) => ({ path: `command.${name}`, message: notice(name, rename(commands, name)) })) } diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 1e140658310..38d53d65f1d 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -10,7 +10,7 @@ Permission blockers are separate from questions: a session may report `attention For any assignment request, the required sequence is: (1) call `agent_manager` with `{ "action": "list" }`; (2) read the returned `sections[].id`, `sections[].worktrees[].session.id` or `sessions[].id`, and `ungrouped[].session.id` or `sessions[].sessions[].id`; (3) call `agent_manager` with `{ "action": "move", "sessionID": "", "sectionID": "" }` once for each worktree; (4) use `sectionID: null` to unassign. Never invent IDs, use section names instead of IDs, or edit `.kilo/agent-manager.json`. The `list` result is the source of truth for IDs and assignments: each `sections` entry includes the section `id`, name, and its assigned `worktrees`; each worktree includes its worktree `id` and its session ID(s) in `session` or `sessions`; `ungrouped` lists worktrees that have no section; and `local.sessions` lists local sessions that cannot be assigned to a section. For `move`, pass the target session's ID as `sessionID` and a section ID as `sectionID`; pass `null` to unassign it. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting, stopping, and moving are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. Prompts to busy or retrying sessions are queued behind active work instead of rejected; you do not need to wait for idle before prompting. Moving a session moves its whole worktree, including multi-version siblings; local sessions cannot be assigned to a section. -To start sessions, keep using the existing `mode` and `tasks` input without an action. Use start mode when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions for independent tasks. +To start sessions, keep using the existing `mode` and `tasks` input without an action. Start sessions only when the user explicitly asks for new Agent Manager sessions or worktrees, or after you confirm with the user first. Never start them on your own to parallelize, delegate, or organize routine work: each session is user-visible and each worktree costs disk space and tokens. Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 3580c71e40f..591e195133e 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -35,6 +35,9 @@ const ModelState = z .passthrough() export namespace KiloTask { + export const usageDescription = + "Subagents launched with this tool are internal to the current session and create no worktrees or interactive sessions. To start visible Agent Manager sessions, use `agent_manager` only when the user explicitly asks." + export const ModelFields = { model: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d37e74eb945..8c352eae994 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -526,6 +526,7 @@ export const TaskTool = Tool.define( return { description: [ DESCRIPTION, + KiloTask.usageDescription, ...(flags.experimentalBackgroundSubagents ? [BACKGROUND_DESCRIPTION] : []), KiloTask.modelDescription, ].join("\n\n"), diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index e01ada2dcb2..463ad1ab5e2 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -151,6 +151,13 @@ function publish( } describe("agent_manager tool", () => { + test("tells models to start sessions only on explicit user intent", async () => { + const tool = await init() + + expect(tool.description).toContain("Start sessions only when the user explicitly asks") + expect(tool.description).toContain("Never start them on your own") + }) + test("uses an object-root input schema without combinators because more complex schemas break Claude models", async () => { const tool = await init() const schema = ToolJsonSchema.fromTool(tool) diff --git a/packages/opencode/test/kilocode/command/reserved.test.ts b/packages/opencode/test/kilocode/command/reserved.test.ts index 4a83a78d204..61e8a3c773e 100644 --- a/packages/opencode/test/kilocode/command/reserved.test.ts +++ b/packages/opencode/test/kilocode/command/reserved.test.ts @@ -48,9 +48,56 @@ describe("reserved command names", () => { expect(message).toContain("reserved for Kilo's own command") expect(message).toContain("Rename it") expect(message).toContain("turn it off in the plugin that registers it") + // No alias was registered, so there is nothing to point the user at. + expect(message).not.toContain("instead") }) - it.live("keeps every other command when config claims a reserved name", () => + test("names the alias in the warning when one is registered", () => { + const message = Reserved.notice("goal", "goal:command") + expect(message).toContain("Run yours as /goal:command instead.") + }) + + test("builds the alias as the reserved name plus the command-source suffix", () => { + expect(Reserved.alias("goal")).toBe("goal:command") + }) + + describe("rename", () => { + test("registers the alias for a full template definition", () => { + expect(Reserved.rename({ goal: { template: "Plugin goal: $ARGUMENTS" } }, "goal")).toBe("goal:command") + }) + + test("has nothing to register for a template-less partial override", () => { + expect(Reserved.rename({ goal: { model: "test/model" } }, "goal")).toBeUndefined() + }) + + test("defers to an explicit alias that defines its own template", () => { + expect( + Reserved.rename( + { + goal: { template: "Plugin goal: $ARGUMENTS" }, + "goal:command": { template: "Explicit alias" }, + }, + "goal", + ), + ).toBeUndefined() + }) + + test("still registers the alias when the explicit entry is template-less", () => { + // A template-less `goal:command` is itself a partial override; it is meant to land on + // top of the auto-registered alias via the deferred-override pass, not to block it. + expect( + Reserved.rename( + { + goal: { template: "Plugin goal: $ARGUMENTS" }, + "goal:command": { model: "test/model" }, + }, + "goal", + ), + ).toBe("goal:command") + }) + }) + + it.live("keeps every other command and exposes the clash as goal:command", () => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* Effect.promise(() => @@ -72,14 +119,63 @@ describe("reserved command names", () => { expect(names).toContain("ship") expect(names).toContain("init") expect(names).toContain("review") + expect(names).toContain("goal:command") expect(list.filter((item) => item.name === "goal")).toHaveLength(1) + // Kilo's own /goal is untouched. expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) expect(yield* command.get("ship")).toMatchObject({ source: "command" }) + // The clashing template is reachable under the alias instead of being dropped. + expect(yield* command.get("goal:command")).toMatchObject({ + source: "command", + template: "Plugin goal: $ARGUMENTS", + description: "Plugin goal", + }) }), ), ) - it.live("reports a config-sourced clash as a config warning", () => + it.live("does not alias a template-less partial override", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ command: { goal: { model: "test/model" } } })), + ) + + const command = yield* Command.Service + const list = yield* command.list() + + expect(list.map((item) => item.name)).not.toContain("goal:command") + expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + + const warnings = yield* Config.Service.use((svc) => svc.warnings()) + const message = warnings.find((item) => item.path === "command.goal")?.message + expect(message).toBeDefined() + expect(message).not.toContain("instead") + }), + ), + ) + + for (const order of ["alias-first", "goal-first"] as const) { + it.live(`lets an explicit goal:command entry win regardless of key order (${order})`, () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const command = { + "goal:command": { template: "Explicit alias" }, + goal: { template: "Plugin goal: $ARGUMENTS" }, + } + const ordered = + order === "alias-first" ? command : { goal: command.goal, "goal:command": command["goal:command"] } + yield* Effect.promise(() => Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ command: ordered }))) + + const svc = yield* Command.Service + expect(yield* svc.get("goal:command")).toMatchObject({ source: "command", template: "Explicit alias" }) + expect(yield* svc.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + }), + ), + ) + } + + it.live("reports a config-sourced clash as a config warning naming the alias", () => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* Effect.promise(() => @@ -92,9 +188,9 @@ describe("reserved command names", () => { const warnings = yield* Config.Service.use((svc) => svc.warnings()) expect(warnings.some((item) => item.path === "command.goal")).toBe(true) - expect(warnings.find((item) => item.path === "command.goal")?.message).toContain( - '"goal" command registered by your config or a plugin', - ) + const message = warnings.find((item) => item.path === "command.goal")?.message + expect(message).toContain('"goal" command registered by your config or a plugin') + expect(message).toContain("Run yours as /goal:command instead.") }), ), ) @@ -129,16 +225,22 @@ describe("reserved command names", () => { yield* Plugin.Service.use((svc) => svc.list()) const warnings = yield* Config.Service.use((svc) => svc.warnings()) - expect(warnings.find((item) => item.path === "command.goal")?.message).toContain( - '"goal" command registered by your config or a plugin', - ) + const message = warnings.find((item) => item.path === "command.goal")?.message + expect(message).toContain('"goal" command registered by your config or a plugin') + expect(message).toContain("Run yours as /goal:command instead.") // The plugin keeps every command that does not clash, and /goal stays Kilo's. const command = yield* Command.Service const list = yield* command.list() expect(list.map((item) => item.name)).toContain("handoff") + expect(list.map((item) => item.name)).toContain("goal:command") expect(list.filter((item) => item.name === "goal")).toHaveLength(1) expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + // The plugin's own template is reachable under the alias. + expect(yield* command.get("goal:command")).toMatchObject({ + source: "command", + template: "Plugin goal: $ARGUMENTS", + }) }), ) }) diff --git a/packages/opencode/test/kilocode/server/command-reserved.test.ts b/packages/opencode/test/kilocode/server/command-reserved.test.ts index cdaeaf3bc85..6eec8c6ae14 100644 --- a/packages/opencode/test/kilocode/server/command-reserved.test.ts +++ b/packages/opencode/test/kilocode/server/command-reserved.test.ts @@ -46,10 +46,13 @@ describe("reserved command name over HTTP", () => { expect(names).toContain("ship") expect(names).toContain("init") expect(commands.filter((item) => item.name === "goal")).toHaveLength(1) + // The clashing template is reachable under the alias instead of being dropped. + expect(names).toContain("goal:command") const warnings = await json(await req(tmp.path, "/config/warnings")) const clash = warnings.find((item) => item.path === "command.goal") expect(clash?.message).toContain('"goal" command registered by your config or a plugin') expect(clash?.message).toContain("reserved for Kilo's own command") + expect(clash?.message).toContain("Run yours as /goal:command instead.") }) }) diff --git a/packages/opencode/test/kilocode/session/goal.test.ts b/packages/opencode/test/kilocode/session/goal.test.ts index 7c779b048ae..c3757eb2713 100644 --- a/packages/opencode/test/kilocode/session/goal.test.ts +++ b/packages/opencode/test/kilocode/session/goal.test.ts @@ -248,9 +248,9 @@ it.instance( yield* run.prompt.prompt({ sessionID: run.session.id, parts: [{ type: "text", text: "Start something" }] }) expect(GoalState.read(yield* run.metadata)).toBeUndefined() const parts = (yield* run.sessions.messages({ sessionID: run.session.id })).flatMap((message) => message.parts) - expect( - parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"), - ).toBe(true) + expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe( + true, + ) yield* Effect.sleep("5200 millis") expect(yield* run.llm.hits).toHaveLength(2) }), @@ -273,9 +273,7 @@ it.instance( permissions .list() .pipe( - Effect.map((items) => - items.find((item) => item.sessionID === run.session.id && item.permission === "goal"), - ), + Effect.map((items) => items.find((item) => item.sessionID === run.session.id && item.permission === "goal")), ), "Goal start did not request permission", "10 seconds", @@ -284,9 +282,9 @@ it.instance( yield* run.idle expect(GoalState.read(yield* run.metadata)).toBeUndefined() const parts = (yield* run.sessions.messages({ sessionID: run.session.id })).flatMap((message) => message.parts) - expect( - parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"), - ).toBe(true) + expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe( + true, + ) }), 30_000, ) @@ -304,9 +302,9 @@ it.instance( const metadata = yield* run.sessions.get(child.id).pipe(Effect.map((value) => value.metadata)) expect(GoalState.read(metadata)).toBeUndefined() const parts = (yield* run.sessions.messages({ sessionID: child.id })).flatMap((message) => message.parts) - expect( - parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"), - ).toBe(true) + expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe( + true, + ) }), 30_000, ) @@ -1175,7 +1173,10 @@ it.instance( sessionID: session.id, permission: [{ permission: "bash", pattern: "*", action: "ask" }], }) - yield* llm.tool("bash", { command: `echo goal-permission-${crypto.randomUUID()}`, description: "Check the workspace" }) + yield* llm.tool("bash", { + command: `echo goal-permission-${crypto.randomUUID()}`, + description: "Check the workspace", + }) yield* command(objective) const pending = yield* pollWithTimeout( permission.list().pipe(Effect.map((items) => items.find((item) => item.sessionID === session.id))), @@ -2017,8 +2018,9 @@ for (const override of [ { template: "Custom workflow: $ARGUMENTS", description: "Run a custom workflow" }, { agent: "ask", model: "test/selected-model", variant: "focused" }, ]) { + const aliased = "template" in override it.instance( - `ignores reserved goal ${"template" in override ? "templates" : "execution overrides"} without hiding other commands`, + `ignores reserved goal ${aliased ? "templates" : "execution overrides"} without hiding other commands`, Effect.gen(function* () { const run = yield* setup({ command: { goal: override, check: { template: "Check validation", description: "Check the workspace" } }, @@ -2036,6 +2038,10 @@ for (const override of [ expect(goals.at(0)?.template).toBe("$ARGUMENTS") expect(yield* commands.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + // A template-less override has nothing to alias; a full template is reachable as + // goal:command instead of being dropped. + expect(list.map((item) => item.name).includes("goal:command")).toBe(aliased) + // Dispatch still reaches Kilo's own goal, on the dispatched model, and never // renders the override's template. yield* run.llm.text("Goal complete. All work is done.") @@ -2046,6 +2052,21 @@ for (const override of [ expect(hits).toHaveLength(1) expect(hits.at(0)?.body.model).toBe("test-model") expect(JSON.stringify(hits.at(0)?.body.messages)).not.toContain("Custom workflow") + + if (!aliased) return + + // The override's own template still runs, on its own alias, under the normal + // command path rather than the goal runner. + yield* run.prompt.command({ + sessionID: run.session.id, + agent: "code", + command: "goal:command", + arguments: "ship it", + model: "test/test-model", + }) + const aliasHits = yield* run.llm.hits + expect(aliasHits).toHaveLength(2) + expect(JSON.stringify(aliasHits.at(-1)?.body.messages)).toContain("Custom workflow: ship it") }), ) } @@ -2502,16 +2523,12 @@ it.instance( const done = yield* goalStatus(run, "complete") expect(done.active).toBe(false) yield* pollWithTimeout( - wake - .list({ sessionID: run.session.id }) - .pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), + wake.list({ sessionID: run.session.id }).pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), "goal did not release its wakeups", "5 seconds", ) yield* pollWithTimeout( - wake - .cronList({ sessionID: run.session.id }) - .pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), + wake.cronList({ sessionID: run.session.id }).pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), "goal did not release its cron tasks", "5 seconds", ) diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 9487c25ece0..a1e5b6e44ac 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -36,6 +36,9 @@ const active = new Set([ "check-org-member.yml", "codeql-kotlin.yml", "codeql.yml", + "community-label.yml", + "community-sweep.yml", + "community-triage-test.yml", "containers.yml", "dependabot-auto-merge.yml", "docs-build.yml", diff --git a/script/kilocode/community-triage.test.ts b/script/kilocode/community-triage.test.ts new file mode 100644 index 00000000000..f9e5b90a87d --- /dev/null +++ b/script/kilocode/community-triage.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test" +import { + areas, + classify, + component, + pushedAt, + decide, + digest, + DAY, + label, + type Event, + type Row, +} from "./community-triage" + +const now = Date.UTC(2026, 9, 8) +const ago = (days: number) => now - days * DAY + +describe("areas", () => { + test("reads the title scope", () => { + expect(areas("fix(vscode): crash on start", [])).toEqual(["area:vscode"]) + expect(areas("feat(tui)!: new thing", [])).toEqual(["area:cli"]) + }) + + test("reads changed paths and prefers agent-manager over vscode", () => { + const files = ["packages/kilo-vscode/src/agent-manager/a.ts", "packages/kilo-vscode/src/x.ts", "README.md"] + expect(areas("fix: thing", files)).toEqual(["area:agent-manager", "area:vscode"]) + }) + + test("returns nothing for unknown scope and paths", () => { + expect(areas("chore: misc", ["script/x.ts"])).toEqual([]) + }) + + test("ignores inherited object keys in the scope", () => { + expect(areas("fix(constructor): x", [])).toEqual([]) + expect(areas("fix(__proto__): x", [])).toEqual([]) + expect(areas("fix(toString): x", [])).toEqual([]) + }) + + test("maps the web UI package", () => { + expect(areas("fix: x", ["packages/kilo-web-ui/src/a.ts"])).toEqual(["area:desktop"]) + }) +}) + +describe("component", () => { + test("maps the issue form answer", () => { + expect(component("### Description\n\nhi\n\n### Component\n\nJetBrains plugin\n\n### Other\n\nx")).toBe( + "area:jetbrains", + ) + }) + + test("skips a typed heading with no known answer", () => { + expect(component("### Component\n\nmy text\n\n### Component\n\nSDK")).toBe("area:sdk") + }) + + test("ignores inherited object keys in the answer", () => { + expect(component("### Component\n\nconstructor")).toBeUndefined() + expect(component("### Component\n\n__proto__")).toBeUndefined() + }) + + test("ignores other answers", () => { + expect(component("### Component\n\nOther / not sure")).toBeUndefined() + expect(component("no form")).toBeUndefined() + }) +}) + +describe("pushedAt", () => { + test("keeps past commit dates", () => { + expect(pushedAt(new Date(ago(2)).toISOString(), now)).toBe(ago(2)) + }) + + test("allows a small clock skew", () => { + expect(pushedAt(new Date(now + 60_000).toISOString(), now)).toBe(now + 60_000) + }) + + test("ignores future commit dates", () => { + expect(pushedAt(new Date(now + DAY).toISOString(), now)).toBeUndefined() + }) +}) + +describe("classify", () => { + const maintainer = (days: number, approve = false): Event => ({ who: "maintainer", at: ago(days), approve }) + const author = (days: number): Event => ({ who: "author", at: ago(days) }) + + test("needs triage without maintainer reaction", () => { + expect(classify([author(3)], [], now)).toEqual({ state: "triage" }) + }) + + test("a high-value label moves a PR to needs-review", () => { + expect(classify([author(3)], [label.high], now)).toEqual({ state: "review" }) + }) + + test("waits for the contributor after a maintainer reaction", () => { + expect(classify([author(5), maintainer(3)], [], now)).toEqual({ state: "awaiting", since: ago(3), days: 3 }) + }) + + test("contributor reply moves the turn back", () => { + expect(classify([maintainer(5), author(2)], [], now)).toEqual({ state: "review" }) + }) + + test("approval is ready to merge until the author pushes again", () => { + expect(classify([author(5), maintainer(3, true)], [], now)).toEqual({ state: "approved" }) + expect(classify([maintainer(3, true), author(1)], [], now)).toEqual({ state: "review" }) + }) +}) + +describe("decide", () => { + test("swaps stale status labels", () => { + const plan = decide([label.community, label.triage, label.ci], { state: "review" }, false) + expect(plan.add).toEqual([label.review]) + expect(plan.remove.sort()).toEqual([label.ci, label.triage].sort()) + }) + + test("never touches hand-set labels", () => { + const plan = decide([label.high, label.keep, label.adopt], { state: "approved" }, false) + expect(plan).toEqual({ add: [], remove: [] }) + }) +}) + +describe("digest", () => { + const row = (num: number, state: Row["state"], more: Partial = {}): Row => ({ + number: num, + title: `PR ${num}`, + url: `https://github.com/o/r/pull/${num}`, + author: "dev", + created: ago(4), + state, + failing: false, + adopt: false, + closed: false, + ...more, + }) + + test("groups rows and skips empty sections", () => { + const text = digest( + [row(1, { state: "triage" }), row(2, { state: "awaiting", since: ago(9), days: 9 })], + "md", + "o/r", + now, + ) + expect(text).toContain("2 open") + expect(text).toContain("**Needs triage (1)**") + expect(text).toContain("[#1 PR 1](https://github.com/o/r/pull/1)") + expect(text).toContain("9d of 14") + expect(text).not.toContain("Approved") + }) + + test("lists adopt candidates and closed PRs", () => { + const text = digest( + [ + row(1, { state: "awaiting", since: ago(20), days: 20 }, { adopt: true }), + row(2, { state: "awaiting", since: ago(30), days: 30 }, { closed: true }), + ], + "slack", + "o/r", + now, + ) + expect(text).toContain("*Adopt: high-value, no reply in 14 days (1)*") + expect(text).toContain("") + expect(text).toContain("*Closed in this run (1)*") + expect(text).toContain("1 open") + }) + + test("caps long lists", () => { + const rows = Array.from({ length: 20 }, (_, i) => row(i + 1, { state: "triage" })) + expect(digest(rows, "md", "o/r", now)).toContain("...and 5 more") + }) +}) diff --git a/script/kilocode/community-triage.ts b/script/kilocode/community-triage.ts new file mode 100644 index 00000000000..d4fbfd850c7 --- /dev/null +++ b/script/kilocode/community-triage.ts @@ -0,0 +1,528 @@ +// Triage automation for community (non-maintainer) pull requests and issues. +// +// label Runs on PR/issue events (community-label.yml). Adds `community`, +// `needs-triage` and `area:*` labels. +// sweep Runs twice a week (community-sweep.yml). Derives each community PR's +// state, syncs status labels, closes stale ones and writes the digest. +// +// Flow and rules: docs/community-triage.md + +export const DAY = 24 * 60 * 60 * 1000 +export const WAIT_DAYS = 14 +const LIMIT = 15 + +export const label = { + community: "community", + triage: "needs-triage", + review: "needs-review", + awaiting: "awaiting-contributor", + ci: "ci-failing", + high: "high-value", + low: "low-value", + adopt: "needs-reimplementation", + keep: "keep-open", +} + +// Labels the sweep owns. It adds and removes these on its own. +const managed = [label.triage, label.review, label.awaiting, label.ci] + +const labels = [ + { name: label.community, color: "0e8a16", description: "Opened by someone outside the Kilo team" }, + { name: label.triage, color: "fbca04", description: "Community PR no maintainer has looked at yet" }, + { name: label.review, color: "1d76db", description: "Community PR waiting for a maintainer" }, + { name: label.awaiting, color: "d4c5f9", description: "Waiting for the contributor to reply" }, + { name: label.ci, color: "b60205", description: "Community PR with failing checks" }, + { name: label.high, color: "5319e7", description: "Maintainers want this change. Set by hand." }, + { name: label.low, color: "cfd3d7", description: "Triaged and not a priority. Set by hand." }, + { + name: label.adopt, + color: "e99695", + description: "High-value PR with no contributor reply. The team takes it over.", + }, + { name: label.keep, color: "bfdadc", description: "Never auto-close this PR. Set by hand." }, + { name: "area:vscode", color: "ededed", description: "VS Code extension" }, + { name: "area:cli", color: "ededed", description: "CLI, TUI and core engine" }, + { name: "area:agent-manager", color: "ededed", description: "Agent Manager" }, + { name: "area:jetbrains", color: "ededed", description: "JetBrains plugin" }, + { name: "area:desktop", color: "ededed", description: "Desktop and web app" }, + { name: "area:docs", color: "ededed", description: "Documentation" }, + { name: "area:gateway", color: "ededed", description: "Kilo Gateway and auth" }, + { name: "area:sdk", color: "ededed", description: "SDK" }, + { name: "area:ui", color: "ededed", description: "Shared UI components" }, + { name: "area:i18n", color: "ededed", description: "Translations" }, +] + +// Title scope, as in `fix(vscode): ...` +const scopes: Record = { + vscode: "area:vscode", + cli: "area:cli", + opencode: "area:cli", + tui: "area:cli", + core: "area:cli", + "agent-manager": "area:agent-manager", + jetbrains: "area:jetbrains", + desktop: "area:desktop", + app: "area:desktop", + docs: "area:docs", + "kilo-docs": "area:docs", + gateway: "area:gateway", + sdk: "area:sdk", + ui: "area:ui", + i18n: "area:i18n", +} + +// Changed file path prefix. The first matching rule for a file wins. +const paths: [string, string][] = [ + ["packages/kilo-vscode/src/agent-manager/", "area:agent-manager"], + ["packages/kilo-vscode/webview-ui/agent-manager/", "area:agent-manager"], + ["packages/kilo-vscode/", "area:vscode"], + ["packages/opencode/", "area:cli"], + ["packages/core/", "area:cli"], + ["packages/server/", "area:cli"], + ["packages/tui/", "area:cli"], + ["packages/kilo-jetbrains/", "area:jetbrains"], + ["packages/kilo-docs/", "area:docs"], + ["packages/kilo-gateway/", "area:gateway"], + ["packages/sdk/", "area:sdk"], + ["packages/kilo-ui/", "area:ui"], + ["packages/kilo-i18n/", "area:i18n"], + ["packages/kilo-web-ui/", "area:desktop"], +] + +// Options of the "Component" dropdown in the issue templates. +const components: Record = { + "vs code extension": "area:vscode", + "cli / tui": "area:cli", + "agent manager": "area:agent-manager", + "jetbrains plugin": "area:jetbrains", + "desktop or web app": "area:desktop", + documentation: "area:docs", + "kilo gateway or account": "area:gateway", + sdk: "area:sdk", +} + +// Plain objects inherit keys like "constructor". Only own keys are labels. +const own = (map: Record, key: string) => (Object.hasOwn(map, key) ? map[key] : undefined) + +export function areas(title: string, files: string[]) { + const set = new Set() + const scope = title.match(/^\w+\(([^)]+)\)!?:/)?.[1] + const hit = scope ? own(scopes, scope.toLowerCase()) : undefined + if (hit) set.add(hit) + for (const file of files) { + const rule = paths.find(([prefix]) => file.startsWith(prefix)) + if (rule) set.add(rule[1]) + } + return [...set].sort() +} + +export function component(body: string) { + // Issue forms render the answer as "### Component". Text the reporter typed + // can contain the same heading, so take the first one with a known answer. + for (const hit of body.matchAll(/^###\s+Component\s*\n+(.+)$/gim)) { + const tag = own(components, hit[1].trim().toLowerCase()) + if (tag) return tag + } +} + +// Clocks differ a little between a contributor and GitHub. Allow a small skew. +const SKEW = 5 * 60 * 1000 + +export function pushedAt(date: string, now: number) { + const at = Date.parse(date) + return at > now + SKEW ? undefined : at +} + +export type Event = { who: "author" | "maintainer"; at: number; approve?: boolean } + +export type State = + | { state: "triage" } + | { state: "review" } + | { state: "approved" } + | { state: "awaiting"; since: number; days: number } + +// Whose turn it is. The latest maintainer reaction decides, unless the author +// answered (comment, review reply or new commit) after it. +export function classify(events: Event[], tags: string[], now: number): State { + const last = (who: Event["who"]) => + events + .filter((e) => e.who === who) + .sort((a, b) => b.at - a.at) + .at(0) + const maintainer = last("maintainer") + const author = last("author") + if (!maintainer) return { state: tags.includes(label.high) || tags.includes(label.low) ? "review" : "triage" } + if (author && author.at > maintainer.at) return { state: "review" } + if (maintainer.approve) return { state: "approved" } + return { state: "awaiting", since: maintainer.at, days: Math.floor((now - maintainer.at) / DAY) } +} + +export function decide(current: string[], state: State, failing: boolean) { + const want = new Set() + if (state.state === "triage") want.add(label.triage) + if (state.state === "review") want.add(label.review) + if (state.state === "awaiting") want.add(label.awaiting) + if (failing) want.add(label.ci) + return { + add: [...want].filter((name) => !current.includes(name)), + remove: managed.filter((name) => !want.has(name) && current.includes(name)), + } +} + +export type Row = { + number: number + title: string + url: string + author: string + created: number + state: State + failing: boolean + adopt: boolean + closed: boolean +} + +export function digest(rows: Row[], mode: "md" | "slack", repo: string, now: number) { + const link = (row: Row) => { + const text = `#${row.number} ${row.title.replace(/[<>|]/g, "")}` + return mode === "md" ? `[${text}](${row.url})` : `<${row.url}|${text}>` + } + const age = (row: Row) => Math.floor((now - row.created) / DAY) + const bold = (text: string) => (mode === "md" ? `**${text}**` : `*${text}*`) + const open = rows.filter((row) => !row.closed) + const section = (title: string, list: Row[], note: (row: Row) => string) => { + if (list.length === 0) return [] + const shown = list.slice(0, LIMIT).map((row) => `- ${link(row)} (@${row.author}, ${note(row)})`) + const more = list.length > LIMIT ? [`- ...and ${list.length - LIMIT} more`] : [] + return ["", bold(`${title} (${list.length})`), ...shown, ...more] + } + const awaiting = (row: Row) => (row.state.state === "awaiting" ? row.state : undefined) + const state = (name: State["state"]) => open.filter((row) => row.state.state === name) + const oldest = (a: Row, b: Row) => a.created - b.created + + return [ + bold(`Community PR queue for ${repo}: ${open.length} open`), + ...section( + "Adopt: high-value, no reply in 14 days", + open.filter((row) => row.adopt), + (row) => `${awaiting(row)?.days}d silent`, + ), + ...section( + "Needs triage", + state("triage").sort(oldest), + (row) => `${age(row)}d old${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Contributor replied, needs review", + state("review").sort(oldest), + (row) => `${age(row)}d old${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Approved, ready to merge", + state("approved"), + (row) => `${age(row)}d old${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Waiting for contributor", + state("awaiting").sort((a, b) => (awaiting(b)?.days ?? 0) - (awaiting(a)?.days ?? 0)), + (row) => `${awaiting(row)?.days}d of ${WAIT_DAYS}${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Closed in this run", + rows.filter((row) => row.closed), + () => `no reply in ${WAIT_DAYS} days`, + ), + ].join("\n") +} + +// ---- GitHub I/O ---- + +const token = process.env.GH_TOKEN +const repo = process.env.REPO ?? "Kilo-Org/kilocode" +const [owner, name] = repo.split("/") +const bots = (login: string) => login.endsWith("[bot]") +const trusted = new Set(["OWNER", "MEMBER", "COLLABORATOR"]) +const extra = new Set( + (process.env.MAINTAINERS ?? "") + .split(",") + .map((x) => x.trim().toLowerCase()) + .filter(Boolean), +) +const cache = new Map() + +async function api(path: string, init?: RequestInit & { token?: string; ok?: number[] }) { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + redirect: "manual", + headers: { + Authorization: `Bearer ${init?.token ?? token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + }) + if (res.ok || init?.ok?.includes(res.status)) return res + throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status} ${await res.text()}`) +} + +// GITHUB_TOKEN cannot see private org members, so they show up as plain +// contributors. An optional MEMBER_TOKEN (read:org) or MAINTAINERS list fixes that. +async function maintainer(login: string, assoc: string) { + if (trusted.has(assoc) || extra.has(login.toLowerCase())) return true + const key = login.toLowerCase() + const hit = cache.get(key) + if (hit !== undefined) return hit + const lookup = process.env.MEMBER_TOKEN + const res = lookup ? await api(`/orgs/${owner}/members/${login}`, { token: lookup, ok: [302, 404] }) : undefined + const found = res?.status === 204 + cache.set(key, found) + return found +} + +async function ensure() { + for (const item of labels) + await api(`/repos/${repo}/labels`, { method: "POST", body: JSON.stringify(item), ok: [422] }) +} + +const add = (num: number, list: string[]) => + list.length === 0 + ? undefined + : api(`/repos/${repo}/issues/${num}/labels`, { method: "POST", body: JSON.stringify({ labels: list }) }) +const drop = (num: number, list: string[]) => + Promise.all( + list.map((item) => + api(`/repos/${repo}/issues/${num}/labels/${encodeURIComponent(item)}`, { method: "DELETE", ok: [404] }), + ), + ) +const comment = (num: number, body: string) => + api(`/repos/${repo}/issues/${num}/comments`, { method: "POST", body: JSON.stringify({ body }) }) + +async function files(num: number) { + const out: string[] = [] + for (let page = 1; ; page++) { + const list: { filename: string }[] = await ( + await api(`/repos/${repo}/pulls/${num}/files?per_page=100&page=${page}`) + ).json() + out.push(...list.map((f) => f.filename)) + if (list.length < 100 || page >= 30) return out + } +} + +async function onevent() { + const event = await Bun.file(process.env.GITHUB_EVENT_PATH!).json() + const pr = event.pull_request + const item = pr ?? event.issue + const login: string = item.user.login + if (item.user.type === "Bot" || bots(login)) return console.log(`skip bot ${login}`) + + // A reopened PR may already be triaged. The sweep decides its status. + const fresh = event.action === "opened" + const community = !(await maintainer(login, item.author_association)) + const tags = new Set() + if (community) tags.add(label.community) + if (community && fresh) tags.add(label.triage) + if (pr) for (const tag of areas(pr.title, await files(pr.number))) tags.add(tag) + if (!pr) { + const tag = component(item.body ?? "") + if (tag) tags.add(tag) + } + const have = new Set(item.labels.map((l: { name: string }) => l.name)) + const next = [...tags].filter((tag) => !have.has(tag)) + console.log(`#${item.number} ${community ? "community" : "maintainer"}: adding [${next.join(", ")}]`) + await ensure() + await add(item.number, next) +} + +type Actor = { login: string; __typename: string } | null +type Node = { createdAt: string; authorAssociation: string; author: Actor; state?: string } +type Pull = { + number: number + title: string + url: string + isDraft: boolean + createdAt: string + authorAssociation: string + author: Actor + labels: { nodes: { name: string }[] } + commits: { nodes: { commit: { committedDate: string; statusCheckRollup: { state: string } | null } }[] } + timelineItems: { nodes: { createdAt: string; actor: { login: string } | null }[] } + comments: { totalCount: number; nodes: Node[] } + reviews: { totalCount: number; nodes: Node[] } + reviewThreads: { totalCount: number; nodes: { comments: { nodes: Node[] } }[] } +} + +type Reply = { user: { login: string } | null; created_at?: string; submitted_at?: string } + +// The query reads only the latest comments, reviews and replies. Before a PR +// is closed or adopted, ask the API for everything since the maintainer's +// last reaction, so a reply outside that window cannot be missed. +async function replied(num: number, login: string, since: number) { + const iso = new Date(since).toISOString() + const get = async (path: string): Promise => (await api(`/repos/${repo}/${path}`)).json() + const mine = (list: Reply[]) => + list.some((x) => x.user?.login === login && Date.parse(x.created_at ?? x.submitted_at ?? "") > since) + if (mine(await get(`issues/${num}/comments?since=${iso}&per_page=100`))) return true + if (mine(await get(`pulls/${num}/comments?since=${iso}&per_page=100`))) return true + for (let page = 1; page <= 5; page++) { + const list = await get(`pulls/${num}/reviews?per_page=100&page=${page}`) + if (mine(list)) return true + if (list.length < 100) return false + } + return false +} + +const query = ` +query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 25, states: OPEN, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number title url isDraft createdAt + authorAssociation + author { login __typename } + labels(first: 30) { nodes { name } } + timelineItems(last: 1, itemTypes: [REOPENED_EVENT]) { nodes { ... on ReopenedEvent { createdAt actor { login } } } } + commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } } + comments(last: 20) { totalCount nodes { createdAt authorAssociation author { login __typename } } } + reviews(last: 20) { totalCount nodes { createdAt state authorAssociation author { login __typename } } } + reviewThreads(last: 20) { totalCount nodes { comments(last: 5) { nodes { createdAt authorAssociation author { login __typename } } } } } + } + } + } +}` + +async function graphql(cursor: string | null) { + const res = await api("/graphql", { + method: "POST", + body: JSON.stringify({ query, variables: { owner, repo: name, cursor } }), + }) + const json = await res.json() + if (json.errors) throw new Error(JSON.stringify(json.errors)) + return json.data.repository.pullRequests +} + +async function sweep() { + const now = Date.now() + const enabled = process.env.CLOSE_ENABLED === "true" + const cap = Number(process.env.MAX_COMMENTS ?? 20) + let spent = 0 + await ensure() + + const prs: Pull[] = [] + let cursor: string | null = null + do { + const page = await graphql(cursor) + prs.push(...page.nodes) + cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null + } while (cursor) + console.log(`${prs.length} open PRs`) + + const rows: Row[] = [] + for (const pr of prs) { + const login = pr.author?.login + if (!login || pr.author?.__typename === "Bot" || bots(login) || pr.isDraft) continue + if (await maintainer(login, pr.authorAssociation)) continue + + const tags: string[] = pr.labels.nodes.map((l: { name: string }) => l.name) + const nodes: Node[] = [ + ...pr.comments.nodes, + ...pr.reviews.nodes.filter((r: Node) => r.state !== "PENDING" && r.state !== "DISMISSED"), + ...pr.reviewThreads.nodes.flatMap((t: { comments: { nodes: Node[] } }) => t.comments.nodes), + ] + const events: Event[] = [] + for (const node of nodes) { + const who = node.author?.login + if (!who || node.author?.__typename === "Bot" || bots(who)) continue + const at = Date.parse(node.createdAt) + if (who === login) events.push({ who: "author", at }) + else if (await maintainer(who, node.authorAssociation)) + events.push({ who: "maintainer", at, approve: node.state === "APPROVED" }) + } + const commit = pr.commits.nodes.at(0)?.commit + // Commit dates come from the commit author. A date in the future would sort + // after every maintainer reaction, so it is ignored. A small clock skew is allowed. + const pushed = commit ? pushedAt(commit.committedDate, now) : undefined + if (pushed) events.push({ who: "author", at: pushed }) + // Reopening a closed PR is a reply too. + const reopen = pr.timelineItems.nodes.at(0) + if (reopen?.actor?.login === login) events.push({ who: "author", at: Date.parse(reopen.createdAt) }) + + // The query reads only the latest events. If it missed some and found no + // maintainer reaction, the reaction may be older than the window. Send the + // PR to a human, do not call it untriaged. + const cut = + pr.comments.totalCount > pr.comments.nodes.length || + pr.reviews.totalCount > pr.reviews.nodes.length || + pr.reviewThreads.totalCount > pr.reviewThreads.nodes.length + const first = classify(events, tags, now) + const state: State = + first.state === "triage" && cut + ? { state: "review" } + : first.state === "awaiting" && first.days >= WAIT_DAYS && (await replied(pr.number, login, first.since)) + ? { state: "review" } + : first + const failing = ["FAILURE", "ERROR"].includes(commit?.statusCheckRollup?.state ?? "") + const plan = decide(tags, state, failing) + const more = [...(tags.includes(label.community) ? [] : [label.community])] + if (!tags.some((tag) => tag.startsWith("area:"))) more.push(...areas(pr.title, await files(pr.number))) + const row: Row = { + number: pr.number, + title: pr.title, + url: pr.url, + author: login, + created: Date.parse(pr.createdAt), + state, + failing, + adopt: false, + closed: false, + } + + if (state.state === "awaiting" && state.days >= WAIT_DAYS) { + if (tags.includes(label.high)) { + row.adopt = true + if (!tags.includes(label.adopt)) more.push(label.adopt) + } else if (!tags.includes(label.keep)) { + if (!enabled) console.log(`[dry-run] would close #${pr.number} (${state.days}d without reply)`) + else if (spent < cap) { + spent++ + await comment( + pr.number, + `Closing this pull request because a maintainer asked for changes on ${new Date(state.since).toISOString().slice(0, 10)} and we have not heard back in ${WAIT_DAYS} days. Feel free to reopen it when you have updates, or open a new one.`, + ) + await api(`/repos/${repo}/pulls/${pr.number}`, { method: "PATCH", body: JSON.stringify({ state: "closed" }) }) + row.closed = true + console.log(`closed #${pr.number}`) + } + } + } else if (state.state === "awaiting" && plan.add.includes(label.awaiting) && enabled && spent < cap) { + spent++ + const due = new Date(state.since + WAIT_DAYS * DAY).toISOString().slice(0, 10) + await comment( + pr.number, + `Thanks for the contribution! A maintainer left feedback and is waiting for your reply. If we do not hear back by ${due}, this pull request will be closed. Push a fix or leave a comment to keep it open.`, + ) + } + + if (state.state !== "awaiting" && tags.includes(label.adopt)) plan.remove.push(label.adopt) + console.log( + `#${pr.number} ${state.state}${failing ? " ci-failing" : ""} +[${[...plan.add, ...more]}] -[${plan.remove}]`, + ) + await add(pr.number, [...plan.add, ...more]) + await drop(pr.number, row.closed ? [] : plan.remove) + rows.push(row) + } + + const out = process.env.GITHUB_STEP_SUMMARY + if (out) await Bun.write(out, digest(rows, "md", repo, now) + "\n") + const slack = process.env.SLACK_PAYLOAD + if (slack) await Bun.write(slack, JSON.stringify({ text: digest(rows, "slack", repo, now) })) + console.log(digest(rows, "md", repo, now)) +} + +if (import.meta.main) { + const cmd = process.argv[2] + if (cmd === "label") await onevent() + else if (cmd === "sweep") await sweep() + else { + console.error("usage: community-triage.ts ") + process.exit(1) + } +}