Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
github-actions[bot]
2026-10-09 07:25:31 +00:00
114 changed files with 3956 additions and 378 deletions
@@ -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.
@@ -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.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Open chat links in the system browser with Shift-click when the Integrated Browser handles link clicks
+5
View File
@@ -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.
+5
View File
@@ -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.
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+20
View File
@@ -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:
@@ -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
+6
View File
@@ -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
+2
View File
@@ -1,3 +1,5 @@
<!-- Community PRs are triaged twice a week. If a maintainer asks for changes and we hear nothing for 14 days, the PR is closed. See docs/community-triage.md. -->
## Issue
<!-- Reference an existing issue with `Fixes #123`, `Closes #123`, or equivalent linked issue wording. -->
+47
View File
@@ -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
+67
View File
@@ -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
@@ -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
+2
View File
@@ -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
+11 -11
View File
@@ -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=="],
+157
View File
@@ -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<br/>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.
+1 -1
View File
@@ -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",
@@ -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
@@ -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.
@@ -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.
+44 -44
View File
@@ -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'
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:28a0ba0675cdfb62f6f80d63a3fadcaacf215c2ad65f12f2fa04774bfdb33028
size 4940
oid sha256:352dfe36ed8a02365c18313281a048283adcf58593431384910676d2c147b9c5
size 3109
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00746923c55f811fba5e823ff8e35205f6f6c70d1e7ce440514636ce8a4d0791
size 5364
oid sha256:94937c48bf1380567e8a1084aa97303584361ad8e42e9bddbe4fd1d4a1ea050f
size 4938
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0fa512d7a35f92d135fb7f029936186dd86e8514ac9956ca99f7a8d2852f36d2
size 13981
oid sha256:18576213b3aa90acd6114cd92a4b3861240c0c14889e9486921afde5e76f015f
size 12230
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10b8abe679042e3ccd36f7baca0cdcf14ceae61faadd597aa94c7471e17ffd7c
size 5209
oid sha256:7ad974c5a1795d6d8f199614fefd923c316b169414441fd7d907b5c84b70c563
size 3165
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76f28776968833286e0dc6c2610fa7a9cc32652615b1d5dbd0d7fef092b28afc
size 5620
oid sha256:751e89229eeaf0f30aed769be94509fc66230b38e5e7373e10b5ea5f535b5ef8
size 5135
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdb59fc167c642e9c6a5e8298fe06393cc8b4ce67bfc4c588245b748d2661615
size 5584
oid sha256:539e854b91b550275df4748253147091913b3e0240b30a7141b2dd7381b21643
size 3057
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da4aac6eededda5642e50d4482a684242ddcd2502dd660140c61bafacdabbea7
size 6039
oid sha256:bffc5d14bc63e0ba18c2b4f43f36ac408c1add96f926dfdff13073b835686589
size 5672
@@ -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<string, string>
private readonly dimensions?: number
private readonly omitted = new Set<string>()
// 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<OpenAIEmbeddingResponse> {
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<OpenAIEmbeddingResponse> => {
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
@@ -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<never>()
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)
}
}
@@ -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
@@ -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 },
})
@@ -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()
},
@@ -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
}
@@ -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<boolean> {
return this.run(() => this.doReorder(ids))
}
private async doReorder(ids: string[]): Promise<boolean> {
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<boolean> {
return this.run(() => this.doSetExpanded(id, expanded))
}
@@ -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
@@ -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) }
}
@@ -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<string, BrowserOwner>()
private readonly listeners = new Set<(state: BrowserState) => void>()
private readonly viewers = new Set<(frame: BrowserFrame & Pick<BrowserRoute, "sessionId" | "projectId">) => void>()
private readonly pointers = new Set<(cursor: BrowserCursor & Pick<BrowserRoute, "sessionId" | "projectId">) => void>()
private readonly token = randomBytes(32).toString("hex")
private readonly proxies = new Set<BrowserProxy>()
private gateway: BrowserProxy | undefined
@@ -393,6 +395,11 @@ export class BrowserBroker {
return () => this.viewers.delete(listener)
}
cursors(listener: (cursor: BrowserCursor & Pick<BrowserRoute, "sessionId" | "projectId">) => 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 {
@@ -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<string, unknown>
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<string, Key>()
private readonly contexts = new Set<number>()
private reporter: Promise<void> = 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<void> {
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<void> {
this.casting = undefined
if (!this.started || !this.session) return
@@ -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"
@@ -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(
() => (
<StreamViewport
scope={() => 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<BrowserCursor & { scope: typeof scope }> = {}) => {
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()
@@ -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()
@@ -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({
@@ -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<string, unknown> }> = []
const hooks = new Map<string, () => Promise<void>>()
const frames: BrowserFrame[] = []
@@ -35,6 +35,7 @@ function protocol() {
send: async (method: string, params?: Record<string, unknown>) => {
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 = `<style>
body { margin: 0 } #cursors > * { display: block; width: 300px; height: 40px; margin: 0; font: 20px monospace }
#drag:active { cursor: grabbing !important }
</style><main id="cursors">
<a href="#link">Link</a>
<input value="Input">
<p style="cursor:default">Explicit default text</p>
<p>Native text</p>
<div style="cursor:ew-resize">Resize</div>
<div id="drag" style="cursor:url(data:image/png;base64,invalid), grab">Custom cursor fallback</div>
<button><span>Native button</span></button>
</main>`
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<void>) => {
const result = Promise.withResolvers<BrowserCursor>()
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 = '<body style="margin:0;cursor:grab;min-height:100vh">Child</body>'
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<string, unknown>).__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<BrowserCursor>()
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 })
@@ -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)
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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('<div class="prompt-input-hint-selectors">')
const selectors = dialog.indexOf("<PromptSelectors")
const actions = dialog.indexOf('<div class="prompt-input-hint-actions">', selectors)
const sandbox = dialog.indexOf("<SandboxButtonBase", actions)
const speech = dialog.indexOf("<SpeechToTextButton", actions)
@@ -5,6 +5,7 @@ import type { ExtensionMessage, WebviewMessage } from "../src/types/messages"
import { formatBrowserFeedback, type BrowserReference } from "../../src/shared/browser-feedback"
import { BrowserPanel as BrowserPanelView } from "../browser"
import {
browserCursorEvent,
browserDevtoolsEvent,
browserFrameEvent,
browserInspectionEvent,
@@ -110,6 +111,9 @@ function command(command: BrowserCommand): WebviewMessage {
}
function event(message: ExtensionMessage): BrowserEvent | undefined {
if (message.type === "agentManager.browserCursor") {
return browserCursorEvent(browserScope(message.sessionId, message.projectId), message)
}
if (message.type === "agentManager.browserFrame") {
return browserFrameEvent(browserScope(message.sessionId, message.projectId), message)
}
@@ -30,6 +30,7 @@ import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector"
import { PromptSelectors } from "../src/components/chat/PromptSelectors"
import { SandboxButtonBase, SandboxTooltipContent } from "../src/components/shared/SandboxButton"
import {
MultiModelSelector,
@@ -1002,42 +1003,51 @@ export const NewWorktreeDialog: Component<{
</div>
</div>
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<Show when={session.agents().length > 1}>
<ModeSwitcherBase
agents={session.agents()}
value={agent()}
onSelect={selectAgent}
trigger={WORKTREE_PROMPT_SCOPE}
portal={false}
deferDismiss
/>
</Show>
<Show when={!compareMode()}>
<ModelSelectorBase
value={model()}
onSelect={selectModel}
onPick={restorePrompt}
onCancel={restorePrompt}
trigger={WORKTREE_PROMPT_SCOPE}
placement="top-start"
portal={false}
deferDismiss
/>
<ThinkingSelectorBase
variants={variants()}
value={effectiveVariant()}
onSelect={selectVariant}
onClear={() => selectVariant(DEFAULT_VARIANT)}
allowClear
clearLabel={t("common.default")}
trigger={WORKTREE_PROMPT_SCOPE}
portal={false}
deferDismiss
cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false}
/>
</Show>
</div>
<PromptSelectors
agent={
<Show when={session.agents().length > 1}>
<ModeSwitcherBase
agents={session.agents()}
value={agent()}
onSelect={selectAgent}
trigger={WORKTREE_PROMPT_SCOPE}
portal={false}
deferDismiss
/>
</Show>
}
model={
<Show when={!compareMode()}>
<ModelSelectorBase
value={model()}
onSelect={selectModel}
onPick={restorePrompt}
onCancel={restorePrompt}
trigger={WORKTREE_PROMPT_SCOPE}
placement="top-start"
portal={false}
deferDismiss
/>
</Show>
}
variant={
<Show when={!compareMode()}>
<ThinkingSelectorBase
variants={variants()}
value={effectiveVariant()}
onSelect={selectVariant}
onClear={() => 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}
/>
</Show>
}
/>
<div class="prompt-input-hint-actions">
<Tooltip value={t("prompt.action.enhance")} placement="top">
<IconButton
@@ -301,6 +301,7 @@ export const ProjectList: Component<Props> = (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
@@ -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<ProjectsSectionProps> = (props) => (
<div class="am-projects">
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.projects")}</span>}
actions={props.tools}
/>
<div class="am-projects-list">
<For each={props.projects.map((project) => project.id)}>
{(id) => {
const project = () => props.projects.find((item) => item.id === id)!
return (
<div class="am-project">
<SidebarSectionHeader
class="am-project-item"
expanded={project().expanded}
ariaLabel={project().label}
icon={<ProjectAvatar label={project().label} src={project().avatar} />}
title={project().missing ? props.t("agentManager.project.missing") : project().root}
label={
<>
<span class="am-project-label">{project().label}</span>
<Show when={props.count(project().id) !== undefined}>
<span class="am-project-count">({props.count(project().id)})</span>
</Show>
<Show when={project().missing}>
<Icon name="warning" size="small" />
</Show>
</>
}
actions={
<ProjectRowActions
branch={props.baseBranch(project().id)}
bindings={props.bindings}
t={props.t}
pinned={project().pinned}
onCreate={() => 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<ProjectsSectionProps> = (props) => {
// Optimistic order after a drop, until the extension pushes new snapshots.
const [local, setLocal] = createSignal<string[]>()
const [dragging, setDragging] = createSignal<string>()
const [drop, setDrop] = createSignal<ProjectDrop>()
const nodes = new Map<string, HTMLDivElement>()
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 (
<div class="am-projects">
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.projects")}</span>}
actions={props.tools}
/>
<DragDropProvider onDragStart={onDragStart} onDragMove={onDragMove} onDragEnd={onDragEnd}>
<DragDropSensors />
<ConstrainDragXAxis />
<div ref={list} class="am-projects-list">
<For each={projects().map((project) => 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 (
<div
ref={(el) => 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,
}}
>
<SidebarSectionHeader
ref={(el) => {
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={<ProjectAvatar label={project().label} src={project().avatar} />}
title={project().missing ? props.t("agentManager.project.missing") : project().root}
label={
<>
<span class="am-project-label">{project().label}</span>
<Show when={props.count(project().id) !== undefined}>
<span class="am-project-count">({props.count(project().id)})</span>
</Show>
<Show when={project().missing}>
<Icon name="warning" size="small" />
</Show>
</>
}
actions={
<ProjectRowActions
branch={props.baseBranch(project().id)}
bindings={props.bindings}
t={props.t}
pinned={project().pinned}
onCreate={() => 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)
}}
/>
<Show when={project().expanded}>
<ProjectBodySlot project={project} body={props.body} />
</Show>
</div>
)
}}
</For>
<Show when={project().expanded}>
<ProjectBodySlot project={project} body={props.body} />
</Show>
</div>
)
}}
</For>
</div>
<DragOverlay class="am-project-drag-layer">
{(() => {
const project = projects().find((item) => item.id === dragging())
if (!project) return null
return (
<div class="am-wt-overlay am-project-overlay">
<ProjectAvatar label={project.label} src={project.avatar} />
<span>{project.label}</span>
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
<ProjectsFooter t={props.t} onCreate={props.onCreateProject} onAdd={props.onAdd} onClone={props.onClone} />
</div>
<ProjectsFooter t={props.t} onCreate={props.onCreateProject} onAdd={props.onAdd} onClone={props.onClone} />
</div>
)
)
}
@@ -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> = (props) => {
return (
<div
ref={props.ref}
class={`am-sidebar-header${props.onToggle ? " am-sidebar-header-toggleable" : ""}${props.class ? ` ${props.class}` : ""}`}
title={props.title}
onClick={(event) => {
@@ -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
@@ -267,7 +267,7 @@ export const PRPanel: Component<PRPanelProps> = (props) => {
</Tooltip>
<Tooltip value="Open in browser" placement="bottom">
<IconButton
icon="link"
icon="square-arrow-top-right"
size="small"
variant="ghost"
label="Open in browser"
@@ -0,0 +1,49 @@
/** Pure helpers for drag-and-drop reordering of sidebar projects. */
export interface ProjectRow {
id: string
top: number
bottom: number
}
export interface ProjectDrop {
id: string
after: boolean
}
/**
* Find the insertion point for a dragged project from a vertical position.
* `rows` are the movable (not pinned) projects in display order. The upper
* half of a row inserts before it and the lower half inserts after it, so
* tall expanded projects do not make the target flicker.
*/
export function projectDrop(rows: ProjectRow[], y: number): ProjectDrop | undefined {
const first = rows.at(0)
const last = rows.at(-1)
if (!first || !last) return
if (y < first.top) return { id: first.id, after: false }
const row = rows.find((item) => 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<T extends { id: string; pinned: boolean }>(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]
}
@@ -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)
}
@@ -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<string, Extract<BrowserInteraction, { kind: "key" }>>()
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()
@@ -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,
@@ -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) => {
@@ -2,6 +2,7 @@ export { BrowserPanel } from "./BrowserPanel"
export { createBrowserController } from "./controller"
export type { BrowserController, BrowserControllerOptions } from "./controller"
export {
browserCursorEvent,
browserDevtoolsEvent,
browserFrameEvent,
browserInspectionEvent,
@@ -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
@@ -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<ChatViewProps> = (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
@@ -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<PromptInputProps> = (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<HTMLElement>(".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 (
<div
ref={containerRef}
@@ -2293,78 +2377,100 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</div>
</div>
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<ModeSwitcher sessionID={sid} blocked={props.blocked?.() ?? false} hint={modeHint()} />
<ModelSelector sessionID={sid} blocked={props.blocked?.() ?? false} />
<ThinkingSelector sessionID={sid} blocked={props.blocked?.() ?? false} />
</div>
<div class="prompt-input-hint" ref={hintRef}>
<PromptSelectors
agent={<ModeSwitcher sessionID={sid} blocked={props.blocked?.() ?? false} hint={modeHint()} />}
model={<ModelSelector sessionID={sid} blocked={props.blocked?.() ?? false} />}
variant={<ThinkingSelector sessionID={sid} blocked={props.blocked?.() ?? false} />}
/>
<div class="prompt-input-hint-actions">
<div class="prompt-action" data-folded={hidden().length === 0 ? "" : undefined}>
<PromptOverflow items={hidden()} />
</div>
<Show when={showIndexing()}>
<Tooltip value={indexing.status().message || indexing.label()} placement="top" openDelay={0}>
<div class="prompt-action" data-folded={folded("indexing") ? "" : undefined}>
<Tooltip value={indexing.status().message || indexing.label()} placement="top" openDelay={0}>
<IconButton
icon="database"
variant="ghost"
size="small"
onClick={handleOpenIndexingSettings}
aria-label={language.t("prompt.action.indexing")}
class={`prompt-indexing-button prompt-indexing-button--${indexing.tone()}`}
/>
</Tooltip>
</div>
</Show>
<Show when={sandboxVisible()}>
<div class="prompt-action" data-folded={folded("sandbox") ? "" : undefined}>
<SandboxButtonBase
enabled={sandboxEnabled()}
available={sandboxReady() ? sandboxAvailable() : undefined}
reason={sandboxReason()}
disabled={sandboxDisabled()}
tooltip={<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />}
tooltipClass="prompt-sandbox-tooltip-content"
onToggle={toggleSandbox}
/>
</div>
</Show>
<div class="prompt-action" data-folded={folded("enhance") ? "" : undefined}>
<Tooltip value={language.t("prompt.action.enhance")} placement="top" openDelay={0}>
<IconButton
icon="database"
icon="wand-sparkles"
variant="ghost"
size="small"
onClick={handleOpenIndexingSettings}
aria-label={language.t("prompt.action.indexing")}
class={`prompt-indexing-button prompt-indexing-button--${indexing.tone()}`}
onClick={handleEnhance}
disabled={!canEnhance()}
loading={enhancing()}
aria-label={language.t("prompt.action.enhance")}
/>
</Tooltip>
</Show>
<Tooltip
value={`${language.t(
autoApprove() ? "prompt.action.autoApprove.enabled" : "prompt.action.autoApprove.disabled",
)} ${language.t("prompt.action.autoApprove.sandboxExcluded")}`}
placement="top"
openDelay={0}
>
<IconButton
icon="shield"
variant="ghost"
size="small"
onClick={() => 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" : ""}`}
/>
</Tooltip>
<Show when={sandboxVisible()}>
<SandboxButtonBase
enabled={sandboxEnabled()}
available={sandboxReady() ? sandboxAvailable() : undefined}
reason={sandboxReason()}
disabled={sandboxDisabled()}
tooltip={<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />}
tooltipClass="prompt-sandbox-tooltip-content"
onToggle={toggleSandbox}
/>
</Show>
<Tooltip value={language.t("prompt.action.enhance")} placement="top" openDelay={0}>
<IconButton
icon="wand-sparkles"
variant="ghost"
size="small"
onClick={handleEnhance}
disabled={!canEnhance()}
loading={enhancing()}
aria-label={language.t("prompt.action.enhance")}
/>
</Tooltip>
<Show when={canUseSpeech()}>
<SpeechToTextButton speech={speech} disabled={isDisabled()} start={startSpeech} label={language.t} />
</Show>
<Show
when={showStop()}
fallback={
<Tooltip value={sendLabel()} placement="top" openDelay={0}>
<Show
when={goal.active()}
fallback={
</div>
<div class="prompt-action" data-folded={folded("approve") ? "" : undefined}>
<Tooltip
value={`${language.t(
autoApprove() ? "prompt.action.autoApprove.enabled" : "prompt.action.autoApprove.disabled",
)} ${language.t("prompt.action.autoApprove.sandboxExcluded")}`}
placement="top"
openDelay={0}
>
<IconButton
icon="shield"
variant="ghost"
size="small"
onClick={() => 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" : ""}`}
/>
</Tooltip>
</div>
<div class="prompt-input-hint-pinned" ref={pinnedRef}>
<Show when={canUseSpeech()}>
<SpeechToTextButton speech={speech} disabled={isDisabled()} start={startSpeech} label={language.t} />
</Show>
<Show
when={showStop()}
fallback={
<Tooltip value={sendLabel()} placement="top" openDelay={0}>
<Show
when={goal.active()}
fallback={
<IconButton
icon="send"
variant="ghost"
size="small"
onClick={handleSendClick}
disabled={!canSend()}
aria-label={sendLabel()}
/>
}
>
<IconButton
icon="send"
variant="ghost"
@@ -2372,33 +2478,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={handleSendClick}
disabled={!canSend()}
aria-label={sendLabel()}
/>
}
>
<IconButton
icon="send"
variant="ghost"
size="small"
onClick={handleSendClick}
disabled={!canSend()}
aria-label={sendLabel()}
>
{language.t("prompt.goal.start")}
</IconButton>
</Show>
>
{language.t("prompt.goal.start")}
</IconButton>
</Show>
</Tooltip>
}
>
<Tooltip value={stopLabel()} placement="top" openDelay={0}>
<IconButton
icon="stop"
variant="ghost"
size="small"
onClick={() => session.abort()}
aria-label={language.t("prompt.action.stop")}
/>
</Tooltip>
}
>
<Tooltip value={stopLabel()} placement="top" openDelay={0}>
<IconButton
icon="stop"
variant="ghost"
size="small"
onClick={() => session.abort()}
aria-label={language.t("prompt.action.stop")}
/>
</Tooltip>
</Show>
</Show>
</div>
</div>
</div>
</div>
@@ -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<Tone, number> = { success: 1, warning: 2, error: 3 }
/** The "..." button that holds the prompt actions folded out of the toolbar. */
export const PromptOverflow: Component<Props> = (props) => {
const language = useLanguage()
// The most important folded state wins: error, then warning, then an enabled toggle.
const dot = () =>
props.items.reduce<Tone | undefined>((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 (
<DropdownMenu gutter={4} placement="top-end">
<Tooltip value={language.t("prompt.action.more")} placement="top" openDelay={0}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
size="small"
class="prompt-more-button"
data-dot={dot()}
data-busy={busy() ? "" : undefined}
aria-label={language.t("prompt.action.more")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content class="prompt-more-menu">
<For each={props.items}>
{(item) => (
<DropdownMenu.Item disabled={item.disabled || item.busy} onSelect={item.run} data-tone={item.tone}>
<span class="prompt-more-icon">
<Show when={item.busy} fallback={<Icon name={item.icon} size="small" />}>
<Spinner />
</Show>
</span>
<span class="prompt-more-text">
<DropdownMenu.ItemLabel>{item.label}</DropdownMenu.ItemLabel>
<Show when={item.description}>
<DropdownMenu.ItemDescription>{item.description}</DropdownMenu.ItemDescription>
</Show>
</span>
</DropdownMenu.Item>
)}
</For>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
@@ -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<HTMLButtonElement>(`.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<HTMLElement>(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<HTMLElement>(".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> = (props) => {
let root: HTMLDivElement | undefined
onMount(() => {
const el = root
if (!el) return
const state = {
frame: 0,
timer: undefined as ReturnType<typeof setTimeout> | 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<HTMLButtonElement>(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 (
<div class="prompt-selectors" ref={root} onPointerOver={onPointerOver} onKeyDown={onKeyDown}>
<div class="prompt-selector" data-part="agent">
{props.agent}
</div>
<div class="prompt-selector" data-part="model">
{props.model}
</div>
<div class="prompt-selector" data-part="variant">
{props.variant}
</div>
</div>
)
}
@@ -233,6 +233,8 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
}
const stoppable = () => !props.readonly && !!childSessionId() && avatar() === "running"
const openLabel = createMemo(() => (worktree ? language.t("task.open.panel") : language.t("task.open.tab")))
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured" data-component="task-tool-heading">
<div data-slot="basic-tool-tool-info-main">
@@ -272,13 +274,15 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
/>
</Tooltip>
</Show>
<IconButton
icon="square-arrow-top-right"
size="small"
variant="ghost"
aria-label={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"}
onClick={openInTab}
/>
<Tooltip value={openLabel()} placement="top">
<IconButton
icon="square-arrow-top-right"
size="small"
variant="ghost"
aria-label={openLabel()}
onClick={openInTab}
/>
</Tooltip>
</Show>
</div>
)
@@ -292,7 +296,7 @@ const TaskToolRenderer: Component<ToolProps> = (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}
>
<AgentAvatar id={childSessionId() ?? ""} status={avatar()} />
@@ -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)))
}
@@ -164,8 +164,18 @@ export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
triggerProps={{ variant: "ghost", size: "small", disabled: props.blocked }}
trigger={
<>
<span class="mode-switcher-trigger-label">{triggerLabel()}</span>
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ "flex-shrink": "0" }}>
{/* Keyed so the label remounts on change and the prompt pill can animate it. */}
<Show when={triggerLabel()} keyed>
{(label) => <span class="mode-switcher-trigger-label">{label}</span>}
</Show>
<svg
class="selector-trigger-chevron"
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
style={{ "flex-shrink": "0" }}
>
<path d="M8 4l4 5H4l4-5z" />
</svg>
</>
@@ -881,7 +881,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
}}
trigger={
<>
<span class="model-selector-trigger-label">{triggerLabel()}</span>
{/* Keyed so the label remounts on change and the prompt pill can animate it. */}
<Show when={triggerLabel()} keyed>
{(label) => <span class="model-selector-trigger-label">{label}</span>}
</Show>
<Show when={activeCollectsData()}>
<Tooltip value={dataLabel()} placement="top" openDelay={0}>
<span class="model-selector-trigger-free-data" aria-label={dataLabel()}>
@@ -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<ThinkingSelectorBaseProps> = (props) => {
@@ -80,7 +82,13 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (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<ThinkingSelectorBaseProps> = (props
<Tooltip
value={
<div data-slot="tooltip-keybind">
<span>{language.t("prompt.thinking.tooltip")}</span>
<span>{`${language.t("prompt.thinking.tooltip")}: ${display(props.value)}`}</span>
<Show when={props.cycleHint}>
<span data-slot="tooltip-keybind-key">Shift+Tab</span>
</Show>
@@ -201,8 +209,22 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label, disabled: props.blocked }}
trigger={
<>
<span class="thinking-selector-trigger-label">{display(props.value)}</span>
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ "flex-shrink": "0" }}>
{/* Keyed so the label remounts on change and the prompt pill can animate it. */}
<Show when={display(props.value)} keyed>
{(label) => (
<span class="thinking-selector-trigger-label" data-unset={props.value ? undefined : ""}>
{label}
</span>
)}
</Show>
<svg
class="selector-trigger-chevron"
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
style={{ "flex-shrink": "0" }}
>
<path d="M8 4l4 5H4l4-5z" />
</svg>
</>
@@ -212,10 +234,16 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
<div
class="thinking-selector-list"
role="listbox"
aria-label={props.heading}
ref={listRef}
onKeyDown={onKeyDown}
style={bodyH() !== undefined ? { "max-height": `${bodyH()}px` } : {}}
>
<Show when={props.heading}>
<div class="thinking-selector-heading" aria-hidden="true">
{props.heading}
</div>
</Show>
<For each={rows()}>
{(v, i) => (
<div
@@ -263,6 +291,7 @@ export const ThinkingSelector: Component<ThinkingSelectorProps> = (props) => {
onClear={() => session.selectVariant(undefined, id())}
allowClear
clearLabel={language.t("common.default")}
heading={language.t("prompt.thinking.tooltip")}
cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false}
/>
)
+3
View File
@@ -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": "حفظ",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -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",
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "رد کردن",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "保存",
+3
View File
@@ -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": "저장",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "Сохранить",
+3
View File
@@ -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": "บันทึก",
+3
View File
@@ -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",
+3
View File
@@ -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": "Скасувати",
+3
View File
@@ -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": "保存",
+3
View File
@@ -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": "儲存",
@@ -2453,6 +2453,7 @@ const ProjectsStory = () => (
onSelect={() => {}}
onRemove={() => {}}
onExpand={() => {}}
onReorder={() => {}}
onHistory={() => {}}
onNew={() => {}}
onCreate={() => {}}
@@ -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));
@@ -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;
@@ -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;
}
@@ -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

Some files were not shown because too many files have changed in this diff Show More