Merge remote-tracking branch 'origin/main' into fix-agent-manager-multi-project-id

# Conflicts:
#	packages/opencode/src/kilocode/sandbox/policy.ts
This commit is contained in:
marius-kilocode
2026-08-07 13:45:12 +02:00
230 changed files with 6435 additions and 1531 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Improve Agent Manager terminal focus and keyboard navigation. `Cmd+/` now focuses a visible embedded terminal before hiding it, `Cmd+Shift+T` creates a side terminal only while that terminal area has focus, and `Cmd+Shift+[` / `]` switch terminal tabs. `Cmd+Shift+M` focuses the Agent Manager prompt instead of opening VS Code Problems. `Cmd+W` hides the last side terminal instead of stopping its shell.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Align multi-project Agent Manager header actions with the worktree controls.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Improve model search relevance with provider-aware results and personalized usage suggestions.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Let Agent Manager users choose the repository when creating or importing a worktree in multi-project mode.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Update the active model, mode, and thinking selectors when executing a slash command with configured overrides.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Add kilocode command-file endpoints so clients can list editable command/workflow files, inspect model and reasoning variant metadata, and remove them.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Compress speech-to-text audio input to AAC format across macOS, Linux, and Windows to prevent payload size errors on long recordings.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Start Agent Manager worktree sessions faster by prefetching base branches, reducing workspace file-watcher load, and overlapping independent multi-session setup.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix the Agent Manager terminal toolbar button to toggle panel visibility directly.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix the sidebar navigation bar (New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, Settings) disappearing in Cursor when the Kilo Code view is docked in the Secondary Side Bar. Cursor now renders the navigation inside the webview itself so it stays visible regardless of dock location. VS Code is unaffected — it continues to use its native title bar toolbar, which already worked correctly everywhere.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support the Agent Manager tool with llama.cpp servers that reject prefix-only JSON Schema patterns.
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---
Add nested slash command suggestions for `/review` in VS Code and support `staged`, `unpushed`, and `quick` review modes.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve the selected reasoning effort when switching to a model that supports the same or nearest available variant.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Avoid printing an error when closing the TUI cancels in-flight startup refreshes.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Remove unsupported `kilo web` CLI command.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent concurrent Kilo startups from rewriting unchanged credentials, retry transient database locks, and redact bound values from database errors.
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Apply saved sandbox settings to existing sessions and use the latest settings when enabling sandboxing
+1 -1
View File
@@ -2,4 +2,4 @@
"kilo-code": patch
---
Persist the Agent Manager inspector width and share it between the terminal and diff viewer.
Persist the Agent Manager inspector width, share it between the terminal and diff viewer, and keep resizing responsive.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Translate the Agent Manager terminal focus shortcut label in all supported locales.
@@ -41,6 +41,9 @@ jobs:
- name: Check Effect Promise facade allowlist
run: bun run script/check-opencode-promise-facades.ts
- name: Check domain architecture boundaries and ratchets
run: bun run script/check-architecture.ts
- name: Check model tool network boundary
run: bun run script/check-model-tool-network.ts
@@ -1,459 +0,0 @@
# Agent Manager multi-project implementation handoff
**Status:** Implementation-ready handoff from the current branch
**Date:** 2026-07-22
This document tells the next implementation model exactly how to continue from branch `abalone-bactrosaurus` at commit `8f48da2278` plus the uncommitted plan files. It is the execution checklist. Detailed decisions live in:
- [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md) for configuration ownership, bindings, revisions, indexing consent, and settings tests;
- [`agent-manager-multi-project-uniform-ui.md`](./agent-manager-multi-project-uniform-ui.md) for runtime, routing, lifecycle, uniform sidebar UI, and acceptance criteria.
If this handoff conflicts with either architecture document, stop and resolve the contradiction before coding.
## Goal
Finish the current prototype into a safe, complete multi-project Agent Manager behind a default-off VS Code experimental flag.
The feature is done only when:
- multiple expanded projects show the same full Agent Manager sidebar behavior;
- every action uses explicit project/worktree/session ownership;
- Settings cannot write the wrong project's config;
- indexing requires machine-local consent per canonical project;
- project identity is canonical, Git-root-aware, common-dir-safe, and remote-authority-aware;
- single-project behavior remains unchanged when the flag is off.
## Current branch baseline
### Implemented and worth keeping
- persistent additional-project registry with serialized/re-read mutations;
- separate `ProjectContext` objects and per-project Agent Manager state files;
- project-stamped state, stats, PR, and session payloads;
- canonical worktree presence comparison for symlink/`/tmp` aliases;
- per-project session listing from project root and worktree directories;
- partial production `ProjectRouteService` integration and raw-session ambiguity rejection;
- project-qualified sidebar DOM IDs and cross-project previous/next navigation;
- atomic selection validation and same-project activation fast path;
- flag-off return to pinned project;
- Experimental Settings toggle and translations;
- focused unit tests for registry, contexts, paths, pollers, routing, sessions, selection, and navigation.
### Not implemented or incomplete
- immutable/revisioned Settings bindings;
- machine-local indexing consent;
- canonical pinned Git toplevel, `commonGitDir`, URI scheme/authority identity;
- strict project envelopes for all Agent Manager operations;
- complete route-service use for every session/terminal/diff/share/permission operation;
- one full sidebar body shared by single- and multi-project modes;
- uniform per-context poller and stale-state ownership;
- all repository mutations through `ProjectContext.run()`;
- complete real two-project E2E verification.
### Known diff cleanup
- remove unrelated `packages/opencode/package.json` ordering/dependency drift;
- restore unrelated `bun.lock` ordering/version drift unless required by a retained change;
- remove `experimental.multi_project` from the CLI config schema and generated SDK once the VS Code flag is the sole source of truth;
- rewrite the changeset after final behavior is complete.
Do not reset or discard unrelated user changes. Inspect every cleanup diff before applying it.
## Non-negotiable invariants
1. Agent Manager activation is not a Settings write target.
2. No identified operation falls back to the active project or workspace root in multi-project mode.
3. A project config write targets the explicitly selected registered project root, never an active worktree.
4. Runtime config remains directory-correct: project Local uses root, worktree sessions use exact worktree directory.
5. Repository config cannot grant indexing consent.
6. New projects default to indexing disabled on this machine.
7. Provider/API credentials and indexing infrastructure never enter project config through Settings.
8. Raw session/worktree IDs are never sufficient UI/runtime identity when multiple projects are exposed.
9. The full existing sidebar behavior is reused; do not maintain a simplified multi-project copy.
10. Do not raise `AgentManagerProvider.ts` or `AgentManagerApp.tsx` line caps.
## Work order
Complete slices in order. Do not start the next slice while the current slice's tests or checks fail.
## Slice 0A: consolidate the feature flag
### Required result
Use one source of truth: VS Code application setting `kilo-code.new.experimental.multiProject`.
### Required changes
1. Remove `experimental.multi_project` from:
- `packages/core/src/v1/config/config.ts`;
- `packages/sdk/js/src/v2/gen/types.gen.ts` by regenerating the SDK after schema removal;
- `packages/kilo-vscode/webview-ui/src/types/messages/config.ts`.
2. Do not hand-edit generated SDK files. Run `./script/generate.ts` from repository root after endpoint/schema changes.
3. Change `ExperimentalTab.tsx` so the toggle reads the VS Code setting delivered in `configLoaded.settings`, not `config().experimental`.
4. Add `multiProject` to the extension settings payload sent by `KiloProvider.fetchAndSendConfig()`.
5. Toggling it sends only `updateSetting("experimental.multiProject", checked)`.
6. Keep `VscodeHost.multiProject()` and its change listener reading the same VS Code setting.
### Tests
- toggle initial state reflects VS Code setting when CLI config disagrees;
- toggling updates the VS Code setting and Agent Manager reacts;
- no CLI/project config file gains `experimental.multi_project`.
### Stop gate
Run from `packages/kilo-vscode/`:
```sh
bun run format
bun run format:check
bun run typecheck
bun run lint
bun run test:unit
```
Do not continue if any command fails.
## Slice 0B: machine-local indexing consent
### Required result
Indexing enablement is explicit machine-local consent keyed by canonical `ProjectId`, default false. Project config controls indexing rules but cannot enable indexing.
### Required changes
1. Add a versioned machine-local consent store in the extension, not repository config and not synced across machines.
2. Use canonical project identity as the key. The pinned project and registered projects use the same identity resolver.
3. Remove hidden routing of `indexing.enabled` to project config from `webview-ui/src/utils/config-scope.ts`.
4. Remove project/global inheritance logic for `indexing.enabled` from `indexing-tab-state.ts`.
5. The Indexing UI requires an explicit project selector for enablement and reads/writes consent through dedicated messages.
6. Keep these in User config only:
- provider;
- model and dimension;
- credentials/API keys/base URLs;
- vector-store type and connection/storage settings;
- machine/infrastructure tuning defaults.
7. Keep these available in explicit Project scope:
- file extensions;
- repository include/ignore rules;
- deliberate repository chunking/tuning overrides.
8. Indexing startup/status must require both valid User indexing configuration and consent for the routed project.
9. A repository containing `indexing.enabled: true` must not enable indexing.
### Suggested files
- new VS Code-free consent store under `src/indexing/` or `src/agent-manager/`;
- `src/KiloProvider.ts` only as a thin protocol adapter;
- `webview-ui/src/components/settings/IndexingTab.tsx`;
- `webview-ui/src/components/settings/indexing-tab-state.ts`;
- `webview-ui/src/context/config.tsx` only if necessary;
- focused tests beside existing indexing tests.
### Tests
- unknown project defaults off;
- A enabled does not enable B;
- symlink/path alias resolves to the same consent;
- repository config cannot grant consent;
- removing/re-adding behavior follows the documented store policy;
- no credentials/storage settings are emitted in a Project-scope patch.
## Slice 0C: revisioned config overlay backend
### Required result
Config reads return authoritative target descriptors and revisions. Writes use compare-and-swap and cannot overwrite external changes or a changed target.
### Required changes
1. Extend Kilo-owned config overlay code:
- `packages/opencode/src/kilocode/config/overlay.ts`;
- a new Kilo-owned writer under `packages/opencode/src/kilocode/config/`;
- `packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts`;
- `packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts`.
2. Read response includes for global and project targets:
- canonical path;
- scope;
- exists/writable;
- SHA-256 revision of canonical path, existence marker, and exact bytes;
- raw parsed target layer;
- effective config and source metadata.
3. Write request accepts exactly one scope, `set`, `unset`, and expected path/revision.
4. Server re-resolves the authoritative target. Never trust client path as a destination.
5. Lock by canonical target path, re-read inside lock, compare revision, patch raw JSONC, validate, and atomically replace.
6. Return a fresh authoritative snapshot.
7. Return typed 409 conflicts for target/revision changes; preserve drafts client-side.
8. Keep Kilo logic in `packages/opencode/src/kilocode/`. Shared upstream files get minimal marked delegation only when unavoidable.
9. Regenerate the SDK after endpoint changes.
### Tests
Add/extend:
- `packages/opencode/test/kilocode/server/config-overlay.test.ts`;
- `packages/opencode/test/kilocode/project-config-update.test.ts`.
Cover:
- exact raw target layer;
- comment-only external edit conflict;
- missing-file target revision;
- newly created higher-priority target conflict;
- concurrent writers: one success, one 409;
- symlink escape rejection;
- global and project writes remain separate;
- atomic write failure never exposes partial content.
### Stop gate
From `packages/opencode/`:
```sh
bun run typecheck
bun test test/kilocode/server/config-overlay.test.ts
bun test test/kilocode/project-config-update.test.ts
```
From repository root:
```sh
./script/generate.ts
bun run script/check-opencode-annotations.ts
bun run script/check-opencode-promise-facades.ts
```
## Slice 0D: immutable Settings bindings
### Required result
Settings has explicit `User | Project` scope. Project scope requires an explicit trusted project selector. Drafts and saves never follow Agent Manager activation.
### Required changes
1. Split runtime-effective config from Settings editor config.
2. Add a binding controller under `packages/kilo-vscode/src/kilo-provider/`; keep `KiloProvider.ts` as a thin adapter.
3. Replace unqualified config protocol with:
- read request carrying request ID, scope, and optional project ID;
- snapshot carrying opaque binding ID and source/target metadata;
- write request carrying request ID, binding ID, set, and unset.
4. The extension stores authoritative bindings. The webview never supplies a writable path.
5. Project binding resolution:
- resolve registered `ProjectContext.root`;
- require project existence, matching generation, and trust;
- never use active worktree/session or `getWorkspaceDirectory()` after binding creation.
6. Bindings expire on successful save, reconnect, project removal, generation change, or trust revocation.
7. Separate drafts by scope/project binding.
8. Dirty selector changes prompt Save, Discard, or Stay.
9. Out-of-order reads/writes update only matching request/binding.
10. External config events refresh clean drafts and mark dirty drafts stale without overwriting them.
11. Remove `splitConfigByScope()` after all controls declare scope explicitly.
12. Audit direct config mutators outside the save bar, including provider disconnect, imports/resets, custom providers, work styles, Permission Dock, MCP actions, and indexing.
13. Open Project Config takes explicit `ProjectRef`, checks trust, and resolves the registered root.
### UI scope policy
Use the table in `agent-manager-multi-project-configuration.md`. Do not invent another policy.
### Blocking tests
- load A, activate B, save A: only A target changes;
- same while selecting worktrees/sessions;
- User save changes only user config;
- Project save changes only selected trusted project's root config;
- dirty draft cannot migrate to another project;
- project removal/trust revocation expires binding;
- external edit returns conflict and preserves draft;
- no save-time active-directory lookup occurs.
## Slice 1: canonical project identity
### Required result
Every project is a canonical Git repository root with URI authority and common Git directory identity.
### Required changes
1. Replace path-only project descriptors with:
```ts
{
id,
uri,
scheme,
authority,
root,
commonGitDir,
label,
order,
trusted,
addedAt
}
```
2. Pinned and added projects use the same resolver:
- validate URI host scope;
- `git rev-parse --show-toplevel`;
- `git rev-parse --path-format=absolute --git-common-dir`;
- realpath/normalize both;
- derive ID from scheme, authority, and canonical root.
3. Opening VS Code in a repository subdirectory still uses the Git toplevel and root state file.
4. Reject another exposed project sharing canonical `commonGitDir`.
5. Key Git mutation locks by common Git directory.
6. Preserve remote URI scheme/authority in picker, storage, and open-folder operations.
7. Migrate or safely read the current registry version without dropping valid entries.
### Tests
- workspace subdirectory resolves to repository root;
- symlink aliases dedupe;
- linked worktree/common-dir duplicate rejected;
- other remote authority hidden but preserved;
- missing/non-Git entry remains removable and never falls back to pinned root.
## Slice 2: strict routing and lifecycle
### Required result
Every repository-bound operation is explicitly project-qualified once multiple projects are exposed.
### Required changes
1. Introduce one project envelope at the Agent Manager boundary with project ID and generation.
2. Compatibility adapter injects pinned identity only in single-project mode.
3. Multi-project mode rejects missing/mismatched identity with typed errors.
4. Wire `ProjectRouteService` into every existing-session operation:
- transcript/messages;
- prompt/abort;
- share/unshare;
- fork/continuation;
- permission/question responses;
- terminal;
- file/context operations;
- diff/apply/revert.
5. Route global SSE events once by exact directory/session ownership.
6. Preserve non-Agent-Manager KiloProvider behavior.
7. Execute repository mutations through `ctx.run()` and generation-check commits.
8. Removal waits for mutation queue, stops pollers/watchers, flushes state, detaches routes, then removes registry entry.
9. Flag-off activates pinned Local and suspends secondaries without aborting sessions.
### Tests
- same raw session ID across projects is ambiguous without qualifier;
- B Local/share/unshare/permission never routes through A;
- unknown IDs produce no terminal/file/diff operation;
- project switch during mutation cannot change operation ownership;
- late completion after removal cannot mutate replacement context.
## Slice 3: uniform context-owned state and polling
### Required result
Every initialized expanded project owns the same live-state services. Active selection changes cadence/detail only, not ownership.
### Required changes
1. Move stats and PR pollers into `ProjectContext`; remove active singleton/background split.
2. Each context owns stale/presence state, cached stats, local stats, PR state, run/setup state, and generation.
3. Background presence updates project stale state rather than emitting empty arrays.
4. All project outputs include project ID and generation; webview discards stale generations.
5. Session-created/deleted/updated events refresh the owning project store without active-project filtering.
6. Panel visibility and expansion uniformly control polling.
### Tests
- two expanded projects both receive stats/presence/PR updates;
- collapse stops only that project's pollers;
- stale late poll result is dropped;
- background session deletion updates only its project.
## Slice 4: one full sidebar body
### Required result
Single- and multi-project modes render the same full `ProjectSidebarBody` implementation.
### Required changes
1. Extract the current full `renderBody()` from `AgentManagerApp.tsx` intact.
2. Parameterize it with one project store and project-qualified actions.
3. Preserve:
- busy state;
- drag/drop and grouping;
- section actions;
- search and keyboard hints;
- run/setup state;
- New Worktree dialog;
- worktree menus and PR actions;
- complete managed/unassigned session behavior.
4. Delete the simplified duplicate body path.
5. Render project rows keyed by stable project ID and keep bodies mounted across active changes.
6. New Worktree receives explicit target project, defaulting to Settings/detail selection policy as specified.
### Tests
- single-project behavior unchanged with flag off;
- two expanded projects render two full bodies;
- active selection changes no body mount identity;
- drag/drop, sections, worktree actions, sessions, and keyboard navigation work across projects.
## Slice 5: cleanup and release validation
1. Remove unrelated manifest/lockfile drift.
2. Update changeset to final user-visible behavior.
3. Run all extension and affected CLI checks.
4. Build/package the extension.
5. Use the VS Code self-test fixture with two repositories and multiple worktrees/sessions.
6. Keep feature default off until every blocking test passes.
### Required extension checks
From `packages/kilo-vscode/`:
```sh
bun run format
bun run format:check
bun run typecheck
bun run lint
bun run test:unit
bun run knip
bun run check-kilocode-change
bun run compile
```
From repository root:
```sh
bun run script/check-opencode-annotations.ts
bun run script/check-opencode-promise-facades.ts
bun run script/check-md-table-padding.ts
```
### Manual scenarios
1. Flag off: current single-project Agent Manager is unchanged.
2. Enable flag in Experimental Settings; restart; value persists without CLI/project config changes.
3. Add/trust/expand two repositories; restart; pinned remains first.
4. Both projects show Local, worktrees, sessions, stats, PR state, and full actions.
5. Navigate previous/next across projects.
6. Create/delete/rename worktrees in both projects.
7. Send prompts and answer permission/question requests in both projects.
8. Share/unshare B while A is active; exact B directory is used.
9. Load Project Settings A, activate B, save A; B is unchanged.
10. Modify A config externally while draft is dirty; conflict preserves draft.
11. Indexing defaults off for a new project; enabling A does not enable B; repo config cannot enable either.
12. Disable flag while B is active; pinned Local becomes active and B remains registered/suspended.
13. Repeat key routing and identity checks in a remote VS Code window.
## Rules for the implementation model
- Work on one slice at a time.
- Read the referenced architecture section before editing.
- Add focused tests before or with behavior changes.
- Use `apply_patch` for manual edits.
- Do not hand-edit generated SDK files.
- Do not raise file-size caps.
- Do not add fallback compatibility code unless the plan explicitly requires the single-project adapter.
- Do not claim a slice complete until its stop gate passes.
- If a required API or ownership decision is missing, stop and update this plan instead of guessing.
@@ -1,210 +0,0 @@
# Agent Manager Multi-Project — Shipping Gaps
Status as of 2026-07-27. The multi-project sidebar renders and works end to end in the
self-test harness (both projects list worktrees/sessions/sections, section creation
persists, worktree delete works, project switching restores selection, live session
upsert verified). Full unit suite green (3444 pass / 0 fail), typecheck, lint, knip,
arch caps all pass. Everything is uncommitted in the worktree.
This document lists what is still missing, ranked by whether it should block shipping.
Two audiences matter for the blocking decision:
- **All users**: the branch changes shared surfaces (legacy sidebar refactor, config
write path, indexing consent, pollers). Regressions here ship to everyone, flag off
or not.
- **Experimental users**: multi-project mode is gated behind
`kilo-code.new.experimental.multiProject`, default off. Rough edges here are
acceptable if they cannot corrupt data.
---
## 1. Blocks shipping (all-user surfaces)
### 1.0 Empty-state skeleton regression (fixed, must stay fixed)
`initializeState` and `onRequestState` only called `refreshSessions()` when the
managed state contained at least one session. With zero managed sessions the
backend listing never ran, `sessionsLoaded` never reached the webview, and both
the WORKTREES and SESSIONS sections stayed on skeletons forever (the worktree
gate requires `worktreesLoaded() && sessionsLoaded()`). Any user whose state file
lost its sessions — exactly what the earlier session-persist bugs caused — hit a
permanently empty-looking sidebar. Reproduced in the harness on a worktree-root
workspace and fixed by making both refreshes unconditional. Root-caused via
stage-by-stage init logging on 2026-07-27; do not reintroduce a content-based
guard here.
### 1.1 Config write path can fail or wipe drafts for existing callers — P0-6 / P1-7
The config binding rework requires every `updateConfig` sender to carry a binding id,
and `configUpdateFailed` currently wipes the draft for the failed scope. Any existing
sender without a binding (permission dock, model picker, auto-approve, onboarding)
now fails or loses the user's unsaved edits.
- Backend half is done: `expected` is optional in the overlay schema, writer, and
handler, so a client without a binding writes unconditionally again instead of
getting a 400. The webview half (draft retention on `configUpdateFailed`, and the
audit of every `updateConfig` sender) is still open.
- Work: the code change is small; the real work is auditing every webview
`updateConfig` sender and classifying it.
- Also: this change is orthogonal to multi-project. Split it into its own commit
(`feat(vscode): config write revision bindings`) so it can be reverted alone.
### 1.2 Indexing status read silently revokes consent — P0-3 / P0-4
Partially addressed: untrusted projects are now filtered out of the consent list
(P0-4), and config scope switching plus project-scoped `indexing.enabled` writes
were restored after the rework had hardcoded the tab to global scope (the earlier
P1-8 gap). The remaining blocker is the read path below.
`fetchAndSendIndexingStatus` issues `PUT /indexing/consent` (a write) on a plain
status refresh, defaulting to `enabled: false` for any project not in local
`globalState`. On a fresh machine/profile, the first status read turns indexing off
for users who had it on. A refresh can also target the wrong project
(`requestIndexingStatus` resolves from the current session's directory). Separately,
the Indexing tab lists untrusted projects and would let a user enable indexing for a
repo the trust system has not approved.
- Fix: read status with a GET; PUT only from the explicit setter; seed consent from
the effective config on first read instead of defaulting false; require an explicit
project on refresh; filter the project list to trusted projects.
- Dependency: needs a read-only status endpoint. If none exists, this escapes into
`packages/opencode` (shared upstream code, needs `kilocode_change` markers) or the
cloud repo.
- Also orthogonal to multi-project; split into its own commit
(`feat(vscode): per-project indexing consent`).
### 1.3 Land the current work as separate, revertable commits
Everything currently sits uncommitted in one worktree. The review's recommendation
stands: `git reset --soft origin/main` and stage by path into three commits —
multi-project, config bindings, indexing consent. The multi-project commit message
must not promise per-project config/indexing behavior that lands in the other two.
---
## 2. Should fix soon after (experimental surface, data-integrity relevant)
### 2.1 Same repo can register twice — P0-5
`projectIdFor` hashes the canonical path verbatim while `samePath` folds case, so two
casings of one repo produce two project ids, two contexts, and two state managers
racing to write the same `.kilo/agent-manager.json` (last write wins, worktrees and
sessions vanish).
- Fix: fold case inside `projectIdFor` on darwin/win32; reject `addProject` when
`samePath` matches any registered root.
- Migration: existing registry keys were built from the old hash, so re-key them on
load or dual-lookup on read. Note `canonicalizePath` already realpaths existing
paths; the fallback branch must fold case too.
- Why not blocking: requires an unusual casing mismatch at registration time, and the
feature is flag-gated.
### 2.2 Route service is shared across panels but versioned per context — P0-7
Two VS Code windows each have their own `ProjectContexts` with independent generation
counters feeding one shared `ProjectRouteService`. Panel B registering a project at
generation 0 while panel A is at 2 unregisters panel A's routes; closing one panel
drops routes the other still needs.
- Fix: panel-qualified keys (`panelId + projectId + sessionId`), generations issued
by the service, ambiguity computed across all panels.
- Why not blocking: needs two windows running Agent Manager against the same repos.
The fallback already refuses ambiguous raw ids instead of resolving them wrong.
- Note: no two-panel test harness exists today; this fix should create one.
### 2.3 `gh pr` noise in repos without remotes — Bug 5
`gh pr view` fails every 15s per worktree forever, logs before the dedupe, and
`pollOnce` rejections are unhandled.
- Fix: log after the `lastHash` dedupe; `void this.pollOnce().catch(log)` in
`schedule`; per-root remote probe with a single error emission; skip PR pollers for
remote-less projects in `ProjectPollers.sync`.
---
## 3. UX papercuts in experimental mode (fix opportunistically)
- **Legacy tabs orphaned on upgrade** (P1-1): `createLocalTabs` migrates persisted
`localSessionIDs` into a `single` bucket that `tabKey()` never reads once the
catalog arrives. Migrate `single` to the pinned project id on first state apply.
- **`restoreProjectTarget` skips tab bookkeeping** (P1-3): a restored session has no
tab. Call `selectLocal()` first, add the tab, then select.
- **`ensurePendingTab` runs before restore** (P1-4): switch adds a "New Session"
draft that restore may contradict. Move it after restore.
- **Per-keystroke state saves**: `setActiveTarget` writes
`.kilo/agent-manager.json` on every selection change. Debounce or persist on
deactivation/dispose only.
- **Untranslated Indexing tab strings** (P1-9), **unused-ish composite id schemes**
(P1-12), **stats messages tagged at emit time** (P1-14), **no presence sync for
background projects** (P1-15), **registry read cache** (P1-16/17), **realpath
syscall churn** (P1-18), **`resolveProjectRoot` process spawning** (P1-19).
---
## 4. Structural debt (schedule, don't block)
### 4.1 Two sidebar implementations — Arch 1.1
Tracked as #12685 (section parity) and #12686 (sidebar drag-and-drop).
`AgentManagerApp` keeps the legacy `renderBody` (now `SidebarBody.tsx`) and
`ProjectSidebarBody.tsx` as a reduced reimplementation. The reimplementation already
caused one full outage of multi-project mode (missing `DragDropProvider` crashed the
webview render). Missing versus legacy: worktree ordering, drag-and-drop reorder and
move-to-section, grouping, busy/navHint/shortcut badges, section auto-rename, stats
skeletons.
- Direction: single-project becomes the degenerate case of multi-project (one
implicit pinned project, header row hidden), `SidebarBody.tsx` is deleted.
- Do this last: it churns the same message-stamping code as 2.2, needs per-project
DnD state, and must keep legacy pixel-identical for the default-off population.
Use the visual-regression skill to cover both modes before merging it.
### 4.2 Ambient project scope via AsyncLocalStorage — Arch 1.3
`ProjectScope` plus the `this.state`/`this.context` getters make the target project
invisible at call sites; any continuation escaping ALS silently falls back to the
active project. `provider-lifecycle.ts` shows the intended end state (explicit deps).
Keep threading `ctx` explicitly into the remaining handler groups; add a dev-mode log
in the `context` getter when a project-stamped message resolves without a scope.
### 4.3 Per-project webview store — Arch 1.2
`worktrees()`, `managedSessions()`, `selection()` etc. are single-valued with
`memKey()`/`tabKey()` and two "current project" accessors that disagree during the
switch window. Long-term: one `createProjectStore(projectId)` per project. For now
the gating added to the remember effect contains the known race.
---
## 5. Verification gaps to close before the PR
- **SSE session upsert** is unit-tested (`upsertSession`, `byDirectory`, fresh-skip
re-post) but not E2E-verified: the harness backend's basic-auth credentials could
not be re-extracted after a window reload, so external session creation was not
exercised live. Verify manually: `kilo` a new session in a registered repo from a
terminal and watch it appear in that project's sidebar.
- **Tab isolation across projects** (per-project buckets) is unit-tested but not
E2E-verified with real sessions open in two projects.
- **Legacy mode parity**: legacy sidebar and tab bar were smoke-tested (render,
select, section create + inline rename), but not the full matrix (delete, DnD
reorder, move-to-section, review tab, terminals). The extraction moved ~700 lines
of JSX; a visual-regression pass over `SidebarBody`/`TabBar` stories is the
cheapest safety net.
- **Harness instability**: the isolated VS Code window crashed repeatedly during this
work. If it keeps failing on the next pass, say so in the PR rather than claiming
coverage that does not exist.
---
## Proposed landing sequence
1. Quick independent fixes: 2.3 (gh noise), P0-4 (trust filter), 1.1's audit + 1.2's
fallback (config), 1.2's indexing read/write split if the GET endpoint exists.
2. 2.1 (case-fold + registry migration).
3. 2.2 (route service, with a two-panel test harness).
4. 1.1's config/indexing commits split out and merged separately.
5. Arch 1.1 (sidebar collapse) with visual-regression coverage; then the P1 batch.
@@ -0,0 +1,834 @@
# Agent Manager — New Worktree Project Selector
Status: implemented 2026-08-06. The implementation is uncommitted.
This is Slice 4 item 6 ("Add project-aware New Worktree targeting") from
`agent-manager-multi-project-uniform-ui.md`, the last unimplemented item of that slice.
`agent-manager-multi-project-runtime.md:29` deferred it out of the backend-first scope.
Everything the extension side needs already exists; this is almost entirely a webview
change.
Implementation notes:
- The project catalog is passed into the dialog as an accessor so the picker reflects
registry changes while it remains open.
- The per-project default-base resolver returns `undefined` when the project has no
configured/local branch, allowing the backend-detected branch response to remain the
fallback instead of being replaced by a hardcoded `main`.
- Branch, import-result, and worktree-ready messages carry `projectId` in multi-project
mode, which makes fast project changes and cross-project creation activation safe.
- The slash-command hook accepts optional caller-owned commands; `/project` is scoped to
this dialog and is hidden when multi-project mode is unavailable.
---
## Problem
With `kilo-code.new.experimental.multiProject` enabled, the New Worktree dialog has no
notion of which repository it targets, and the user cannot see or change it.
`Cmd+N` opens the dialog with no project at all:
```tsx
// AgentManagerApp.tsx:1870-1876
const showNewWorktreeDialog = () => {
if (!loaded()) return
expandSidebar()
dialog.show(() => (
<NewWorktreeDialog mode={mode} onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />
))
}
```
`projectId` is `undefined`, so every message the dialog sends omits it
(`agentManager.requestBranches` at `NewWorktreeDialog.tsx:321`,
`agentManager.createMultiVersion` at `:373`, `agentManager.importFromPR` at `:566`,
`agentManager.importFromBranch` at `:575`). The extension then silently falls back to the
active project in `messageProject()` (`AgentManagerProvider.ts:474`) before running the
message inside `ProjectScope`.
The result is correct but opaque:
- The dialog never shows which repository the worktree lands in.
- The only way to target a specific project is the per-project `+` button
(`ProjectList.tsx:136-146`), which does pass `projectId` explicitly.
- `defaultBaseBranch` is resolved from the *active* project
(`AgentManagerApp.tsx:261,272`), so even Advanced options' base-branch list and default
badge are implicitly single-project.
The desired behavior, per the original request: the dialog should show the assigned
project and let the user change it. Defaulting to the last selected project is fine as a
default; it just must be visible and overridable.
---
## Design decisions
### Placement: inline with the tab switcher
The selector renders inside the New/Import pill row (`NewWorktreeDialog.tsx:620-699`),
right-aligned with a constrained width, so it is shared by both tabs without adding a
full-width form row.
```
┌─ New Worktree ──────────────────────────────┐
│ [ New ] [ Import ] [ folder kilocode ▾ ] │ ← inline, multiProject only
│ [ Worktree name (optional) ] │
│ [ prompt … ] Code ▾ GPT-5.6 ▾ None ▾ │
Advanced options │
│ VERSIONS 1 2 3 4 ⧉ Compare Models │
│ [ Create Worktree ] │
└──────────────────────────────────────────────┘
```
Rejected alternatives and why:
- **Inside Advanced options.** Wrong category. Advanced options holds refinements of a
known target (branch name, base branch). The project *is* the target: changing it
invalidates the branch list, base branch, default-branch badge, and setup scripts.
Hiding it also fails the stated requirement of seeing which project is assigned.
- **A full-width row directly beneath the tabs.** It covered both tabs, but consumed
unnecessary vertical space and made the project control look like a primary form field.
- **In the dialog title** (`New Worktree in [kilocode ▾]`). Reads nicely but requires
widening `Dialog`'s `title` prop to accept JSX, which touches kilo-ui for one caller.
### Visibility
Render the row only when `multiProject` is true. With the flag off (the default) the
dialog stays byte-identical to today, so there is no regression surface for the
all-user path. When the flag is on but only the pinned project exists, still render it:
showing the target is informative and the requirement is explicitly about seeing the
assignment.
### Default value
`props.projectId ?? activeProjectId()`. `activeProjectId()` already exists at
`AgentManagerApp.tsx:268` (`projectList().find((p) => p.active)?.id ?? currentProjectId()`).
No new persistence. The active project is already the durable "last selected" state
(persisted per project as `activeTarget` in each repo's `.kilo/agent-manager.json`, plus
the registry's ordering). Adding a separate "last dialog project" key would create a
second source of truth that can disagree with the sidebar.
### Reuse, no new CSS
The row uses the existing `am-advanced-field` + `am-nv-config-label` +
`am-selector-wrapper` + `am-selector-trigger` markup, i.e. exactly the structure the base
branch selector already uses at `NewWorktreeDialog.tsx:813-905`, with `DeferredPopover`
(already imported) instead of `BranchSelectPopover`.
### Component extraction
`NewWorktreeDialog.tsx` is already 1136 lines. The selector and its popover list go into a
new `webview-ui/agent-manager/ProjectSelect.tsx` (roughly `BranchSelect.tsx`'s role):
presentational, takes `projects`, `value`, `onSelect`, and labels, and owns nothing but
its own list rendering. The dialog keeps only the signal, the popover trigger, and the
effects.
Note: `webview-ui/agent-manager/NewWorktreeDialog.tsx` is not under a `maxLines` cap
(`tests/unit/agent-manager-arch.test.ts` caps `src/agent-manager/*.ts` only), but the file
is on the arch test's watched list and the caps exist to discourage exactly this kind of
growth.
---
## Changes
### 1. `webview-ui/agent-manager/ProjectSelect.tsx` (new)
Presentational popover body listing projects:
- Row = project label + dimmed root path (tooltip on the full root, matching
`ProjectsSection.tsx:60`).
- Check mark on the selected project.
- Untrusted and missing projects are disabled and carry the same affordances the accordion
uses: `lock` icon + trust hint, `warning` icon + missing hint
(`ProjectsSection.tsx:67-75`). Selecting them is not possible; trust happens in the
sidebar, not in this dialog. Keeps the dialog free of trust-flow branching.
- There is intentionally no Add project action in this picker. Project registration and
trust management stay in the Agent Manager Projects toolbar.
### 2. `webview-ui/agent-manager/NewWorktreeDialog.tsx`
Props change:
```ts
export const NewWorktreeDialog: Component<{
onClose: () => void
projectId?: string // now: initial value, not fixed target
projects?: AgentProjectSnapshot[] // omitted / empty => single-project, row hidden
activeProjectId?: string
defaultBase?: (projectId: string) => string // replaces defaultBaseBranch?: string
mode: ModeRouter
}>
```
`defaultBaseBranch?: string` must become a per-project lookup because each project has its
own configured default and its own local branch. `ProjectList.tsx:142` already computes
that expression (`state?.defaultBaseBranch ?? props.local[projectId]?.branch`); hoist it
into the callback so both call sites share it.
New state and derived values:
```ts
const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId)
const [projectOpen, setProjectOpen] = createSignal(false)
const selectable = () => (props.projects ?? []).filter((p) => p.trusted && !p.missing)
const showProject = () => (props.projects?.length ?? 0) > 0
```
Every outbound message switches from `props.projectId` to `project()`:
`:321` `requestBranches`, `:373` `createMultiVersion`, `:566` `importFromPR`,
`:575` `importFromBranch`.
Reload branch data on project change, replacing the one-shot `onMount` request at `:319-321`:
```ts
createEffect(
on(project, (id) => {
setBranches([])
setBranchSearch("")
setHighlightedIndex(0)
setBaseBranch(null) // custom base is project-specific
setDefaultBranch(props.defaultBase?.(id) ?? "main")
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches", projectId: id })
}),
)
```
Drop stale branch replies in the `agentManager.branches` handler (`:520-525`).
`AgentManagerBranchesMessage` **already declares an optional `projectId`**
(`extension-messages.ts:939-945`, `src/agent-manager/types.ts:293-298`); the field is
simply never populated or read today. Without this guard, switching projects twice quickly
can race a wrong branch list into the base-branch popover:
```ts
if (ev.projectId && ev.projectId !== project()) return
```
Also replace the `if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)` guard
at `:523` — with a per-project lookup, the guard must consult
`props.defaultBase?.(project())` instead of a fixed prop.
Preserved across a project change (all project-agnostic): prompt text and its
`advancedDialogPrompt` persistence, images, name, agent, model, variant, versions, compare
allocations, sandbox override.
Keyboard: add `project` to `WORKTREE_PROMPT_COMMANDS` so `/project` opens the popover,
consistent with mode/model/variant/sandbox already being reachable from the dialog's slash
menu (`:302-314`). Hide it from the list when `showProject()` is false, using the same
`hidden` set mechanism already used for `agents` / `variant` / `sandbox`.
### 3. `webview-ui/agent-manager/AgentManagerApp.tsx`
`showNewWorktreeDialog` (`:1870-1876`) passes the catalog and a per-project default
resolver instead of a single branch string:
```tsx
<NewWorktreeDialog
mode={mode}
onClose={() => dialog.close()}
projects={multiProject() ? projectList() : undefined}
activeProjectId={activeProjectId()}
defaultBase={defaultBase}
/>
```
where `defaultBase(id)` reads `registry.ensure(id).defaultBaseBranch() ?? registry.ensure(id).localStats()?.branch ?? repoDetectedBranch() ?? "main"`.
`registry.ensure` and both store fields already exist
(`project/registry.ts:39`, `project/store.ts:62,67,113,123`).
### 4. `webview-ui/agent-manager/ProjectList.tsx`
`newWorktree(projectId)` (`:136-146`) passes the same `projects` / `activeProjectId` /
`defaultBase` props with `projectId` as the initial value, so the per-project `+` button
opens the dialog pre-scoped but still switchable. Its current inline
`state?.defaultBaseBranch ?? props.local[projectId]?.branch` expression is replaced by the
shared resolver passed down from `AgentManagerApp`.
### 5. `src/agent-manager/worktree-importer.ts`
Stamp `projectId` on all three `agentManager.branches` posts (`:27`, `:48`, `:54`). The
field is already in the type; the value is available from the ambient `ProjectScope`
context the message runs in (`AgentManagerProvider.ts:479`). Without this the stale-reply
guard in the webview is inert.
### 6. Activate the created worktree when the project differs
Creating in a non-active project currently leaves the sidebar where it is:
`createMultiVersion` never activates (no activation call in `provider-multi-version.ts`),
and the new worktree just appears in that project's accordion. That is right for the
per-project `+` button, but for `Cmd+N` where the user deliberately switched projects,
landing in the new worktree is what the flow implies.
Post an `agentManager.activateSelection` for the first created worktree when the chosen
project differs from the active one. `activateSelection` already handles readiness, trust,
and stale-target fallback (`project/messages.ts:76-99`), so this is one message, not new
machinery. Hook it to the existing `agentManager.worktreeSetup` / `multiVersionProgress`
handling in `AgentManagerApp.tsx:1453-1471`, which already carries `projectId`.
### 7. i18n
New keys in `webview-ui/agent-manager/i18n/en.ts` (near the existing
`agentManager.dialog.*` block at `:130`):
- `agentManager.dialog.project.select` — "Select project"
- `agentManager.dialog.project.untrusted` — "Trust this project in the sidebar first"
- `agentManager.dialog.project.missing` — "Repository not found"
Then translate the four keys into the other 20 locale files in that directory via the
`translator` subagent.
---
## Implementation order
1. `ProjectSelect.tsx` with the presentational list, plus i18n keys in `en.ts`.
2. Dialog: `project` signal, prop rename to `defaultBase`, route all four outbound
messages through `project()`, render the row behind `showProject()`.
3. Dialog: `createEffect(on(project, …))` branch reload, base-branch reset, stale-reply
guard.
4. Call-site updates in `AgentManagerApp.tsx` and `ProjectList.tsx`, shared `defaultBase`
resolver.
5. `projectId` stamp in `worktree-importer.ts`.
6. `/project` slash command.
7. Post-create activation when the target project differs.
8. Locale fan-out.
9. Changeset (`minor`, user-facing): worktree creation targets an explicit project.
---
## Tests
Existing source-text unit tests already assert against this dialog and will need to stay
green: `tests/unit/new-worktree-dialog-sandbox.test.ts`,
`tests/unit/prompt-input-bidirectional.test.ts`, and the dialog entry in
`tests/unit/agent-manager-arch.test.ts`.
New coverage:
- The dialog posts `createMultiVersion` / `requestBranches` / `importFromBranch` /
`importFromPR` with the *selected* project id, not the prop, after a project change.
- A `agentManager.branches` reply carrying a non-current `projectId` does not mutate the
branch list (the race guard).
- Changing project clears `baseBranch` and re-derives `defaultBranch` from `defaultBase`.
- Prompt text survives a project change (no accidental reset through the shared
`advancedDialogPrompt` cache).
- The row does not render when `projects` is empty, so the single-project dialog is
unchanged.
- Untrusted and missing projects are not selectable.
Checks to run before declaring done, from `packages/kilo-vscode/`:
`bun run typecheck`, `bun run lint`, `bun run test:unit`, `bun run knip`.
---
## Manual verification
In the isolated harness (`bun run extension:isolated`) with
`kilo-code.new.experimental.multiProject` enabled and two repositories registered:
1. `Cmd+N` from project A shows "Project: A". Switch to B, create, and confirm the
worktree lands in B's accordion and the sidebar activates it.
2. Switch project with Advanced options open and confirm the base-branch list and default
badge follow the new project rather than showing A's branches.
3. Switch project rapidly back and forth and confirm the branch list matches the selected
project (the race guard).
4. Type a prompt, switch project, confirm the prompt is retained.
5. Use the Import tab after switching project and confirm branches and PR import target
the selected repository.
6. Turn the flag off and confirm the dialog is visually identical to today.
---
## Out of scope
- Trusting or removing a project from inside the dialog. Trust stays in the sidebar; the
dialog only disables untrusted entries.
- Any change to how the active project is persisted.
- The quick-create path (`Cmd+Shift+N``agentManager.createWorktree`,
`AgentManagerApp.tsx:1863-1867`). It has no dialog, so it keeps targeting the active
project. Worth revisiting only if the explicit-target rule should apply there too.
- Per-project setup-script or agent selection in the dialog.
## Risks
- **Stale branch data** is the only real correctness risk, and it is why the `projectId`
stamp plus the reply guard are mandatory rather than optional polish.
- **Prop signature change** (`defaultBaseBranch: string``defaultBase: (id) => string`)
touches both call sites; a partial migration would silently show one project's default
branch while creating in another.
- **Dialog file growth**; mitigated by extracting `ProjectSelect.tsx`.
---
# Appendix: exact UI and styling specification
Everything below is copy-paste ready. Class names, tokens, and icon names are all verified
against the current tree. Do not invent new tokens or new class names beyond the ones
listed here.
## A. Visual layout
```
┌─ New Worktree ─────────────────────────────────────────── X ─┐
│ │
│ [ New ][ Import ] [ 📁 kilocode ⌃⌄ ] │
│ └────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Worktree name (optional) │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ prompt … │ │
│ │ Code ▾ OpenAI / GPT-5.6 ▾ None ▾ ✨ 🔒 🎤 │ │
│ └──────────────────────────────────────────────────────┘ │
Advanced options │
│ VERSIONS [1][2][3][4] [⧉ Compare Models] │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Create Worktree │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
Open dropdown (anchored under the trigger, same width as the trigger):
```
┌────────────────────────────────────────────┐
│ 📁 kilocode ~/Documents/git/kilocode ✓│ ← .am-project-option-active
│ 📁 cloud ~/Documents/git/cloud │
│ 🔒 sample-app ~/dev/sample-app │ ← disabled, 50% opacity
│ ⚠ old-repo ~/dev/old-repo │ ← disabled, 50% opacity
└────────────────────────────────────────────┘
```
Rules:
- The selector is inline with the New/Import buttons inside the tab-switcher flex row, so
it applies to New and Import alike.
- The project name and folder icon identify the scope without a separate visible label.
- The trigger is the same control as the Advanced options base-branch trigger
(`.am-selector-trigger`), so the dialog has one visual language for "pick a thing".
- The row is **not rendered at all** when `props.projects` is empty or undefined. That is
the single-project / flag-off case, which must stay pixel-identical to today.
## B. Exact icon names
Only these, from `packages/ui/src/components/icon.tsx`:
| Where | `Icon name` | Notes |
|---|---|---|
| Trigger left | `folder` | Always, regardless of project state. |
| Trigger right | `selector` | Same as every other `.am-selector-trigger`. |
| Option row, normal | `folder` | |
| Option row, untrusted | `lock` | Matches the sidebar accordion affordance. |
| Option row, missing | `warning` | Matches the sidebar accordion affordance. |
| Option row, selected | `check-small` | Right-aligned. |
All at `size="small"`. Do not use `folder-add-left`, `check`, or `plus`.
## C. New file: `webview-ui/agent-manager/ProjectSelect.tsx`
```tsx
// Project picker list for the New Worktree dialog
/** @jsxImportSource solid-js */
import { For, Show, type Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { AgentProjectSnapshot } from "../src/types/messages"
interface ProjectSelectProps {
projects: AgentProjectSnapshot[]
selected?: string
onSelect: (id: string) => void
labels: { untrusted: string; missing: string }
}
export const ProjectSelect: Component<ProjectSelectProps> = (props) => (
<div class="am-dropdown-list">
<For each={props.projects}>
{(project) => {
const blocked = () => !project.trusted || project.missing
const hint = () => {
if (project.missing) return props.labels.missing
if (!project.trusted) return props.labels.untrusted
return project.root
}
const icon = () => {
if (project.missing) return "warning" as const
if (!project.trusted) return "lock" as const
return "folder" as const
}
return (
<button
class="am-project-option"
classList={{ "am-project-option-active": props.selected === project.id }}
disabled={blocked()}
title={hint()}
onClick={() => props.onSelect(project.id)}
type="button"
>
<span class="am-project-option-left">
<Icon name={icon()} size="small" />
<span class="am-project-option-name">{project.label}</span>
<span class="am-project-option-root">{project.root}</span>
</span>
<Show when={props.selected === project.id}>
<Icon name="check-small" size="small" />
</Show>
</button>
)
}}
</For>
</div>
)
```
Notes for the implementer:
- Wrap in `.am-dropdown-list`, not a bare fragment: that class supplies the scroll cap and
4px padding, and `.am-dropdown [data-slot="popover-body"]` zeroes the popover padding.
- No search input. A project list is short; adding one would need keyboard nav plumbing for
no benefit.
- `props.labels` is passed in rather than calling `useLanguage()` here, matching how
`BranchSelect` and `SidebarSearchMenu` take label props.
## D. Exact JSX inserted into `NewWorktreeDialog.tsx`
### D.1 Imports
Add to the existing type import block at lines 6-12:
```ts
AgentProjectSnapshot,
```
Add after line 48 (`import { BranchSelect, BranchSelectPopover } …`):
```ts
import { ProjectSelect } from "./ProjectSelect"
```
`Icon`, `Show`, `DeferredPopover`, `createSignal`, `createEffect` are already imported.
`on` from `solid-js` must be added to the line 5 import list.
### D.2 Props
Replace the component signature at lines 84-89 with:
```tsx
export const NewWorktreeDialog: Component<{
onClose: () => void
/** Resolves the default base branch for one project. */
defaultBase?: (projectId: string) => string | undefined
/** Initial target project. The user can change it while the dialog is open. */
projectId?: string
/** Full project catalog. Empty or undefined hides the project row entirely. */
projects?: () => AgentProjectSnapshot[]
/** Project the sidebar currently has active; used as the default target. */
activeProjectId?: string
mode: ModeRouter
}> = (props) => {
```
### D.3 State
Immediately after line 101 (`const [tab, setTab] = createSignal<DialogTab>("new")`):
```tsx
const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId)
const [projectOpen, setProjectOpen] = createSignal(false)
const projects = () => props.projects?.() ?? []
const showProject = () => projects().length > 0
const projectLabel = () => projects().find((p) => p.id === project())?.label ?? ""
```
`defaultBranch` (line 106) changes from `props.defaultBaseBranch ?? "main"` to:
```tsx
const [defaultBranch, setDefaultBranch] = createSignal(
(project() && props.defaultBase?.(project()!)) || "main",
)
```
### D.4 The inline selector
Insert inside the tab switcher after the Import button:
```tsx
{/* Project scope — applies to both tabs. Hidden unless multi-project is on. */}
<Show when={showProject()}>
<div class="am-nv-project-inline">
<div class="am-selector-wrapper">
<DeferredPopover
open={projectOpen()}
onOpenChange={setProjectOpen}
placement="bottom-start"
flip={false}
sameWidth
portal={false}
deferDismiss
class="am-dropdown"
trigger={
<button class="am-selector-trigger" type="button" disabled={starting() || isPending()}>
<span class="am-selector-left">
<Icon name="folder" size="small" />
<Show
when={projectLabel()}
fallback={
<span class="am-selector-value am-selector-placeholder">
{t("agentManager.dialog.project.select")}
</span>
}
>
<span class="am-selector-value">{projectLabel()}</span>
</Show>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<ProjectSelect
projects={projects()}
selected={project()}
onSelect={(id) => {
track("project_select", { changed: id !== props.activeProjectId })
setProject(id)
setProjectOpen(false)
}}
labels={{
untrusted: t("agentManager.dialog.project.untrusted"),
missing: t("agentManager.dialog.project.missing"),
}}
/>
</DeferredPopover>
</div>
</div>
</Show>
```
Critical details, in order of how easily they get wrong:
1. `placement="bottom-start"`, **not** `top-start`. The rest of this dialog uses
`top-start` because those triggers sit near the bottom of the panel. This one sits at
the top, so it must open downward.
2. `portal={false}` plus the escape CSS in section E.3. Do not switch to a portal unless
the clipping fallback in E.3 is needed.
3. `sameWidth` so the dropdown matches the trigger width, consistent with the base-branch
and compare-models popovers.
4. Never send the project label, root, or id as a telemetry property. `track` takes only
the boolean shown above.
### D.5 Reactive reload on project change
Delete the one-shot request at lines 319-321 inside `onMount` and replace it with an effect
placed next to the other `createEffect` calls:
```tsx
// Project scope owns the branch data, the base branch, and the default badge.
// Prompt, name, model, agent, versions and attachments are project-agnostic and survive.
createEffect(
on(project, (id) => {
if (!id) return
setBranches([])
setBranchSearch("")
setHighlightedIndex(0)
setBaseBranch(null)
setDefaultBranch(props.defaultBase?.(id) ?? "main")
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches", projectId: id })
}),
)
```
`on(project, …)` without `{ defer: true }` runs immediately, which replaces the removed
`onMount` request. Keep the textarea focus logic in `onMount` untouched.
In the `agentManager.branches` handler (lines 520-525), replace the body with:
```tsx
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
if (ev.projectId && ev.projectId !== project()) return
setBranches(ev.branches)
const id = project()
if (!id || !props.defaultBase?.(id)) setDefaultBranch(ev.defaultBranch)
setBranchesLoading(false)
}
```
### D.6 Outbound project id
Four call sites change from `props.projectId` to `project()`:
| Line | Message |
|---|---|
| 321 (now inside the effect) | `agentManager.requestBranches` |
| 373 | `agentManager.createMultiVersion` |
| 566 | `agentManager.importFromPR` |
| 575 | `agentManager.importFromBranch` |
Grep afterwards: `props.projectId` must appear exactly once in the file, in the `project`
signal initializer.
## E. Exact CSS
All of it goes into `webview-ui/agent-manager/agent-manager.css`. No changes to kilo-ui.
### E.1 The inline selector
Insert directly after the `.am-tab-switcher-pill-active` rule (agent-manager.css:3614-3617),
before the `/* Import tab layout */` comment at line 3619:
```css
/* Project scope selector — inline with the New/Import tabs */
.am-nv-project-inline {
display: flex;
align-items: center;
flex-shrink: 0;
flex: 0 1 260px;
min-width: 0;
margin-left: auto;
}
.am-nv-project-inline .am-selector-wrapper {
width: 100%;
min-width: 0;
}
```
The `flex: 0 1 260px` cap keeps the project control compact while allowing long project
names to truncate. `margin-left: auto` keeps it aligned to the right of the New/Import
buttons.
### E.2 The dropdown rows
Insert after the `.am-dropdown-empty` rule (agent-manager.css:3863-3868), before the
`/* Import empty state */` comment at line 3870:
```css
/* Project option rows in the New Worktree project dropdown.
Deliberately distinct from .am-project-item, which styles the sidebar accordion. */
.am-project-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-base);
font-size: var(--font-size-base);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.am-project-option:hover:not(:disabled) {
background: var(--surface-inset-base-hover);
}
.am-project-option-active {
background: var(--surface-inset-base);
}
.am-project-option:disabled {
opacity: 0.5;
cursor: default;
}
.am-project-option-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.am-project-option-left [data-component="icon"] {
color: var(--text-weaker);
flex-shrink: 0;
}
.am-project-option-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 45%;
}
.am-project-option-root {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: var(--kilo-font-size-11);
color: var(--text-weaker);
}
```
Why not reuse `.am-branch-item`: it is defined twice (lines 3211 and 3810) and the earlier
definition sets `font-family: var(--font-mono, monospace)` on `.am-branch-item-name`, which
would render project labels in monospace. Reusing it also couples project rows to future
branch-row changes. `.am-project-item` is likewise off limits: it already styles the
sidebar project accordion header (line 325).
### E.3 Popover clipping escape
`[data-slot="dialog-body"]` is `overflow: hidden` in `packages/ui/src/components/dialog.css:99-105`,
and `[data-slot="dialog-content"]` is `overflow: auto` (line 38). The existing escape rules
at agent-manager.css:2822-2828 only match popovers **inside** `.am-nv-dialog`, and this row
is deliberately outside it. Without the following, the dropdown is clipped by the dialog.
Add to that same rule group (extend the existing selector list rather than duplicating the
declaration):
```css
[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"],
[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] {
overflow: visible;
}
```
Verification step, not optional: open the dropdown with four or more projects registered
and confirm no row is cut off and no inner scrollbar appears on the dialog. If it still
clips, the documented fallback is to drop `portal={false}` from the `DeferredPopover` in
D.4 and delete this rule; the dialog already sets `overflow: visible` on
`[data-slot="dialog-content"]` for portal-based dropdowns (agent-manager.css:2755-2760).
## F. i18n
Add to `webview-ui/agent-manager/i18n/en.ts`, immediately after
`"agentManager.dialog.namePlaceholder"`:
```ts
"agentManager.dialog.project.select": "Select project",
"agentManager.dialog.project.untrusted": "Trust this project in the sidebar first",
"agentManager.dialog.project.missing": "Repository not found",
```
Then add the same three keys to all 20 sibling locale files in that directory (`ar bs br da
de es fa fr it ja ko nl no pl ru th tr uk zh zht`) via the `translator` subagent.
## G. What must not change
- No new CSS variables or tokens. Only the ones listed above, all already in use in this
file.
- No edits to `packages/kilo-ui/` or `packages/ui/`.
- No change to `.am-project-item`, `.am-branch-item`, `.am-selector-trigger`,
`.am-nv-config-label`, or any other existing rule. The only existing rule touched is the
`overflow: visible` selector group in E.3, and only by adding selectors to it.
- No new message types. `agentManager.requestBranches`, `agentManager.createMultiVersion`,
`agentManager.importFromBranch`, and `agentManager.importFromPR` all already exist and
already accept what is needed.
- With `props.projects` empty, the rendered dialog markup must be identical to before the
change. Verify by toggling `kilo-code.new.experimental.multiProject` off.
+1
View File
@@ -444,6 +444,7 @@
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"@vscode/codicons": "^0.0.44",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode-graphemes": "0.4.0",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-XCtBeP2R+Tx2S4LEXn1RRZWoquOUdIFB+8O58n2krHI=",
"aarch64-linux": "sha256-gnf+k+mI7JaqoCrVzFeEVBmKELUfZ9D6XpqzlBXKYzI=",
"aarch64-darwin": "sha256-j9nQgeurmHs00dGOIuxGoV1W/tQ/DJnWrfllr66y9yg=",
"x86_64-darwin": "sha256-+arUGf3HZvSRumkfX082dM0wErzzDexOz3zZcVDMlSI="
"x86_64-linux": "sha256-epitmtKUd9fAucKdH/sDdqv5WmwfpiPH+h/PNt55gd4=",
"aarch64-linux": "sha256-FHVsi2iho+U5aj6Z9lg2GTIu1ViUuqI2c/nZuwjmPH8=",
"aarch64-darwin": "sha256-QDPVLcbTyaZRZvSnqvWNR/mIkS1TrZLX0HjUlg6xwwI=",
"x86_64-darwin": "sha256-SSPc9b3WwcYCnuywcyMRJjVlugoXuo0UoPJ+qzPGajk="
}
}
+1
View File
@@ -10,6 +10,7 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"check:architecture": "bun run script/check-architecture.ts",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty && bun run script/setup-git.ts",
+27 -2
View File
@@ -15,6 +15,7 @@ import { Global } from "./global"
import { DataMigrationTable } from "./data-migration.sql"
import path from "path"
import { parse as parseKiloAccounts } from "./kilocode/credential-migration"
import { isBusy } from "./kilocode/sqlite-error"
import { NonNegativeInt } from "./schema"
// kilocode_change end
@@ -170,6 +171,17 @@ export const legacyImportLayer = Layer.effectDiscard(
const integration = Integration.ID.make(integrationID.replace(/\/+$/, ""))
return [{ integration, value: legacyValue(integration, decoded.value) }]
})
const migrated = yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get()
const existing = yield* db.select().from(CredentialTable).orderBy(desc(CredentialTable.time_created)).all()
const same = (left: Value, right: Value) => JSON.stringify(left) === JSON.stringify(right)
if (
migrated &&
values.every((item) => {
const current = existing.find((row) => row.integration_id === item.integration)
return current !== undefined && same(current.value, item.value)
})
)
return
yield* db.transaction((tx) =>
Effect.gen(function* () {
for (const item of values) {
@@ -181,7 +193,12 @@ export const legacyImportLayer = Layer.effectDiscard(
.orderBy(desc(CredentialTable.time_created)) // kilocode_change - reconcile the active imported account
.get()
if (current) {
yield* tx.update(CredentialTable).set({ value: item.value }).where(eq(CredentialTable.id, current.id)).run()
if (!same(current.value, item.value))
yield* tx
.update(CredentialTable)
.set({ value: item.value })
.where(eq(CredentialTable.id, current.id))
.run()
continue
}
yield* tx.insert(CredentialTable).values({
@@ -194,7 +211,15 @@ export const legacyImportLayer = Layer.effectDiscard(
yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run()
}),
)
}).pipe(Effect.orDie),
}).pipe(
Effect.retry({ while: isBusy, times: 2 }),
Effect.catch((error) =>
isBusy(error)
? Effect.logWarning("legacy credential reconciliation deferred because the database is busy")
: Effect.fail(error),
),
Effect.orDie,
),
)
// kilocode_change end
+23 -13
View File
@@ -4,19 +4,29 @@ import type { Database } from "../database/database"
type Db = Database.Interface["db"]
export function ensure(db: Db) {
return db.transaction(
(tx) =>
Effect.gen(function* () {
const rows = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')")
const names = new Set(rows.map((row) => row.name))
const load = db.all<{ name: string }>("PRAGMA table_info('session_context_epoch')")
const ready = (rows: { name: string }[]) => {
const names = new Set(rows.map((row) => row.name))
return ["agent", "replacement_seq", "revision"].every((name) => names.has(name))
}
return load.pipe(
Effect.flatMap((rows) => {
if (ready(rows)) return Effect.void
return db.transaction(
(tx) =>
Effect.gen(function* () {
const current = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')")
const names = new Set(current.map((row) => row.name))
if (!names.has("agent"))
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL")
if (!names.has("replacement_seq"))
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer")
if (!names.has("revision"))
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL")
}),
{ behavior: "immediate" },
if (!names.has("agent"))
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL")
if (!names.has("replacement_seq"))
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer")
if (!names.has("revision"))
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL")
}),
{ behavior: "immediate" },
)
}),
)
}
@@ -0,0 +1,10 @@
import { Cause, Option } from "effect"
import { isSqlError } from "effect/unstable/sql/SqlError"
export function isBusy(error: unknown): boolean {
if (isSqlError(error)) return error.reason._tag === "LockTimeoutError"
if (typeof error !== "object" || error === null || !("cause" in error) || error.cause === error) return false
if (!Cause.isCause(error.cause)) return isBusy(error.cause)
const failure = Cause.findErrorOption(error.cause)
return Option.isSome(failure) && isBusy(failure.value)
}
+78
View File
@@ -1,11 +1,15 @@
import path from "path"
import { Database as SQLite } from "bun:sqlite" // kilocode_change
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm" // kilocode_change
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { CredentialTable } from "@opencode-ai/core/credential/sql" // kilocode_change
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
// kilocode_change start
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
// kilocode_change end
import { tmpdir } from "./fixture/tmpdir"
@@ -20,6 +24,16 @@ function localLayer(directory: string) {
)
}
// kilocode_change start
function importer(dir: string, store: Database.Interface) {
return Credential.legacyImportLayer.pipe(
Layer.provide(Layer.succeed(Database.Service, store)),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layerWith({ data: dir })),
)
}
// kilocode_change end
describe("Credential", () => {
it.live("stores, updates, lists, and removes credentials", () =>
Effect.acquireUseRelease(
@@ -196,6 +210,70 @@ describe("Credential", () => {
),
)
it.live("skips unchanged legacy writes and defers locked reconciliation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const file = path.join(tmp.path, "credential.db")
const auth = path.join(tmp.path, "auth.json")
const write = (key: string) =>
Effect.promise(() => Bun.write(auth, JSON.stringify({ kilo: { type: "api", key } })))
return Effect.gen(function* () {
yield* write("first")
const store = yield* Database.Service
const layer = importer(tmp.path, store)
yield* Layer.build(Layer.fresh(layer))
const before = yield* store.db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
.get()
yield* Layer.build(Layer.fresh(layer))
const unchanged = yield* store.db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
.get()
expect(unchanged?.time_updated).toBe(before?.time_updated)
yield* write("second")
yield* store.db.run("PRAGMA busy_timeout = 0")
yield* Effect.acquireUseRelease(
Effect.sync(() => {
const holder = new SQLite(file)
holder.run("PRAGMA busy_timeout = 0")
holder.run("BEGIN IMMEDIATE")
return holder
}),
() => Layer.build(Layer.fresh(layer)),
(holder) =>
Effect.sync(() => {
if (holder.inTransaction) holder.run("ROLLBACK")
holder.close()
}),
)
const stale = yield* store.db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
.get()
expect(stale?.value).toMatchObject({ type: "key", key: "first" })
yield* Layer.build(Layer.fresh(layer))
const reconciled = yield* store.db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
.get()
expect(reconciled?.value).toMatchObject({ type: "key", key: "second" })
}).pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("dual-writes stored credentials for released auth.json readers", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Database as SQLite } from "bun:sqlite"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
@@ -159,6 +160,22 @@ describe("database migration compatibility", () => {
sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'session'`,
),
).toEqual({ agent: "build", replacementSeq: 4, revision: 1 })
yield* db.run("PRAGMA busy_timeout = 0")
yield* Effect.acquireUseRelease(
Effect.sync(() => {
const holder = new SQLite(filename)
holder.run("PRAGMA busy_timeout = 0")
holder.run("BEGIN IMMEDIATE")
return holder
}),
() => ensure(db),
(holder) =>
Effect.sync(() => {
if (holder.inTransaction) holder.run("ROLLBACK")
holder.close()
}),
)
}),
).pipe(Effect.provide(Database.layerFromPath(filename)), Effect.scoped),
)
@@ -279,7 +279,13 @@ export class SQLiteEffectPreparedQuery<
assertUnreachable(cacheStrat)
}).pipe(
Effect.catch((e) => {
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
return Effect.fail(
new EffectDrizzleQueryError({
query: queryString,
params: params.map(() => "<redacted>"), // kilocode_change - bound values may contain credentials
cause: Cause.fail(e),
}),
)
}),
)
}
@@ -130,6 +130,24 @@ test("preserves failed transaction begin errors", async () => {
}
})
// kilocode_change start - query errors must never expose bound credential values
test("redacts bound values from query errors", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const secret = "must-not-leak"
yield* db.insert(users).values({ id: 1, name: "Ada" })
const error = yield* db.insert(users).values({ id: 1, name: secret }).pipe(Effect.flip)
expect(error.message).not.toContain(secret)
expect(error.params).not.toContain(secret)
expect(error.params.every((param) => param === "<redacted>")).toBe(true)
}),
)
})
// kilocode_change end
test("supports returning and rejects empty update sets", async () => {
await run(
Effect.gen(function* () {
@@ -13,7 +13,6 @@
| `kilo upgrade [target]` | upgrade kilo to the latest or a specific version |
| `kilo uninstall` | uninstall kilo and remove all related files |
| `kilo serve` | starts a headless kilo server |
| `kilo web` | start kilo server and open web interface |
| `kilo models [provider]` | list all available models |
| `kilo roll-call <filter>` | batch-test text models matching a filter for connectivity and latency |
| `kilo profile` | show Kilo account profile |
@@ -14,6 +14,31 @@ The JetBrains plugin provides the best native JetBrains UX for working with an A
{% image src="/docs/img/jetbrains/plugin-auto-updates.png" alt="JetBrains Updates settings with Update plugins automatically enabled" width="900" caption="Enable automatic plugin updates to receive Kilo Code fixes and improvements." /%}
### Install with bundled Kilo Core
The Marketplace build is best for most users. Use the bundled Kilo Core build when your IDE cannot download the Kilo Core runtime after installation, such as on locked-down corporate networks, behind strict proxy or TLS inspection, in offline development environments, or where corporate policy blocks applications from downloading executables at runtime.
The bundled build ships the JetBrains plugin with Kilo Core included. The install is larger, but first launch does not need a separate runtime download.
1. Open **Settings → Plugins**
2. Click the gear icon and choose **Manage Plugin Repositories...**
3. Click **+** and add the Kilo Code repository URL:
```text
https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
```
4. Click **OK**, then install or update **Kilo Code** from **Settings → Plugins**
5. Restart the IDE if prompted
{% image src="/docs/img/jetbrains/plugin-custom-repository-menu.png" alt="JetBrains Plugins settings with Manage Plugin Repositories selected from the gear menu" width="900" caption="Open Manage Plugin Repositories from the Plugins settings gear menu." /%}
{% image src="/docs/img/jetbrains/plugin-custom-repository-url.png" alt="JetBrains Custom Plugin Repositories dialog with the Kilo Code repository URL added" width="700" caption="Add the Kilo Code custom plugin repository URL." /%}
After restart, open the **Kilo Code** tool window and choose **... → Core**. The menu footer should show **Bundled Core** with the version and architecture.
{% image src="/docs/img/jetbrains/plugin-bundled-core.png" alt="Kilo Code tool window Core menu showing Bundled Core and the current architecture" width="900" caption="Confirm that the plugin is using Bundled Core." /%}
### If you used the v7 EAP {% #jetbrains-early-access %}
{% callout type="info" %}
@@ -232,7 +232,7 @@ See [Agent Manager Workflows](/docs/automate/agent-manager-workflows#merging-wor
## Terminals
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session. If the embedded terminal is already visible but the prompt has focus, the same shortcut focuses the terminal without hiding it. Press it again while the terminal has focus to hide the panel.
### Choosing the Terminal Destination
@@ -243,11 +243,18 @@ The toolbar's terminal button is a split button: click it to open a terminal, or
The dropdown choice is remembered per panel and becomes the default for new panels. You can also set the default directly with the `kilo-code.new.agentManager.terminalButtonDestination` setting (`vscode` or `agentManager`). The `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) shortcut follows the same destination.
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal it and press again to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you close its tab in the panel.
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal and focus it, press it while the panel is visible but another control has focus to move focus into the terminal, and press it again from the terminal to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you click its close button or type `exit` in the shell.
### Multiple Terminals
The side panel hosts multiple terminals per context (the local workspace or a worktree). The panel header is a tab strip: click a tab to switch, click **+** to open another terminal, and click **X** (or middle-click) to close a single terminal. Drag tabs to reorder them. Closing a terminal no longer hides the panel — closing the last one lands on the empty state. Pressing `Cmd+W` (macOS) / `Ctrl+W` (Windows/Linux) with a focused side terminal closes exactly that terminal.
Agent Manager has two separate terminal tab strips:
- **Main terminal tabs** appear alongside the agent session tabs. With the prompt or a main terminal focused, press `Cmd+Shift+T` / `Ctrl+Shift+T` to create another main terminal tab.
- **Side terminal tabs** appear in the terminal panel. Focus a side terminal, then press `Cmd+Shift+T` / `Ctrl+Shift+T` to create another side terminal. You can also click **+** in the side-terminal strip.
The shortcut follows terminal focus, not panel visibility. A visible side panel with the prompt focused still creates a main terminal tab. Press `Cmd+Shift+[` / `Ctrl+Shift+[` for the previous terminal or `Cmd+Shift+]` / `Ctrl+Shift+]` for the next terminal in the focused terminal strip. Drag tabs to reorder them. Pressing `Cmd+W` / `Ctrl+W` with a focused side terminal closes that terminal when other terminals remain. On the last side terminal, it hides the panel and keeps the shell alive; use its close button or type `exit` to stop it.
`Cmd+T` / `Ctrl+T` always creates a new agent session tab. It never creates a terminal.
New terminals are named "Terminal N" using the lowest free number, and tabs pick up the live title from the shell or running program, so a dev server or editor names its own tab.
@@ -256,7 +263,7 @@ New terminals are named "Terminal N" using the lowest free number, and tabs pick
A common workflow is letting the agent work, then switching to the terminal to run tests or inspect the worktree, then switching back to control the agent:
1. **Agent Manager → Terminal:** Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to open and focus the terminal for the current session. The terminal runs inside the session's worktree, so commands like `npm test` or `git status` operate on the agent's isolated branch.
2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This works from anywhere in VS Code — the terminal, another editor tab, or the sidebar.
2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This explicit shortcut always targets the prompt and works from anywhere in VS Code — the terminal, another editor tab, or the sidebar. Returning to the panel by clicking its editor tab or switching windows restores the last focused control instead.
## Setup Scripts
@@ -392,11 +399,13 @@ Closing a managed worktree removes it from Agent Manager, deletes its `.kilo/wor
| `Cmd+Shift+N` | `Ctrl+Shift+N` | Create a new worktree immediately |
| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree |
| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree |
| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree |
| `Cmd+W` | `Ctrl+W` | Close current tab |
| `Cmd+T` | `Ctrl+T` | New agent session tab in worktree |
| `Cmd+W` | `Ctrl+W` | Close the focused tab or terminal; the last side terminal hides instead of stopping |
| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree |
| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree |
| `Cmd+/` | `Ctrl+/` | Focus terminal for current session |
| `Cmd+/` | `Ctrl+/` | Focus terminal, or hide it when it already has focus |
| `Cmd+Shift+T` | `Ctrl+Shift+T` | New side terminal when a side terminal is focused; otherwise new main terminal tab |
| `Cmd+Shift+[` / `]` | `Ctrl+Shift+[` / `]` | Previous / next terminal |
| `Cmd+D` | `Ctrl+D` | Toggle diff panel |
| `Cmd+E` | `Ctrl+E` | Run / stop run script |
| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts |
@@ -659,21 +659,6 @@ Options:
--cors additional domains to allow for CORS [array] [default: []]
```
## kilo web
```
start kilo server and open web interface
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
```
## kilo models
```
@@ -161,9 +161,9 @@ A configured destination is an egress route, not tenant, organization, repositor
The config setting supplies the initial default for new sessions that do not have a saved preference. Use the lock button in the VS Code prompt or `/sandbox` in the CLI to change the current session. Your latest choice is saved as the default for future sessions in that project, takes precedence over the config default, and persists across restarts.
Each initialized session snapshots its network mode, allowed destinations, and additional writable paths. Changing config affects new sessions. The prompt control or `/sandbox` can change the current session's enabled state, but it cannot change these authority lists, and they never expand during an active session.
Each session preserves its enabled or disabled choice. Saving changes through Kilo settings to network mode, allowed destinations, or additional writable paths refreshes existing session policies before their next tool execution. Enabling sandboxing also reads the latest settings. A tool that is already running keeps the policy it started with.
Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect.
At creation, forked sessions retain the source session's confinement and subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. Later trusted sandbox settings replace those network and writable-path limits before the affected session's next tool execution.
Cloud sessions do not expose the local sandbox control because their tools do not run in your local sandbox.
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88a4490322f4f70694f7b785cff41c5900b4ef8c14da104396fe55d264a624b4
size 14535
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d49870048a42ea9c0166143fbb364b0d9ee45d786e3b1346fcd0eda76c533bdb
size 2414
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838
size 1085
+2
View File
@@ -72,6 +72,8 @@
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://github.com>
<!-- packages/opencode/src/kilocode/security/github.ts -->
- <https://github.com/anthropics/claude-code/issues/31375>
<!-- packages/kilo-vscode/src/utils.ts -->
- <https://github.com/apps/kiloconnect>
<!-- packages/opencode/src/cli/cmd/github.handler.ts -->
- <https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts>
+6
View File
@@ -128,6 +128,12 @@
## [Unreleased]
## [7.0.14] - 2026-08-06
### Fixed
- Improve slash command matching in the JetBrains plugin so typed commands resolve more reliably.
- Avoid startup crashes when the Kilo CLI database is temporarily locked by another process.
## [7.0.13] - 2026-08-05
### Added
@@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
@@ -630,8 +631,12 @@ object KiloCliDataParser {
CommandInfo(
name = obj.str("name") ?: "",
description = obj.str("description"),
agent = obj.str("agent"),
model = obj.str("model"),
variant = obj.str("variant"),
source = obj.str("source"),
hints = obj["hints"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
subtask = obj.flagOrNull("subtask"),
)
}
@@ -662,9 +667,34 @@ object KiloCliDataParser {
CommandDto(
name = name,
description = obj.str("description"),
agent = obj.str("agent"),
model = obj.str("model"),
variant = obj.str("variant"),
source = obj.str("source"),
hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
template = obj.str("template"),
subtask = obj.flagOrNull("subtask"),
)
}
fun parseAgentBehaviorCommandFiles(raw: String): List<CommandFileDto> =
raw.array().mapNotNull { item ->
val obj = item.obj() ?: return@mapNotNull null
val name = obj.str("name") ?: return@mapNotNull null
val location = obj.str("location") ?: return@mapNotNull null
CommandFileDto(
name = name,
description = obj.str("description"),
agent = obj.str("agent"),
model = obj.str("model"),
variant = obj.str("variant"),
source = obj.str("source"),
builtin = obj.bool("builtin"),
location = location,
editable = obj.bool("editable"),
content = obj.str("content"),
subtask = obj.flagOrNull("subtask"),
hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
)
}
@@ -713,6 +743,16 @@ object KiloCliDataParser {
return if (prim.isString) prim.content else null
}
fun parsePathConfig(raw: String): String? {
val prim = runCatching { tryParseObject(raw)?.get("config")?.jsonPrimitive }.getOrNull() ?: return null
return if (prim.isString) prim.content else null
}
fun parsePathHome(raw: String): String? {
val prim = runCatching { tryParseObject(raw)?.get("home")?.jsonPrimitive }.getOrNull() ?: return null
return if (prim.isString) prim.content else null
}
fun parseModelState(raw: String): ModelStateDto {
val obj = tryParseObject(raw) ?: return ModelStateDto()
return ModelStateDto(
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
@@ -75,7 +76,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
override suspend fun reloadSkills(directory: String): Boolean {
LOG.info("Skills reload requested dir=$directory")
if (hasActiveSession(directory)) {
if (hasActiveSession(directory, "Skills")) {
LOG.warn("Skills reload blocked by active session dir=$directory")
return false
}
@@ -136,6 +137,45 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
override suspend fun commands(directory: String) = KiloCliDataParser.parseAgentBehaviorCommands(request(directory, "/command", null))
override suspend fun commandFiles(directory: String): List<CommandFileDto> =
KiloCliDataParser.parseAgentBehaviorCommandFiles(request(directory, "/kilocode/command/files", null))
override suspend fun removeCommand(directory: String, location: String): Boolean =
post(directory, "/kilocode/command/remove", JsonObject(mapOf("location" to JsonPrimitive(location))))
override suspend fun reloadCommands(directory: String): Boolean {
LOG.info("Commands reload requested dir=$directory")
if (hasActiveSession(directory, "Commands")) {
LOG.warn("Commands reload blocked by active session dir=$directory")
return false
}
runCatching { post(directory, "/instance/reload") }.onFailure { err ->
LOG.warn("Commands reload failed dir=$directory", err)
}.getOrThrow()
LOG.info("Commands reload succeeded dir=$directory")
return true
}
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
LOG.info("Commands save requested dir=$directory count=${edits.size}")
app.requireReady()
val known = knownCommands(directory)
val roots = commandRoots(directory)
val paths = edits.map { (location, content) ->
val path = writableCommandPath(directory, location, known, roots) ?: return false
path to content
}
withContext(Dispatchers.IO) {
for ((path, content) in paths) {
Files.createDirectories(path.parent)
Files.writeString(path, content, StandardCharsets.UTF_8)
}
}
LOG.info("Command files saved dir=$directory count=${paths.size}")
LOG.info("Commands save reload deferred dir=$directory count=${paths.size}")
return true
}
override suspend fun mcpStatus(directory: String) = KiloCliDataParser.parseMcpStatus(request(directory, "/mcp", null)).also { items ->
LOG.info("MCP status returned dir=$directory count=${items.size}")
}
@@ -193,24 +233,24 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return true
}
private fun hasActiveSession(directory: String): Boolean {
private fun hasActiveSession(directory: String, label: String): Boolean {
val active = app.sessions.statuses.value.filterValues { it.type != "idle" }
if (active.isNotEmpty()) {
LOG.info("Skills reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}")
LOG.info("$label reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}")
return true
}
val permissions = runCatching { app.chat.pendingPermissions(directory) }.onFailure { err ->
LOG.warn("Skills reload pending permission check failed dir=$directory", err)
LOG.warn("$label reload pending permission check failed dir=$directory", err)
}.getOrDefault(emptyList())
if (permissions.isNotEmpty()) {
LOG.info("Skills reload pending permissions dir=$directory count=${permissions.size}")
LOG.info("$label reload pending permissions dir=$directory count=${permissions.size}")
return true
}
val questions = runCatching { app.chat.pendingQuestions(directory) }.onFailure { err ->
LOG.warn("Skills reload pending question check failed dir=$directory", err)
LOG.warn("$label reload pending question check failed dir=$directory", err)
}.getOrDefault(emptyList())
if (questions.isNotEmpty()) {
LOG.info("Skills reload pending questions dir=$directory count=${questions.size}")
LOG.info("$label reload pending questions dir=$directory count=${questions.size}")
return true
}
return false
@@ -238,6 +278,11 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return items.mapNotNull { item -> resolveEditablePath(item) }.toSet()
}
private suspend fun knownCommands(directory: String): Set<Path> {
val items = commandFiles(directory)
return items.mapNotNull { item -> resolveEditableCommandPath(item) }.toSet()
}
private fun writablePath(directory: String, location: String, known: Set<Path>): Path? {
val path = resolveSkillPath(location)
if (path == null) {
@@ -251,12 +296,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return path
}
private fun writableCommandPath(directory: String, location: String, known: Set<Path>, roots: Set<Path>): Path? {
val path = resolveCommandPath(location)
if (path == null) {
LOG.warn("Command save rejected: invalid location dir=$directory location=$location")
return null
}
if (path in known || newCommandPath(path, roots)) return path
LOG.warn("Command save rejected: unknown command dir=$directory path=$path")
return null
}
private fun resolveEditablePath(skill: SkillDto): Path? {
val path = resolveSkillPath(skill.location) ?: return null
if (urlCached(path)) return null
return path
}
private fun resolveEditableCommandPath(command: CommandFileDto): Path? {
if (!command.editable) return null
return resolveCommandPath(command.location)
}
private fun resolveSkillPath(location: String): Path? {
val raw = normalizeWorkspacePath(location) ?: return null
val path = try {
@@ -268,6 +329,59 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return path
}
private fun resolveCommandPath(location: String): Path? {
val raw = normalizeWorkspacePath(location) ?: return null
val path = try {
Path.of(raw).normalize()
} catch (_: InvalidPathException) {
return null
}
if (!path.isAbsolute || path.fileName?.toString()?.endsWith(".md") != true) return null
return path
}
private suspend fun commandRoots(directory: String): Set<Path> = buildSet {
addProjectCommandRoots(this, directory)
val paths = runCatching { request(directory, "/path", null) }.getOrNull()
val config = paths?.let(KiloCliDataParser::parsePathConfig)
if (config != null) addConfigCommandRoots(this, config)
val home = paths?.let(KiloCliDataParser::parsePathHome)
if (home != null) addHomeCommandRoots(this, home)
}
private fun addProjectCommandRoots(roots: MutableSet<Path>, dir: String) {
val base = try {
Path.of(dir).normalize()
} catch (_: InvalidPathException) {
return
}
for (cfg in listOf(".kilo", ".kilocode")) {
for (name in listOf("command", "commands")) roots.add(base.resolve(cfg).resolve(name).normalize())
}
}
private fun addConfigCommandRoots(roots: MutableSet<Path>, dir: String) {
val base = try {
Path.of(dir).normalize()
} catch (_: InvalidPathException) {
return
}
for (name in listOf("command", "commands")) roots.add(base.resolve(name).normalize())
}
private fun addHomeCommandRoots(roots: MutableSet<Path>, home: String) {
val base = try {
Path.of(home).normalize()
} catch (_: InvalidPathException) {
return
}
for (cfg in listOf(".kilo", ".kilocode")) addConfigCommandRoots(roots, base.resolve(cfg).toString())
}
private fun newCommandPath(path: Path, roots: Set<Path>): Boolean {
return roots.any { root -> path.startsWith(root) }
}
private fun urlCached(path: Path): Boolean {
return cacheRoots().any { root -> path.startsWith(root.resolve("kilo").resolve("skills").normalize()) }
}
@@ -56,8 +56,12 @@ internal object KiloWorkspaceDtoMapper {
fun command(c: CommandInfo) = CommandDto(
name = c.name,
description = c.description,
agent = c.agent,
model = c.model,
variant = c.variant,
source = c.source,
hints = c.hints,
subtask = c.subtask,
)
fun skill(s: SkillInfo) = SkillDto(
@@ -135,8 +135,12 @@ data class AgentInfo(
data class CommandInfo(
val name: String,
val description: String?,
val agent: String?,
val model: String?,
val variant: String?,
val source: String?,
val hints: List<String>,
val subtask: Boolean?,
)
data class SkillInfo(
@@ -1824,7 +1824,7 @@ class KiloCliDataParserTest {
@Test
fun `parseCommands - maps name, description, source, and hints`() {
val raw = """[
{"name":"init","description":"guided AGENTS.md setup","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command"},
{"name":"init","description":"guided AGENTS.md setup","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command","subtask":true},
{"name":"mcp-tool","template":"","hints":["${'$'}1","${'$'}2"],"source":"mcp"}
]"""
@@ -1833,8 +1833,12 @@ class KiloCliDataParserTest {
assertEquals(2, result.size)
assertEquals("init", result[0].name)
assertEquals("guided AGENTS.md setup", result[0].description)
assertEquals("reviewer", result[0].agent)
assertEquals("anthropic/claude-sonnet-4-6", result[0].model)
assertEquals("high", result[0].variant)
assertEquals("command", result[0].source)
assertEquals(listOf("\$ARGUMENTS"), result[0].hints)
assertEquals(true, result[0].subtask)
assertEquals("mcp", result[1].source)
assertEquals(listOf("\$1", "\$2"), result[1].hints)
}
@@ -1885,6 +1889,8 @@ class KiloCliDataParserTest {
fun `parsePathState - extracts state from valid path response`() {
val raw = """{"home":"/home/user","state":"/home/user/.local/state/kilo","config":"/home/user/.config/kilo","worktree":"/project","directory":"/project"}"""
assertEquals("/home/user/.local/state/kilo", KiloCliDataParser.parsePathState(raw))
assertEquals("/home/user/.config/kilo", KiloCliDataParser.parsePathConfig(raw))
assertEquals("/home/user", KiloCliDataParser.parsePathHome(raw))
}
@Test
@@ -119,6 +119,76 @@ class KiloAgentBehaviorRpcApiImplTest {
assertEquals(1, mock.requestCount("/instance/reload"))
}
@Test
fun `command files and remove command call CLI endpoints`() = runBlocking {
val dir = Files.createTempDirectory("kilo-command-test")
val file = Files.createDirectories(dir.resolve("command")).resolve("review.md")
Files.writeString(file, "---\ndescription: Review code\n---\n\nReview $" + "ARGUMENTS")
mock.commandFiles = """[
{"name":"review","description":"Review code","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review","subtask":true},
{"name":"init","source":"command","builtin":true,"location":"builtin","editable":false,"content":"Init"}
]""".trimIndent()
val rpc = rpc()
val commands = rpc.commandFiles("/test project")
assertEquals(listOf("review", "init"), commands.map { it.name })
assertEquals(true, commands.single { it.name == "review" }.editable)
assertEquals("reviewer", commands.single { it.name == "review" }.agent)
assertEquals("anthropic/claude-sonnet-4-6", commands.single { it.name == "review" }.model)
assertEquals("high", commands.single { it.name == "review" }.variant)
assertEquals(true, commands.single { it.name == "review" }.subtask)
assertEquals(false, commands.single { it.name == "init" }.editable)
assertTrue(rpc.removeCommand("/test project", file.toString()))
assertEquals("{\"location\":\"$file\"}", mock.lastCommandRemoveBody)
assertEquals(1, mock.requestCount("/kilocode/command/remove"))
mock.commandRemoveStatus = 400
val err = assertFailsWith<RuntimeException> {
rpc.removeCommand("/test", "/tmp/missing.md")
}
assertContains(err.message.orEmpty(), "HTTP 400")
assertTrue(rpc.reloadCommands("/test project"))
assertEquals(1, mock.requestCount("/instance/reload"))
}
@Test
fun `save commands validates known and new project command paths`() = runBlocking {
val project = Files.createTempDirectory("kilo-command-project")
val known = Files.createDirectories(project.resolve(".kilo/command")).resolve("known.md")
val added = project.resolve(".kilo/commands/new.md")
val other = Files.createTempFile("kilo-command-other", ".md")
Files.writeString(known, "old")
Files.writeString(other, "old")
mock.commandFiles = """[
{"name":"known","source":"command","builtin":false,"location":"$known","editable":true,"content":"old"}
]""".trimIndent()
val rpc = rpc()
assertTrue(rpc.saveCommands(project.toString(), mapOf(known.toString() to "new", added.toString() to "created")))
assertEquals("new", Files.readString(known))
assertEquals("created", Files.readString(added))
assertEquals(1, mock.requestCount("/kilocode/command/files"))
assertFalse(rpc.saveCommands(project.toString(), mapOf(other.toString() to "nope")))
assertEquals("old", Files.readString(other))
}
@Test
fun `save commands validates new global command paths`() = runBlocking {
val project = Files.createTempDirectory("kilo-command-project")
val config = Files.createTempDirectory("kilo-command-config")
val added = config.resolve("commands/global.md")
mock.path = """{"home":"/tmp","state":"/tmp","config":"$config","worktree":"$project","directory":"$project"}"""
val rpc = rpc()
assertTrue(rpc.saveCommands(project.toString(), mapOf(added.toString() to "global command")))
assertEquals("global command", Files.readString(added))
assertEquals(1, mock.requestCount("/path"))
}
@Test
fun `url cached skills are read only`() = runBlocking {
val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote")
@@ -68,10 +68,12 @@ class MockCliServer : AutoCloseable {
@Volatile var mcpStatus = 200
@Volatile var mcpActionStatus = 200
@Volatile var agentRemoveStatus = 200
@Volatile var commandRemoveStatus = 200
@Volatile var skillRemoveStatus = 200
@Volatile var agentBuilderStatus = 200
@Volatile var lastMcpActionPath: String? = null
@Volatile var lastAgentRemoveBody: String? = null
@Volatile var lastCommandRemoveBody: String? = null
@Volatile var lastSkillRemoveBody: String? = null
@Volatile var lastAgentBuilderPath: String? = null
@Volatile var lastAgentBuilderBody: String? = null
@@ -83,11 +85,13 @@ class MockCliServer : AutoCloseable {
@Volatile var providersAfterAuthPut: String? = null
@Volatile var agents = "[]"
@Volatile var commands = "[]"
@Volatile var commandFiles = "[]"
@Volatile var skills = "[]"
@Volatile var providersStatus = 200
@Volatile var providerAuthStatus = 200
@Volatile var agentsStatus = 200
@Volatile var commandsStatus = 200
@Volatile var commandFilesStatus = 200
@Volatile var skillsStatus = 200
// File search responses
@@ -370,7 +374,7 @@ class MockCliServer : AutoCloseable {
respond(output, organizationSetStatus, "true")
}
path == "/global/event" -> handleSse(output, latch)
path == "/path" -> respond(output, 200, this.path)
bare == "/path" -> respond(output, 200, this.path)
bare == "/provider" -> respond(output, providersStatus, providers)
bare == "/provider/auth" -> respond(output, providerAuthStatus, providerAuth)
bare == "/agent" -> respond(output, agentsStatus, agents)
@@ -384,6 +388,11 @@ class MockCliServer : AutoCloseable {
lastAgentRemoveBody = body
respond(output, agentRemoveStatus, if (agentRemoveStatus == 200) "true" else """{"error":"Agent not found"}""")
}
bare == "/kilocode/command/files" -> respond(output, commandFilesStatus, commandFiles)
bare == "/kilocode/command/remove" && method == "POST" -> {
lastCommandRemoveBody = body
respond(output, commandRemoveStatus, if (commandRemoveStatus == 200) "true" else """{"error":"Command not found"}""")
}
bare == "/kilocode/skill/remove" && method == "POST" -> {
lastSkillRemoveBody = body
respond(output, skillRemoveStatus, if (skillRemoveStatus == 200) "true" else """{"error":"Skill not found"}""")
@@ -3,6 +3,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
@@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.SkillDto
class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var agents = emptyList<AgentDetailDto>()
var skills = emptyList<SkillDto>()
var commandFiles = emptyList<CommandFileDto>()
var mcps = emptyList<McpStatusDto>()
var mcpConfigs = emptyMap<String, McpServerConfigDto>()
val agentCalls = mutableListOf<String>()
@@ -19,6 +21,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
val skillRemovals = mutableListOf<Pair<String, String>>()
val skillReloads = mutableListOf<String>()
val skillSaves = mutableListOf<Triple<String, String, String>>()
val commandCalls = mutableListOf<String>()
val commandRemovals = mutableListOf<Pair<String, String>>()
val commandReloads = mutableListOf<String>()
val commandSaves = mutableListOf<Triple<String, String, String>>()
val mcpCalls = mutableListOf<String>()
val mcpConfigCalls = mutableListOf<String>()
val mcpSaves = mutableListOf<Triple<String, String, McpConfigDto?>>()
@@ -33,6 +39,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var afterMcpConnect: (suspend (String, String) -> Unit)? = null
var createError: Exception? = null
var skillsError: Exception? = null
var commandFilesError: Exception? = null
var removeError: Exception? = null
var removeSkillError: Exception? = null
var saveSkillError: Exception? = null
@@ -42,6 +49,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var removeSkillResult = true
var reloadSkillResult = true
var saveSkillResult = true
var removeCommandResult = true
var reloadCommandResult = true
var saveCommandResult = true
var mcpConnectResult = true
var mcpDisconnectResult = true
var mcpAuthenticateResult = true
@@ -123,6 +133,35 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
return emptyList()
}
override suspend fun commandFiles(directory: String): List<CommandFileDto> {
assertNotEdt("agentBehavior.commandFiles")
commandFilesError?.let { throw it }
commandCalls.add(directory)
return commandFiles
}
override suspend fun removeCommand(directory: String, location: String): Boolean {
assertNotEdt("agentBehavior.removeCommand")
commandRemovals.add(directory to location)
if (removeCommandResult) commandFiles = commandFiles.filterNot { it.location == location }
return removeCommandResult
}
override suspend fun reloadCommands(directory: String): Boolean {
assertNotEdt("agentBehavior.reloadCommands")
commandReloads.add(directory)
return reloadCommandResult
}
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
assertNotEdt("agentBehavior.saveCommands")
for ((location, content) in edits) commandSaves.add(Triple(directory, location, content))
if (saveCommandResult) commandFiles = commandFiles.map { command ->
edits[command.location]?.let { command.copy(content = it) } ?: command
}
return saveCommandResult
}
override suspend fun mcpStatus(directory: String): List<McpStatusDto> {
assertNotEdt("agentBehavior.mcpStatus")
mcpStatusError?.let { throw it }
+1 -1
View File
@@ -1,5 +1,5 @@
kotlin.stdlib.default.dependency=false
kilo.jetbrains.version=7.0.13
kilo.jetbrains.version=7.0.14
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
# false is NOT releasable -- production builds fail unless this is true.
@@ -2,6 +2,7 @@ package ai.kilocode.rpc
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
@@ -38,6 +39,14 @@ interface KiloAgentBehaviorRpcApi : RemoteApi<Unit> {
suspend fun commands(directory: String): List<CommandDto>
suspend fun commandFiles(directory: String): List<CommandFileDto>
suspend fun removeCommand(directory: String, location: String): Boolean
suspend fun reloadCommands(directory: String): Boolean
suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean
suspend fun mcpStatus(directory: String): List<McpStatusDto>
suspend fun mcpConfig(directory: String): Map<String, McpServerConfigDto>
@@ -6,7 +6,11 @@ import kotlinx.serialization.Serializable
data class CommandDto(
val name: String,
val description: String? = null,
val agent: String? = null,
val model: String? = null,
val variant: String? = null,
val source: String? = null,
val hints: List<String> = emptyList(),
val template: String? = null,
val subtask: Boolean? = null,
)
@@ -0,0 +1,19 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
data class CommandFileDto(
val name: String,
val description: String? = null,
val agent: String? = null,
val model: String? = null,
val variant: String? = null,
val source: String? = null,
val builtin: Boolean = false,
val location: String,
val editable: Boolean = false,
val content: String? = null,
val subtask: Boolean? = null,
val hints: List<String> = emptyList(),
)
+6
View File
@@ -168,6 +168,12 @@ Agent Manager local worktree sessions use the current shared `kilo serve` proces
Extension-side code lives in `src/agent-manager/`, webview code in `webview-ui/agent-manager/`. The webview reuses the sidebar's provider chain and `ChatView` component, adding a `WorktreeModeProvider` and a split layout.
### Multi-project migration
Multi-project Agent Manager is an incremental migration behind the application-scoped `kilo-code.new.experimental.multiProject` flag (default `false`); flag-off behavior must remain unchanged. The project registry/contexts, per-project state and session routing, project sidebar, sections and drag-and-drop, progress/persistence, and project-targeted worktree creation are implemented.
It is not yet a complete convergence: audit every operation for explicit project/worktree/session routing, finish immutable project-bound Settings and machine-local indexing consent, harden canonical Git identity and multi-window route ownership, and replace the duplicate `SidebarBody`/`ProjectSidebarBody` implementations with one shared body. Full two-project E2E and legacy-parity coverage is still incomplete.
## Webview UI (kilo-ui)
New webview features must use **`@kilocode/kilo-ui`** components instead of raw HTML elements with inline styles. This is a Solid.js component library built on `@kobalte/core`.
+3 -2
View File
@@ -44,8 +44,9 @@ export default [
{
files: ["webview-ui/agent-manager/AgentManagerApp.tsx"],
// Lowered 3210 → 2800 after extracting the sidebar body (SidebarBody.tsx)
// and the tab bar (TabBar.tsx) into components. Keep shrinking as more
// logic moves out; do not raise.
// and the tab bar (TabBar.tsx) into components. The keybinding defaults
// (keybind-defaults.ts) extraction offsets the terminal-ux additions; keep
// shrinking as more logic moves out; do not raise.
rules: { complexity: ["error", 74], "max-lines": ["error", 2800] },
},
{
+45 -11
View File
@@ -59,6 +59,12 @@
],
"main": "./dist/extension.js",
"contributes": {
"configurationDefaults": {
"files.watcherExclude": {
"**/.kilo/worktrees/**": true,
"**/.kilocode/worktrees/**": true
}
},
"taskDefinitions": [
{
"type": "kilo-worktree-setup",
@@ -245,6 +251,16 @@
"title": "Agent Manager: Next Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.previousTerminal",
"title": "Agent Manager: Previous Terminal",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.nextTerminal",
"title": "Agent Manager: Next Terminal",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.search",
"title": "Agent Manager: Search Worktrees and Sessions",
@@ -498,37 +514,37 @@
{
"command": "kilo-code.new.sidebarTitle.plusButtonClicked",
"group": "navigation@0",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.historyButtonClicked",
"group": "navigation@1",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.agentManagerOpen",
"group": "navigation@2",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.kiloClawOpen",
"group": "navigation@3",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked",
"group": "navigation@4",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.profileButtonClicked",
"group": "navigation@5",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.settingsButtonClicked",
"group": "navigation@6",
"when": "view == kilo-code.SidebarProvider"
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
}
],
"scm/title": [
@@ -554,22 +570,22 @@
{
"command": "kilo-code.new.plusButtonClicked",
"group": "navigation@0",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel"
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.historyButtonClicked",
"group": "navigation@1",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel"
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.profileButtonClicked",
"group": "navigation@2",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel"
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.settingsButtonClicked",
"group": "navigation@3",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel"
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
}
],
"editor/context": [
@@ -633,6 +649,11 @@
"key": "ctrl+shift+g",
"mac": "cmd+shift+g"
},
{
"command": "-workbench.actions.view.problems",
"key": "ctrl+shift+m",
"mac": "cmd+shift+m"
},
{
"command": "kilo-code.new.agentManagerOpen",
"key": "ctrl+shift+m",
@@ -668,6 +689,18 @@
"mac": "cmd+alt+right",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.previousTerminal",
"key": "ctrl+shift+[",
"mac": "cmd+shift+[",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.nextTerminal",
"key": "ctrl+shift+]",
"mac": "cmd+shift+]",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.search",
"key": "ctrl+f",
@@ -1271,6 +1304,7 @@
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"@vscode/codicons": "^0.0.44",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode-graphemes": "0.4.0",
+11 -1
View File
@@ -17,7 +17,7 @@ import type { EditorContext, IndexingStatus } from "./services/cli-backend/types
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
import { notebookUri } from "./services/autocomplete/continuedev/core/autocomplete/notebook"
import { buildWebviewHtml, getWebviewFontSize } from "./utils"
import { buildWebviewHtml, getWebviewFontSize, isCursorHost } from "./utils"
import { saveImage } from "./kilo-provider/save-image"
import { handleEditorAction } from "./kilo-provider/editor-actions"
import { exportTranscript } from "./kilo-provider/export-transcript"
@@ -89,6 +89,7 @@ import {
} from "./services/autocomplete/settings"
import { routeEarlyMessage } from "./kilo-provider/early-message"
import * as ModelState from "./kilo-provider/model-state"
import { handleModelUsageMessage } from "./kilo-provider/model-usage"
import { handleForkSession } from "./kilo-provider/fork-session"
import { openConfig } from "./kilo-provider/open-config"
import {
@@ -1025,6 +1026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
copy: (text) => vscode.env.clipboard.writeText(text),
openSessions: (ids) => this.trackOpenSessions(ids),
speechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)),
})
) {
return
@@ -1046,6 +1048,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"),
openChanges: (sessionId?: string, turnId?: string) =>
vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId, turnId }),
openProfile: () => vscode.commands.executeCommand("kilo-code.new.profileButtonClicked"),
currentSessionId: this.currentSession?.id,
createWorktree: async (baseBranch, branchName) => {
await this.createWorktreeHandler?.(baseBranch, branchName)
@@ -4057,6 +4060,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Clear globalState items that are not part of the configuration
await this.extensionContext?.globalState.update("variantSelections", undefined)
await this.extensionContext?.globalState.update("recentModels", undefined)
await this.extensionContext?.globalState.update("modelUsage", undefined)
await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined)
await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", undefined)
await this.extensionContext?.globalState.update("kilo.marketplace.dismissedSuggestions", undefined)
@@ -4074,6 +4078,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Re-send globalState items to the webview
this.postMessage({ type: "variantsLoaded", variants: {} })
this.postMessage({ type: "recentsLoaded", recents: [] })
this.postMessage({ type: "modelUsageLoaded", usage: {} })
// Re-fetch notifications to reflect cleared dismissed IDs
await this.fetchAndSendNotifications()
@@ -4904,6 +4909,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
title: "Kilo Code",
port: this.connectionService.getServerInfo()?.port,
extraStyles: `.container { height: 100vh; }`,
// Dedicated single-purpose panels (Settings, Profile, Sub-Agent Viewer)
// never show the bar. Sidebar and "Open in Tab" only need it in Cursor —
// VS Code's native toolbar (restored in package.json) works everywhere.
topBar: this.opts.hideTopBar !== true && isCursorHost(),
topBarSurface: this.opts.topBarSurface === "tab" ? "tab_title" : "sidebar_title",
})
}
@@ -103,6 +103,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
// backend connectivity (config, providers, agents, profile, auth).
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
projectDirectory,
hideTopBar: true,
})
if (this.remoteService) {
provider.setRemoteService(this.remoteService)
@@ -41,7 +41,7 @@ export class SubAgentViewerProvider implements vscode.Disposable {
dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"),
}
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context)
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { hideTopBar: true })
// Start accepting this session's SSE events as soon as the panel subscribes.
// Reasoning deltas are not persisted until the reasoning part finishes.
provider.trackSession(sessionID)
@@ -75,7 +75,7 @@ import { createProjectWiring } from "./project/wiring"
import { ProjectScope } from "./project/scope"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
import { focusPanelPrompt } from "./focus-panel"
export class AgentManagerProvider implements Disposable {
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
private panel: PanelContext | undefined
@@ -356,22 +356,22 @@ export class AgentManagerProvider implements Disposable {
if (this.panel) {
this.log("Panel already open, revealing")
this.panel.reveal(preserveFocus)
if (!preserveFocus) this.postToWebview({ type: "action", action: "focusInput" })
if (!preserveFocus)
focusPanelPrompt(this.panel, this.waitForPanelReady(this.panel), this.waitForPanelActive(this.panel))
return
}
this.log("Opening Agent Manager panel")
this.host.capture("Agent Manager Opened", { source: PLATFORM })
this.attachPanel(
this.host.openPanel({
onBeforeMessage: (msg) => this.onMessage(msg),
worktreeDirectories: () => this.getWorktreeDirectories(),
workspaceRoot: () => this.getRoot(),
projectId: () => this.contexts.active()?.id,
}),
)
const panel = this.host.openPanel({
onBeforeMessage: (msg) => this.onMessage(msg),
worktreeDirectories: () => this.getWorktreeDirectories(),
workspaceRoot: () => this.getRoot(),
projectId: () => this.contexts.active()?.id,
})
this.attachPanel(panel)
if (!preserveFocus) focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
}
public onPanelVisibilityChange(cb: (visible: boolean) => void): void {
this.onVisibilityChange = cb
}
@@ -811,15 +811,15 @@ export class AgentManagerProvider implements Disposable {
private onImportMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type === "agentManager.requestBranches") {
void this.importer.branches()
void this.importer.branches(m.projectId)
return null
}
if (m.type === "agentManager.importFromBranch") {
void this.importer.branch(m.branch)
void this.importer.branch(m.branch, m.projectId)
return null
}
if (m.type === "agentManager.importFromPR") {
void this.importer.pr(m.url)
void this.importer.pr(m.url, m.projectId)
return null
}
}
@@ -1055,6 +1055,7 @@ export class AgentManagerProvider implements Disposable {
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
projectId: this.host.multiProject() ? this.context?.id : undefined,
status: "ready",
message: "Worktree ready",
sessionId,
@@ -1694,11 +1695,11 @@ export class AgentManagerProvider implements Disposable {
* Used for the keyboard shortcut to switch back from terminal.
*/
public focusPanel(): void {
if (!this.panel) return
this.panel.reveal(false)
this.postToWebview({ type: "action", action: "focusInput" })
const panel = this.panel
if (!panel) return
panel.reveal(false)
focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
}
public isActive(): boolean {
return this.panel?.active === true
}
@@ -121,6 +121,8 @@ export class WorktreeManager {
// Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done
private static fetchCache = new Map<string, number>()
private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute
private static gitAvailable = false
private static lfsAvailable: boolean | undefined
private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
const key = this.root
@@ -150,6 +152,13 @@ export class WorktreeManager {
return this.withGitLock(() => this.createWorktreeImpl(params))
}
/** Start the remote base refresh before creation reaches the git mutex. */
async prefetchBase(branch?: string): Promise<void> {
await this.ensureMigrated()
const base = branch || (await this.defaultBranch())
await this.withGitLock(() => this.refreshBase(base))
}
async renameBranch(worktreePath: string, current: string, requested: string): Promise<string> {
await this.ensureMigrated()
return this.withGitLock(() => this.renameBranchImpl(worktreePath, current, requested))
@@ -176,9 +185,12 @@ export class WorktreeManager {
}
private async ensureGitAvailable(): Promise<void> {
if (WorktreeManager.gitAvailable) return
try {
await execWithShellEnv("git", ["--version"])
WorktreeManager.gitAvailable = true
} catch (error) {
WorktreeManager.gitAvailable = false
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(
"Git is not installed or not found in PATH. Please install Git (https://git-scm.com) and restart VS Code.",
@@ -762,20 +774,14 @@ export class WorktreeManager {
source: "remote",
}
}
WorktreeManager.fetchCache.delete(cacheKey)
}
// Either not cached or cache is stale - do the fetch.
// Use non-interactive env to prevent SSH passphrase popups.
onProgress?.("fetching", `Fetching ${remote}/${branch}...`)
try {
// Only opt into simple-git's allowUnsafeSshCommand when the SSH command
// is the fixed value Kilo injects — never for an inherited one, which
// could be attacker-controlled.
const env = nonInteractiveEnv()
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
.env(env)
.fetch(remote, branch, { "--quiet": null, "--no-tags": null })
WorktreeManager.fetchCache.set(cacheKey, Date.now())
await this.refreshBase(branch, remote)
if (await this.refExistsLocally(`${remote}/${branch}`)) {
return {
ref: `${remote}/${branch}`,
@@ -830,6 +836,23 @@ export class WorktreeManager {
throw new Error(`Could not resolve start point for branch "${branch}"`)
}
private async refreshBase(branch: string, requested?: string): Promise<void> {
const remote = requested ?? (await this.resolveRemote())
if (!remote) return
const key = `${this.root}:${remote}:${branch}`
const cached = WorktreeManager.fetchCache.get(key)
if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) return
// Only opt into simple-git's allowUnsafeSshCommand when the SSH command
// is the fixed value Kilo injects — never for an inherited one, which
// could be attacker-controlled.
const env = nonInteractiveEnv()
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
.env(env)
.fetch(remote, branch, { "--quiet": null, "--no-tags": null })
WorktreeManager.fetchCache.set(key, Date.now())
}
/**
* Resolve the primary remote name for this repo.
* Uses `GitOps.resolveRemote` when available, otherwise checks for "origin".
@@ -894,10 +917,13 @@ export class WorktreeManager {
}
async checkLfsAvailable(): Promise<boolean> {
if (WorktreeManager.lfsAvailable) return true
try {
await execWithShellEnv("git", ["lfs", "version"], { cwd: this.root, timeout: 5000 })
WorktreeManager.lfsAvailable = true
return true
} catch {
WorktreeManager.lfsAvailable = false
// git-lfs not installed
return false
}
@@ -0,0 +1,8 @@
import type { PanelContext } from "./host"
export function focusPanelPrompt(panel: PanelContext, ready: Promise<boolean>, active: Promise<boolean>): void {
void Promise.all([ready, active]).then(([ready, active]) => {
if (!ready || !active) return
panel.postMessage({ type: "action", action: "focusInput" })
})
}
@@ -71,6 +71,9 @@ export function buildKeybindingMap(
if (!bindings.runScript) bindings.runScript = formatKeybinding(mac ? "cmd+e" : "ctrl+e", mac)
if (!bindings.toggleDiff) bindings.toggleDiff = formatKeybinding(mac ? "cmd+d" : "ctrl+d", mac)
if (!bindings.showShortcuts) bindings.showShortcuts = formatKeybinding(mac ? "cmd+shift+/" : "ctrl+shift+/", mac)
if (!bindings.previousTerminal)
bindings.previousTerminal = formatKeybinding(mac ? "cmd+shift+[" : "ctrl+shift+[", mac)
if (!bindings.nextTerminal) bindings.nextTerminal = formatKeybinding(mac ? "cmd+shift+]" : "ctrl+shift+]", mac)
return bindings
}
@@ -6,6 +6,9 @@ import { versionedName } from "./branch-name"
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
import { ensureSandbox } from "./sandbox-bootstrap"
import type { LifecycleHost } from "./provider-lifecycle"
import { Semaphore } from "./semaphore"
const PROVISION_CONCURRENCY = 2
/**
* Multi-version creation needs the lifecycle capabilities plus three provider
@@ -19,8 +22,8 @@ export interface MultiVersionHost extends LifecycleHost {
}
/**
* Create N worktrees with one session each (optionally one model per version),
* then fan the initial prompt out to every created session. State is reached
* Create N worktrees, provision their sessions with bounded concurrency, then
* send each initial prompt as soon as that session is ready. State is reached
* through the project context; everything else goes through the host.
*/
export async function createMultiVersion(
@@ -57,26 +60,49 @@ export async function createMultiVersion(
groupId,
})
// Phase 1: Create all worktrees + sessions first
// Phase 1: finish every shared-repository Git mutation before setup scripts
// or agents can run their own Git commands in the new worktrees.
const created: CreatedVersion[] = []
for (let i = 0; i < versions; i++) {
const version = await createVersion(ctx, host, {
index: i,
versions,
groupId,
baseBranch,
branchName,
worktreeName,
models,
providerID,
modelID,
sandbox: msg.sandbox,
})
if (!version) continue
created.push(version)
const specs = Array.from({ length: versions }, (_, index) => ({
index,
versions,
groupId,
baseBranch,
branchName,
worktreeName,
models,
providerID,
modelID,
sandbox: msg.sandbox,
}))
const prepared: PreparedVersion[] = []
for (const spec of specs) {
const version = await prepareVersion(host, spec)
if (version) prepared.push(version)
}
// Phase 2: Git creation is complete, so independent setup/session pipelines
// can overlap without racing the shared worktree metadata mutation.
const provision = async (version: PreparedVersion) => {
const ready = await provisionVersion(ctx, host, version)
if (!ready) return
created.push(ready)
sendInitialPrompt(
host,
ctx.id,
ready,
models,
{ providerID, modelID },
{
text,
agent,
variant: msg.variant,
files,
},
)
// Update progress
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
@@ -87,15 +113,8 @@ export async function createMultiVersion(
})
}
// Phase 2: Send the initial prompt to all sessions, or clear busy state if no text.
await sendInitialPrompts(
host,
ctx.id,
created,
models,
{ providerID, modelID },
{ text, agent, variant: msg.variant, files },
)
const gate = new Semaphore(PROVISION_CONCURRENCY)
await Promise.all(prepared.map((version) => gate.run(() => provision(version))))
// Notify completion
host.post({
@@ -128,12 +147,13 @@ interface VersionSpec {
sandbox: boolean | undefined
}
/** Create one version's worktree + session and wire it into state and the webview. */
async function createVersion(
ctx: ProjectContext,
host: MultiVersionHost,
spec: VersionSpec,
): Promise<CreatedVersion | null> {
interface PreparedVersion {
spec: VersionSpec
wt: NonNullable<Awaited<ReturnType<MultiVersionHost["createOnDisk"]>>>
}
/** Create one version's worktree while the shared-repository Git barrier is active. */
async function prepareVersion(host: MultiVersionHost, spec: VersionSpec): Promise<PreparedVersion | null> {
host.log(`Creating worktree ${spec.index + 1}/${spec.versions}`)
const version = versionedName(spec.branchName || spec.worktreeName, spec.index, spec.versions)
@@ -148,6 +168,16 @@ async function createVersion(
host.log(`Failed to create worktree for version ${spec.index + 1}`)
return null
}
return { spec, wt }
}
/** Set up one prepared worktree, create its session, and expose it to the UI. */
async function provisionVersion(
ctx: ProjectContext,
host: MultiVersionHost,
prepared: PreparedVersion,
): Promise<CreatedVersion | null> {
const { spec, wt } = prepared
await host.runSetup(wt.result.path, wt.result.branch, wt.worktree.id)
@@ -240,11 +270,11 @@ async function reconcileSandbox(
}
}
/** Fan the initial prompt out to every created session, throttled between sends. */
async function sendInitialPrompts(
/** Send one version's initial prompt as soon as its session is ready. */
function sendInitialPrompt(
host: MultiVersionHost,
projectId: string,
created: CreatedVersion[],
created: CreatedVersion,
models: VersionSpec["models"],
resolved: { providerID: string | undefined; modelID: string | undefined },
input: {
@@ -253,22 +283,16 @@ async function sendInitialPrompts(
variant: string | undefined
files: Extract<AgentManagerInMessage, { type: "agentManager.createMultiVersion" }>["files"]
},
): Promise<void> {
const messages = buildInitialMessages(created, models, resolved, input.text, input.agent, input.variant, input.files)
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
if (input.text) {
host.log(`Sending initial message to version ${i + 1} (session=${msg.sessionId})`)
host.promptName({
sessionID: msg.sessionId,
text: input.text,
providerID: msg.providerID,
modelID: msg.modelID,
})
}
host.post({ type: "agentManager.sendInitialMessage", projectId, ...msg })
if (input.text && i < messages.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 300))
}
): void {
const msg = buildInitialMessages([created], models, resolved, input.text, input.agent, input.variant, input.files)[0]!
if (input.text) {
host.log(`Sending initial message to version ${created.versionIndex + 1} (session=${msg.sessionId})`)
host.promptName({
sessionID: msg.sessionId,
text: input.text,
providerID: msg.providerID,
modelID: msg.modelID,
})
}
host.post({ type: "agentManager.sendInitialMessage", projectId, ...msg })
}
@@ -299,6 +299,7 @@ interface BranchesMessage {
interface ImportResultMessage {
type: "agentManager.importResult"
projectId?: string
success: boolean
message: string
errorCode?: WorktreeSetupErrorCode
@@ -21,23 +21,26 @@ export class WorktreeImporter {
constructor(private readonly host: WorktreeImporterHost) {}
async branches(): Promise<void> {
async branches(projectId?: string): Promise<void> {
const manager = this.host.manager()
if (!manager) {
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" })
return
}
try {
const result = await manager.listBranches()
const state = this.host.state()
const configured = state?.getDefaultBaseBranch()
const base =
configured && result.branches.some((branch) => branch.name === configured) ? configured : result.defaultBranch
void manager.prefetchBase(base).catch((err) => this.host.log("Failed to prefetch base branch:", err))
const checked = await manager.checkedOutBranches()
const branches = result.branches.map((branch) => ({
...branch,
isCheckedOut: checked.has(branch.name),
}))
const state = this.host.state()
const configured = state?.getDefaultBaseBranch()
if (state && configured && !branches.some((branch) => branch.name === configured)) {
this.host.log(`Default base branch "${configured}" no longer exists, clearing`)
state.setDefaultBaseBranch(undefined)
@@ -46,31 +49,32 @@ export class WorktreeImporter {
this.host.post({
type: "agentManager.branches",
projectId,
branches,
defaultBranch: result.defaultBranch,
})
} catch (error) {
this.host.log(`Failed to list branches: ${error}`)
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" })
}
}
async branch(branch: string): Promise<void> {
await this.run({ branch })
async branch(branch: string, projectId?: string): Promise<void> {
await this.run({ branch }, projectId)
}
async pr(url: string): Promise<void> {
await this.run({ url })
async pr(url: string, projectId?: string): Promise<void> {
await this.run({ url }, projectId)
}
private async run(target: { branch: string } | { url: string }): Promise<void> {
private async run(target: { branch: string } | { url: string }, projectId?: string): Promise<void> {
const manager = this.host.manager()
const state = this.host.state()
if (!manager || !state) {
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
this.host.post({ type: "agentManager.importResult", projectId, success: false, message: "Not a git repository" })
return
}
if (this.busy()) return
if (this.busy(projectId)) return
this.importing = true
const branch = "branch" in target
const creating = branch ? "Creating worktree from branch..." : "Resolving PR..."
@@ -79,7 +83,7 @@ export class WorktreeImporter {
? `Branch "${target.branch}" is already checked out in another worktree`
: "This PR's branch is already checked out in another worktree"
try {
const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const
const progress = { type: "agentManager.worktreeSetup", projectId, status: "creating" } as const
this.host.post({ ...progress, message: creating })
const result = branch
? await manager.createWorktree({ existingBranch: target.branch })
@@ -102,7 +106,7 @@ export class WorktreeImporter {
state.addSession(session.id, worktree.id)
this.host.register(session.id, result.path)
this.host.ready(session.id, result, worktree.id)
this.host.post({ type: "agentManager.importResult", success: true, message: success })
this.host.post({ type: "agentManager.importResult", projectId, success: true, message: success })
this.host.log(`${log} as worktree ${worktree.id}`)
} catch (error) {
state.removeWorktree(worktree.id)
@@ -111,27 +115,28 @@ export class WorktreeImporter {
throw error
}
} catch (error) {
this.importError(error, duplicate)
this.importError(error, duplicate, projectId)
} finally {
this.importing = false
}
}
private busy(): boolean {
private busy(projectId?: string): boolean {
if (!this.importing) return false
this.host.post({
type: "agentManager.importResult",
projectId,
success: false,
message: "Another import is already in progress",
})
return true
}
private importError(error: unknown, duplicate: string): void {
private importError(error: unknown, duplicate: string, projectId?: string): void {
const raw = error instanceof Error ? error.message : String(error)
const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw
const code = classifyWorktreeError(message)
this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code })
this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code })
this.host.post({ type: "agentManager.worktreeSetup", projectId, status: "error", message, errorCode: code })
this.host.post({ type: "agentManager.importResult", projectId, success: false, message, errorCode: code })
}
}
+19 -1
View File
@@ -26,6 +26,7 @@ import { RemoteStatusService } from "./services/RemoteStatusService"
import { markWorkspace } from "./util/spotlight"
import { createNotebookBridge } from "./services/notebook"
import { createGitExecutable } from "./util/git-executable"
import { isCursorHost } from "./utils"
let agentManager: AgentManagerProvider | undefined
let shuttingDown = false
@@ -48,6 +49,10 @@ export function activate(context: vscode.ExtensionContext) {
console.log("Kilo Code extension is now active")
shuttingDown = false
// Drives the "!kilo-code.new.isCursor" guards on the native view/title and
// editor/title menu contributions — see isCursorHost() for why.
void vscode.commands.executeCommand("setContext", "kilo-code.new.isCursor", isCursorHost())
const telemetry = TelemetryProxy.getInstance()
// Create shared connection service (one server for all webviews)
@@ -140,7 +145,12 @@ export function activate(context: vscode.ExtensionContext) {
// The terminal intercepts all keystrokes unless the command is listed in
// terminal.integrated.commandsToSkipShell, which only contains built-in
// commands by default.
const skip = ["kilo-code.new.agentManagerOpen", "kilo-code.new.agentManager.showTerminal"]
const skip = [
"kilo-code.new.agentManagerOpen",
"kilo-code.new.agentManager.showTerminal",
"kilo-code.new.agentManager.previousTerminal",
"kilo-code.new.agentManager.nextTerminal",
]
if (process.platform === "darwin") skip.push("kilo-code.new.agentManager.runScript")
ensureCommandsSkipShell(skip)
@@ -232,6 +242,7 @@ export function activate(context: vscode.ExtensionContext) {
deserializeWebviewPanel(panel: vscode.WebviewPanel) {
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context, {
tabTitle: panelTitleHandler(panel),
topBarSurface: "tab",
})
tabProvider.setRemoteService(remoteService)
tabProvider.setAutoApproveController(autoApprove)
@@ -476,6 +487,12 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "tabNext" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.previousTerminal", () => {
agentManagerProvider.postMessage({ type: "action", action: "terminalPrevious" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTerminal", () => {
agentManagerProvider.postMessage({ type: "action", action: "terminalNext" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.search", () => {
agentManagerProvider.postMessage({ type: "action", action: "search" })
}),
@@ -627,6 +644,7 @@ function openKiloInNewTab(
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context, {
tabTitle: panelTitleHandler(panel),
topBarSurface: "tab",
})
tabProvider.setRemoteService(remoteService)
tabProvider.setAutoApproveController(autoApprove)
@@ -13,12 +13,18 @@ export async function loadCommands(client: KiloClient, dir: string): Promise<unk
const promise = retry(() => client.command.list({ directory: dir }, { throwOnError: true })).then(({ data }) => ({
type: "commandsLoaded",
commands: data.map((cmd) => ({
name: cmd.name,
description: cmd.description,
source: cmd.source,
hints: cmd.hints,
})),
commands: data.map((cmd) => {
const item = cmd as typeof cmd & { variant?: string }
return {
name: item.name,
description: item.description,
agent: item.agent,
model: item.model,
variant: item.variant,
source: item.source,
hints: item.hints,
}
}),
}))
promises.set(dir, promise)
@@ -6,6 +6,7 @@ import type { SuggestionContext } from "./handlers/suggestion"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { buildChatSettingsMessage } from "./chat-settings"
import { buildThroughputSettingMessage } from "./throughput-settings"
import { handleModelUsageMessage, type ModelUsageMessage } from "./model-usage"
type Ctx = {
question: SuggestionContext
@@ -18,6 +19,7 @@ type Ctx = {
copy: (text: string) => PromiseLike<void>
openSessions: (ids: string[]) => void
speechToTextModels: () => Promise<void>
modelUsage: (message: ModelUsageMessage) => Promise<void>
}
export async function routeEarlyMessage(
@@ -42,6 +44,10 @@ export async function routeEarlyMessage(
)
return true
}
if (message.type === "recordModelUsage" || message.type === "requestModelUsage") {
await ctx.modelUsage(message as ModelUsageMessage)
return true
}
await routeSuggestionWebviewMessage(ctx.question, message)
if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true
if (message.type === "exportSessionTranscript") {
@@ -0,0 +1,65 @@
const LIMIT = 200
export type ModelUsageMap = Record<string, { count: number; lastUsed: number }>
export type ModelUsageMessage =
| { type: "recordModelUsage"; providerID: string; modelID: string }
| { type: "requestModelUsage" }
function valid(value: unknown): value is { count: number; lastUsed: number } {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
const item = value as Record<string, unknown>
const count = item.count
const lastUsed = item.lastUsed
return (
typeof count === "number" &&
Number.isFinite(count) &&
count > 0 &&
typeof lastUsed === "number" &&
Number.isFinite(lastUsed)
)
}
export function validateModelUsage(raw: unknown): ModelUsageMap {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
const entries = Object.entries(raw as Record<string, unknown>).flatMap(([key, value]) =>
valid(value) ? [[key, value] as const] : [],
)
return Object.fromEntries(
entries
.sort(([, a], [, b]) => b.lastUsed - a.lastUsed)
.slice(0, LIMIT)
.map(([key, value]) => [
key,
{
count: Math.floor(value.count),
lastUsed: value.lastUsed,
},
]),
)
}
export function recordModelUsage(raw: unknown, providerID: unknown, modelID: unknown, now = Date.now()): ModelUsageMap {
if (typeof providerID !== "string" || !providerID || typeof modelID !== "string" || !modelID) {
return validateModelUsage(raw)
}
const usage = validateModelUsage(raw)
const key = `${providerID}/${modelID}`
const current = usage[key] ?? { count: 0, lastUsed: 0 }
usage[key] = { count: current.count + 1, lastUsed: now }
return validateModelUsage(usage)
}
export async function handleModelUsageMessage(
message: ModelUsageMessage,
context:
| { globalState: { get: (key: string) => unknown; update: (key: string, value: unknown) => Thenable<void> } }
| undefined,
post: (message: unknown) => void,
): Promise<void> {
const current = context?.globalState.get("modelUsage")
const usage =
message.type === "recordModelUsage"
? recordModelUsage(current, message.providerID, message.modelID)
: validateModelUsage(current)
if (message.type === "recordModelUsage") await context?.globalState.update("modelUsage", usage)
post({ type: "modelUsageLoaded", usage })
}
@@ -32,4 +32,13 @@ export type KiloProviderOptions = {
* is ambiguous.
*/
projectQualifier?: () => { projectId: string } | undefined
/**
* Hides the in-webview sidebar top bar (New Task, History, Agent Manager,
* etc.) for dedicated single-purpose panels Settings, Profile, and the
* Sub-Agent Viewer where it doesn't apply and would let users navigate
* away from the panel's one job. Sidebar and "Open in Tab" leave this unset.
*/
hideTopBar?: boolean
/** Reports "Open in Tab" as the top bar's telemetry surface instead of the sidebar default. */
topBarSurface?: "tab"
}
@@ -15,6 +15,7 @@ interface Ctx {
openAgentManager: () => Thenable<unknown>
openAdvancedWorktree: () => Thenable<unknown>
openChanges: (sessionId?: string, turnId?: string) => Thenable<unknown>
openProfile: () => Thenable<unknown>
currentSessionId?: string
createWorktree?: (baseBranch?: string, branchName?: string) => Promise<void>
continueInWorktree?: (
@@ -59,6 +60,11 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) {
return true
}
if (message.type === "openProfilePanel") {
await ctx.openProfile()
return true
}
if (message.type !== "continueInWorktree") return false
handleContinueInWorktree({
sessionId: message.sessionId,
@@ -24,7 +24,7 @@ type Recording = Input & {
type Audio = {
data: string
format: "wav"
format: "m4a"
model: string
language?: string
}
@@ -39,12 +39,10 @@ ObjC.import("AVFoundation")
ObjC.import("Foundation")
function run(args) {
const settings = $.NSMutableDictionary.alloc.init
settings.setObjectForKey($.NSNumber.numberWithUnsignedInt(1819304813), $.AVFormatIDKey)
settings.setObjectForKey($.NSNumber.numberWithUnsignedInt(1633772320), $.AVFormatIDKey)
settings.setObjectForKey($.NSNumber.numberWithDouble(16000), $.AVSampleRateKey)
settings.setObjectForKey($.NSNumber.numberWithInt(1), $.AVNumberOfChannelsKey)
settings.setObjectForKey($.NSNumber.numberWithInt(16), $.AVLinearPCMBitDepthKey)
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsFloatKey)
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsBigEndianKey)
settings.setObjectForKey($.NSNumber.numberWithInt(24000), $.AVEncoderBitRateKey)
const error = Ref()
const url = $.NSURL.fileURLWithPath(args[0])
const recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, error)
@@ -68,7 +66,7 @@ export async function startSpeechCapture(input: Input): Promise<boolean> {
starting = input.requestId
try {
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.m4a`)
if (useMacCapture(process.platform, process.env)) {
const result = await startMac(file, input).catch((err: unknown) => {
console.warn("[Kilo New] Native macOS speech capture failed, falling back to FFmpeg", err)
@@ -105,7 +103,7 @@ export async function stopSpeechCapture(requestId: string): Promise<Audio> {
const file = await readFile(state.file)
await removeFile(state.file)
return { data: file.toString("base64"), format: "wav", model: state.model, language: state.language }
return { data: file.toString("base64"), format: "m4a", model: state.model, language: state.language }
}
export async function cancelSpeechCapture(requestId: string): Promise<void> {
@@ -168,6 +166,35 @@ export function macCaptureArgs(file: string): string[] {
return ["-l", "JavaScript", "-e", macScript, file]
}
export function ffmpegCaptureArgs(input: string[], file: string): string[] {
return ["-y", ...input, "-c:a", "aac", "-b:a", "24k", "-ar", "16000", "-ac", "1", "-movflags", "+faststart", file]
}
export function ffmpegPipeArgs(file: string): string[] {
return [
"-y",
"-f",
"s16le",
"-ar",
"16000",
"-ac",
"1",
"-i",
"pipe:0",
"-c:a",
"aac",
"-b:a",
"24k",
"-ar",
"16000",
"-ac",
"1",
"-movflags",
"+faststart",
file,
]
}
export function useMacCapture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
return platform === "darwin" && !env.KILO_FFMPEG_PATH && !env.FFMPEG_PATH
}
@@ -178,7 +205,7 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
const proc = first.pipe
? pipeProcess(first.pipe, bin, file)
: spawn(bin, ["-y", ...first.input, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
: spawn(bin, ffmpegCaptureArgs(first.input, file), {
stdio: ["pipe", "ignore", "pipe"],
})
const state = createState(input, file, proc)
@@ -212,32 +239,9 @@ function createState(input: Input, file: string, proc: ChildProcess): Recording
function pipeProcess(pipe: string[], bin: string, file: string): ChildProcess {
const source = spawn("pw-record", pipe, { stdio: ["ignore", "pipe", "pipe"] })
const proc = spawn(
bin,
[
"-y",
"-f",
"s16le",
"-ar",
"16000",
"-ac",
"1",
"-i",
"pipe:0",
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
"-f",
"wav",
file,
],
{
stdio: ["pipe", "ignore", "pipe"],
},
)
const proc = spawn(bin, ffmpegPipeArgs(file), {
stdio: ["pipe", "ignore", "pipe"],
})
if (source.stdout && proc.stdin) source.stdout.pipe(proc.stdin)
source.on("error", (err) => proc.emit("error", err))
+25 -1
View File
@@ -18,6 +18,27 @@ export function getWebviewFontSize(): number {
return clamp(raw)
}
/**
* True when running inside Cursor rather than real VS Code (or another
* fork). Cursor's Secondary Side Bar support is known to be unreliable for
* extension-contributed `view/title` toolbars (see
* https://github.com/anthropics/claude-code/issues/31375 for the same class
* of bug in a different extension), so Cursor falls back to an in-webview
* navigation bar instead of the native toolbar that VS Code renders fine
* everywhere.
*
* This is a per-host choice, not a per-dock-location one: `WebviewView` (and
* the rest of the public API, checked against @types/vscode) exposes no way
* to ask "is my view currently in the primary or secondary side bar", so the
* webview fallback bar is Cursor's only option in both locations. Accepted
* trade-off: Cursor's primary side bar loses the single-line native look it
* had before this existed, in exchange for the Secondary Side Bar actually
* working, with zero guessing about dock position anywhere.
*/
export function isCursorHost(): boolean {
return vscode.env.appName.toLowerCase().includes("cursor")
}
function fontStyle(): string {
const base = getWebviewFontSize()
const vars = SIZES.map((size) => `--kilo-font-size-${size}: ${(base * size) / 13}px;`).join("\n ")
@@ -41,6 +62,9 @@ export function buildWebviewHtml(
title: string
port?: number
extraStyles?: string
/** Sidebar top bar visibility and telemetry surface for the shared webview bundle (App.tsx). Unused by the Agent Manager bundle. */
topBar?: boolean
topBarSurface?: string
},
): string {
const nonce = getNonce()
@@ -83,7 +107,7 @@ export function buildWebviewHtml(
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}";</script>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}"; window.KILO_TOP_BAR = ${opts.topBar !== false}; window.KILO_TOP_BAR_SURFACE = "${opts.topBarSurface ?? "sidebar_title"}";</script>
<script nonce="${nonce}" src="${opts.scriptUri}"></script>
</body>
</html>`
@@ -80,17 +80,18 @@ test("auto efficient details show server description and model choices", async (
await expect(preview).not.toContainText("openai/gpt-5.5")
})
test("typing a provider initial moves the active descendant to matching results", async ({ page }) => {
test("search uses a flat relevance-ranked result list with provider labels", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
await combobox.fill("N")
await combobox.fill("nov")
const nova = page.getByRole("treeitem", { name: "Nova" })
await expect(nova).toBeVisible()
await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id"))
await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveAttribute("aria-expanded", "true")
await expect(page.locator(".model-selector-group-label").filter({ hasText: "NVIDIA" })).toHaveCount(0)
await expect(nova).toContainText("NVIDIA")
})
test("provider groups collapse, expand, and skip their model rows", async ({ page }) => {
@@ -141,12 +142,13 @@ test("active descendant always identifies a visible tree item", async ({ page })
await active()
await combobox.fill("N")
await active()
await combobox.press("ArrowLeft")
await combobox.press("ArrowDown")
await combobox.press("ArrowLeft")
await active()
await combobox.fill("no matching model")
await active()
await expect(combobox).toHaveAttribute(
"aria-activedescendant",
await page.getByRole("treeitem", { name: "Use default model" }).getAttribute("id"),
)
})
test("expanded preview waits for explicit pointer selection", async ({ page }) => {
@@ -23,6 +23,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"),
@@ -49,6 +50,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"),
path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"),
path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"),
@@ -63,6 +65,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/diff-viewer/BaseBranchPicker.tsx"),
]
const TSX_FILE = TSX_FILES[0]!
const KEYBIND_DEFAULTS_FILE = path.join(ROOT, "webview-ui/agent-manager/keybind-defaults.ts")
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
const DIFF_CONTROLLER_FILE = path.join(ROOT, "src/agent-manager/worktree-diff-controller.ts")
const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts")
@@ -364,7 +367,6 @@ describe("Agent Manager Worktree Actions", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")) as {
contributes: { keybindings: { command: string; key?: string; mac?: string }[] }
}
const source = fs.readFileSync(TSX_FILE, "utf-8")
const dialog = manifest.contributes.keybindings.find(
(item) => item.command === "kilo-code.new.agentManager.newWorktree",
)
@@ -374,8 +376,31 @@ describe("Agent Manager Worktree Actions", () => {
expect(dialog).toMatchObject({ key: "ctrl+n", mac: "cmd+n" })
expect(quick).toMatchObject({ key: "ctrl+shift+n", mac: "cmd+shift+n" })
expect(source).toContain('newWorktree: isMac ? "⌘N" : "Ctrl+N"')
expect(source).toContain('quickWorktree: isMac ? "⌘N" : "Ctrl+Shift+N"')
const bindings = fs.readFileSync(KEYBIND_DEFAULTS_FILE, "utf-8")
expect(bindings).toContain('newWorktree: isMac ? "⌘N" : "Ctrl+N"')
expect(bindings).toContain('quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N"')
})
it("reserves Cmd+Shift+M for the Agent Manager instead of Problems", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")) as {
contributes: { keybindings: { command: string; key?: string; mac?: string }[] }
}
const removed = manifest.contributes.keybindings.find((item) => item.command === "-workbench.actions.view.problems")
const manager = manifest.contributes.keybindings.find((item) => item.command === "kilo-code.new.agentManagerOpen")
expect(removed).toMatchObject({ key: "ctrl+shift+m", mac: "cmd+shift+m" })
expect(manager).toMatchObject({ key: "ctrl+shift+m", mac: "cmd+shift+m" })
})
it("creates side terminals only while a side terminal owns focus", () => {
const source = fs.readFileSync(TSX_FILE, "utf-8")
const start = source.indexOf('else if (msg.action === "newTerminal")')
const end = source.indexOf('else if (msg.action === "cycleAgentMode"', start)
const action = source.slice(start, end)
expect(action).toContain("if (terms.sideFocusedId()) termHandlers.addSide()")
expect(action).not.toContain("terminalVisible()")
expect(action).toContain("else termHandlers.requestNew()")
})
it("forwards the quick-worktree command to immediate creation", () => {
@@ -436,7 +461,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
const lifecycle = source.getProject().addSourceFileAtPath(path.join(ROOT, "src/agent-manager", module))
const fn = lifecycle.getFunction(delegated[1]!)
expect(fn, `delegated function ${delegated[1]} not found in ${module}`).toBeTruthy()
// The multi-version flow spans phase helpers (createVersion, sendInitialPrompts),
// The multi-version flow spans prepare, provision, and initial-prompt helpers,
// so ordering assertions need the whole module, not just the orchestrator.
if (delegated[1] === "createMultiVersion") return lifecycle.getText()
return fn!.getText()
@@ -690,7 +715,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
it("worktree import behavior lives in the cohesive importer", () => {
const text = importer()
for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value)
for (const value of ["createFromPR", "createWorktree", "this.busy(projectId)"]) expect(text).toContain(value)
expect(body("onImportMessage")).toContain("this.importer")
})
@@ -842,7 +867,7 @@ describe("KiloProvider — pending session refresh on reconnect", () => {
// ---------------------------------------------------------------------------
describe("Agent Manager — dialog listener cleanup", () => {
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"), "utf-8")
/**
* Regression: handleChangeDefaultBaseBranch subscribes to vscode.onMessage
@@ -850,34 +875,20 @@ describe("Agent Manager — dialog listener cleanup", () => {
* and the Escape keydown handler. If the dialog closed via backdrop click or
* external dialog.close(), the listener leaked and stacked on every reopen.
*
* The fix ties unsub() to Solid's onCleanup inside the dialog.show() render
* function so it always disposes regardless of how the dialog closes.
* The fix ties unsub() to the dialog component's Solid cleanup so it always
* disposes regardless of how the dialog closes.
*/
it("handleChangeDefaultBaseBranch uses onCleanup(unsub) inside dialog.show", () => {
const fnStart = tsx.indexOf("const handleChangeDefaultBaseBranch")
expect(fnStart, "handleChangeDefaultBaseBranch must exist").toBeGreaterThan(-1)
// Grab the function body (enough to cover the dialog.show callback)
const snippet = tsx.slice(fnStart, fnStart + 2000)
// The dialog.show callback must register onCleanup(unsub)
const showIdx = snippet.indexOf("dialog.show(")
expect(showIdx, "dialog.show() call must exist").toBeGreaterThan(-1)
const afterShow = snippet.slice(showIdx)
expect(afterShow, "onCleanup(unsub) must be inside dialog.show callback").toContain("onCleanup(unsub)")
it("DefaultBaseBranchDialog disposes its message listener on cleanup", () => {
expect(tsx).toContain("const unsub = vscode.onMessage")
expect(tsx).toContain("onCleanup(unsub)")
})
it("selectBranch does not manually call unsub (handled by onCleanup)", () => {
const fnStart = tsx.indexOf("const handleChangeDefaultBaseBranch")
const snippet = tsx.slice(fnStart, fnStart + 2000)
// Find the selectBranch function body
const selStart = snippet.indexOf("const selectBranch")
expect(selStart, "selectBranch must exist").toBeGreaterThan(-1)
const selEnd = snippet.indexOf("}", selStart + 50)
const selBody = snippet.slice(selStart, selEnd + 1)
expect(selBody, "selectBranch should not call unsub() directly").not.toContain("unsub()")
it("select does not manually call unsub (handled by onCleanup)", () => {
const selStart = tsx.indexOf("const select =")
expect(selStart, "select must exist").toBeGreaterThan(-1)
const selEnd = tsx.indexOf("}", selStart + 40)
const selBody = tsx.slice(selStart, selEnd + 1)
expect(selBody, "select should not call unsub() directly").not.toContain("unsub()")
})
})
@@ -0,0 +1,58 @@
import { describe, expect, it } from "bun:test"
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
const root = join(__dirname, "..", "..")
const dialog = readFileSync(join(root, "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8")
const app = readFileSync(join(root, "webview-ui", "agent-manager", "AgentManagerApp.tsx"), "utf8")
const pending = readFileSync(join(root, "webview-ui", "agent-manager", "pending-create.ts"), "utf8")
const importer = readFileSync(join(root, "src", "agent-manager", "worktree-importer.ts"), "utf8")
const css = readFileSync(join(root, "webview-ui", "agent-manager", "agent-manager.css"), "utf8")
describe("Agent Manager New Worktree project targeting", () => {
it("routes dialog operations through the selected project and rejects stale responses", () => {
expect(dialog).toContain("const [project, setProject]")
expect(dialog).toContain("if (ev.projectId !== project()) return")
expect(dialog).toContain('type: "agentManager.requestBranches", projectId: id')
expect(dialog).toContain('type: "agentManager.createMultiVersion"')
expect(dialog).toContain("projectId: target")
expect(dialog).toContain('type: "agentManager.importFromPR"')
expect(dialog).toContain('type: "agentManager.importFromBranch"')
})
it("does not replace a pending cross-project activation", () => {
expect(pending).toContain("if (pending()) return")
expect(app).toContain("usePendingCreate(activeProjectId")
expect(app).toContain('msg.type === "agentManager.importResult"')
expect(app).toContain("!msg.success) creation.abandon(msg.projectId)")
})
it("tags branch and import responses with their owning project", () => {
expect(importer).toContain("async branches(projectId?: string)")
expect(importer).toContain('type: "agentManager.branches", projectId')
expect(importer).toContain('type: "agentManager.importResult", projectId')
expect(importer).toContain('type: "agentManager.worktreeSetup", projectId')
})
it("keeps the project picker aligned with the dialog selector system", () => {
expect(css).toContain(".am-nv-project-inline")
expect(css).toContain(".am-project-option")
expect(css).toContain('[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"])')
})
it("defines project labels in every Agent Manager locale", () => {
const keys = [
"agentManager.dialog.project.select",
"agentManager.dialog.project.untrusted",
"agentManager.dialog.project.missing",
]
const locales = readdirSync(join(root, "webview-ui", "agent-manager", "i18n")).filter((file) =>
file.endsWith(".ts"),
)
for (const file of locales) {
const source = readFileSync(join(root, "webview-ui", "agent-manager", "i18n", file), "utf8")
for (const key of keys) expect(source, `${file} is missing ${key}`).toContain(`"${key}"`)
}
})
})
@@ -5,6 +5,10 @@ import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "../../webview-ui/
const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8")
const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8")
const terminal = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"),
"utf8",
)
test("xterm owns the padding used by FitAddon", () => {
const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1]
@@ -23,6 +27,18 @@ test("uses one persisted width for the diff and terminal inspector", () => {
expect(app).not.toContain("terminalWidth")
})
test("limits inspector layout updates during resize", () => {
expect(app).toContain("SIDE_RESIZE_INTERVAL_MS = 32")
expect(app).toContain("time - sideResizeTime < SIDE_RESIZE_INTERVAL_MS")
})
test("does not refit hidden terminal buffers during resize", () => {
const callback = terminal.match(/const ro = new ResizeObserver\(\(\) => \{([\s\S]*?)\n \}\)/)?.[1]
expect(callback).toBeDefined()
expect(callback).toContain("if (!props.active) return")
expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()"))
})
test("clamps the restored inspector width to the shared layout bounds", () => {
expect(clampPanelWidth(undefined, 1200)).toBe(600)
expect(clampPanelWidth(500, 1200)).toBe(500)
@@ -12,6 +12,8 @@ function scene(
saved?: "vscode" | "agentManager"
visible?: boolean
focusedId?: string
count?: number
script?: boolean
mac?: boolean
} = {},
) {
@@ -33,6 +35,7 @@ function scene(
requestSide: () => {
calls.requestSide++
visible = true
focusedId ??= "terminal:side"
},
ensureSide: () => calls.ensureSide++,
closeSide: (terminalId) => {
@@ -43,6 +46,8 @@ function scene(
},
visible: () => visible,
focusedId: () => focusedId,
count: () => opts.count ?? 2,
isScript: () => opts.script ?? false,
hide: () => {
calls.hide++
visible = false
@@ -60,7 +65,7 @@ function scene(
}
describe("Agent Manager side terminal controller", () => {
it("toggles the panel and hands focus to the chat only when the terminal had it", () => {
it("toggles the panel, focusing a visible terminal before hiding it", () => {
const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" })
focused.ctl.toggle()
expect(focused.calls.hide).toBe(1)
@@ -68,7 +73,8 @@ describe("Agent Manager side terminal controller", () => {
const elsewhere = scene({ destination: "agentManager", visible: true })
elsewhere.ctl.toggle()
expect(elsewhere.calls.hide).toBe(1)
expect(elsewhere.calls.requestSide).toBe(1)
expect(elsewhere.calls.hide).toBe(0)
expect(elsewhere.calls.refocus).toBe(0)
const hidden = scene({ destination: "agentManager", visible: false })
@@ -77,6 +83,23 @@ describe("Agent Manager side terminal controller", () => {
expect(hidden.calls.hide).toBe(0)
})
it("toggles panel visibility from toolbar button without requiring focus", () => {
const visibleUnfocused = scene({ destination: "agentManager", visible: true })
visibleUnfocused.ctl.openPreferred("tab_toolbar")
expect(visibleUnfocused.calls.hide).toBe(1)
expect(visibleUnfocused.calls.requestSide).toBe(0)
const visibleFocused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" })
visibleFocused.ctl.openPreferred("tab_toolbar")
expect(visibleFocused.calls.hide).toBe(1)
expect(visibleFocused.calls.requestSide).toBe(0)
const hidden = scene({ destination: "agentManager", visible: false })
hidden.ctl.openPreferred("tab_toolbar")
expect(hidden.calls.requestSide).toBe(1)
expect(hidden.calls.hide).toBe(0)
})
it("ensures an open terminal panel has a terminal after switching contexts", async () => {
const visible = scene({ visible: true })
visible.ctl.syncContext("wt-2", "wt-1")
@@ -93,18 +116,32 @@ describe("Agent Manager side terminal controller", () => {
hidden.ctl.syncContext("wt-2", "wt-1")
expect(hidden.calls.ensureSide).toBe(0)
const closed = scene({ visible: true })
const closed = scene({ visible: true, focusedId: "terminal:side" })
closed.ctl.syncContext("wt-2", "wt-1")
closed.ctl.toggle()
await Promise.resolve()
expect(closed.calls.ensureSide).toBe(0)
})
it("kills the focused terminal and refocuses the chat", () => {
it("closes the focused terminal without stealing focus from its survivor", () => {
const focused = scene({ focusedId: "terminal:two" })
expect(focused.ctl.close()).toBe(true)
expect(focused.calls.closed).toEqual(["terminal:two"])
expect(focused.calls.refocus).toBe(1)
expect(focused.calls.refocus).toBe(0)
})
it("hides instead of killing the last or provider-owned terminal", () => {
const last = scene({ focusedId: "terminal:last", count: 1 })
expect(last.ctl.close()).toBe(true)
expect(last.calls.closed).toEqual([])
expect(last.calls.hide).toBe(1)
expect(last.calls.refocus).toBe(1)
const script = scene({ focusedId: "script:run", script: true, count: 2 })
expect(script.ctl.close()).toBe(true)
expect(script.calls.closed).toEqual([])
expect(script.calls.hide).toBe(1)
expect(script.calls.refocus).toBe(1)
})
it("does nothing on close without a focused terminal", () => {
@@ -18,6 +18,7 @@ function scene(initial: string | null = LOCAL) {
const events = {
activated: [] as string[],
selected: [] as string[],
cleared: 0,
saved: 0,
shown: [] as string[],
errors: 0,
@@ -29,7 +30,7 @@ function scene(initial: string | null = LOCAL) {
tabIds: tabs,
selectReview: () => undefined,
selectSessionTab: () => undefined,
clearSession: () => undefined,
clearSession: () => events.cleared++,
resetOthers: () => undefined,
isPendingId: () => false,
findTab: () => undefined,
@@ -401,6 +402,69 @@ describe("Agent Manager terminal state", () => {
})
})
it("cycles side terminals in both directions and wraps", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
item.state.setSideActive(LOCAL, "terminal:one")
expect(item.handlers.cycle("next", "side")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
expect(item.state.focusRequest()?.id).toBe("terminal:two")
expect(item.handlers.cycle("next", "side")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
expect(item.handlers.cycle("previous", "side")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
dispose()
})
})
it("cycles main terminal tabs independently from side terminals", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "tab" })
item.state.setActiveId("terminal:one")
expect(item.handlers.cycle("next", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:two")
expect(item.handlers.cycle("next", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:one")
dispose()
})
})
it("starts terminal cycling at the boundary when no terminal is active", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "tab" })
item.state.setActiveId(undefined)
expect(item.handlers.cycle("next", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:one")
item.state.setActiveId(undefined)
expect(item.handlers.cycle("previous", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:two")
dispose()
})
})
it("keeps the session open when its last main terminal closes", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
item.state.setActiveId("terminal:one")
item.state.setFocusedId("terminal:one")
expect(item.handlers.closeFocused()).toBe(true)
expect(item.state.current()).toEqual([])
expect(item.events.cleared).toBe(0)
dispose()
})
})
it("moves activation to the last remaining side terminal on close", () => {
createRoot((dispose) => {
const item = scene()
@@ -410,6 +474,7 @@ describe("Agent Manager terminal state", () => {
expect(item.handlers.closeSide("terminal:two")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
expect(item.state.focusRequest()?.id).toBe("terminal:one")
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }])
expect(item.handlers.closeSide("terminal:one")).toBe(true)
@@ -39,6 +39,11 @@ describe("formatKeybinding", () => {
it("formats plain key", () => {
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
})
it("formats bracket keys", () => {
expect(formatKeybinding("cmd+shift+[", true)).toBe("⌘⇧[")
expect(formatKeybinding("cmd+shift+]", true)).toBe("⌘⇧]")
})
})
describe("windows/linux", () => {
@@ -77,4 +82,11 @@ describe("buildKeybindingMap", () => {
expect(buildKeybindingMap(bindings, true).search).toBe("⌘F")
expect(buildKeybindingMap(bindings, false).search).toBe("Ctrl+F")
})
it("provides terminal navigation fallbacks", () => {
expect(buildKeybindingMap([], true).previousTerminal).toBe("⌘⇧[")
expect(buildKeybindingMap([], true).nextTerminal).toBe("⌘⇧]")
expect(buildKeybindingMap([], false).previousTerminal).toBe("Ctrl+Shift+[")
expect(buildKeybindingMap([], false).nextTerminal).toBe("Ctrl+Shift+]")
})
})
@@ -13,8 +13,15 @@ describe("modelPatch", () => {
})
})
it("clears stale variant when next model does not support it", () => {
it("keeps the nearest supported effort when the exact variant is unavailable", () => {
expect(modelPatch("kilo", "anthropic/claude-sonnet-4-6", ["low", "medium"], "high")).toEqual({
model: "kilo/anthropic/claude-sonnet-4-6",
variant: "medium",
})
})
it("clears an unknown variant when next model does not support it", () => {
expect(modelPatch("kilo", "anthropic/claude-sonnet-4-6", ["low", "medium"], "thinking")).toEqual({
model: "kilo/anthropic/claude-sonnet-4-6",
variant: null,
})
@@ -13,7 +13,10 @@ import {
isAuto,
autoSummary,
autoChoices,
rankModelSearch,
mostUsedModels,
} from "../../webview-ui/src/components/shared/model-selector-utils"
import type { EnrichedModel } from "../../webview-ui/src/context/provider"
const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" }
@@ -169,6 +172,51 @@ describe("autoSummary", () => {
})
})
const SEARCH_MODELS: EnrichedModel[] = [
{ id: "solar-pro", name: "Solar Pro", providerID: "nvidia", providerName: "NVIDIA" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "openai", providerName: "OpenAI" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "kilo", providerName: "Kilo" },
{ id: "gpt-5.6", name: "GPT-5.6", providerID: "anthropic", providerName: "Anthropic" },
]
describe("rankModelSearch", () => {
it("prefers an exact model token over a longer prefix match", () => {
expect(
rankModelSearch(SEARCH_MODELS, "sol")
.slice(0, 2)
.map((model) => model.name),
).toEqual(["GPT-5.6 Sol", "GPT-5.6 Sol"])
})
it("keeps provider variants together and uses usage to order equivalent variants", () => {
const result = rankModelSearch(SEARCH_MODELS, "sol", {
usage: { "kilo/gpt-5.6-sol": { count: 4, lastUsed: 10 }, "openai/gpt-5.6-sol": { count: 1, lastUsed: 20 } },
})
expect(result.slice(0, 2).map((model) => model.providerID)).toEqual(["kilo", "openai"])
})
it("does not let usage make a weaker model beat an exact match", () => {
const result = rankModelSearch(SEARCH_MODELS, "sol", {
usage: { "nvidia/solar-pro": { count: 1000, lastUsed: 100 } },
})
expect(result[0]?.name).toBe("GPT-5.6 Sol")
})
})
describe("mostUsedModels", () => {
it("orders suggestions by personal count and excludes favorites", () => {
const result = mostUsedModels(
SEARCH_MODELS,
{
"nvidia/solar-pro": { count: 2, lastUsed: 20 },
"openai/gpt-5.6-sol": { count: 5, lastUsed: 10 },
},
new Set(["openai/gpt-5.6-sol"]),
)
expect(result.map((model) => model.providerID)).toEqual(["nvidia"])
})
})
describe("isDataCollectedModel", () => {
it("uses only explicit prompt training metadata", () => {
expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true)
@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test"
import { recordModelUsage, validateModelUsage } from "../../src/kilo-provider/model-usage"
describe("model usage history", () => {
it("increments a model and updates its last-used timestamp", () => {
expect(recordModelUsage({ "openai/gpt": { count: 2, lastUsed: 10 } }, "openai", "gpt", 20)).toEqual({
"openai/gpt": { count: 3, lastUsed: 20 },
})
})
it("drops malformed entries and caps persisted history", () => {
const raw = Object.fromEntries(
Array.from({ length: 205 }, (_, index) => [`provider/model-${index}`, { count: 1, lastUsed: index }]),
)
const result = validateModelUsage({ ...raw, invalid: { count: 0, lastUsed: 1 } })
expect(Object.keys(result)).toHaveLength(200)
expect(result["provider/model-204"]).toEqual({ count: 1, lastUsed: 204 })
expect(result.invalid).toBeUndefined()
})
})
@@ -78,6 +78,15 @@ describe("sendCommand dismisses pending tool requests", () => {
it("rejects questions before sending", () => {
expect(body).toContain("dismissQuestion")
})
it("applies model, agent, and variant overrides when provided by a command", () => {
expect(body).toContain("if (overrides?.agent)")
expect(body).toContain("selectAgent(overrides.agent, scope)")
expect(body).toContain("if (overrides?.model)")
expect(body).toContain("selectModel(parsed.providerID, parsed.modelID, scope)")
expect(body).toContain("if (overrides?.variant)")
expect(body).toContain("selectVariant(overrides.variant, scope)")
})
})
describe("static command completion contract", () => {
@@ -0,0 +1,67 @@
import { describe, expect, it, mock } from "bun:test"
import type { Session } from "@kilocode/sdk/v2/client"
import { createMultiVersion, type MultiVersionHost } from "../../src/agent-manager/provider-multi-version"
import type { ProjectContext } from "../../src/agent-manager/project/context"
import type { CreateWorktreeOnDiskResult } from "../../src/agent-manager/worktree-create"
describe("multi-version provisioning", () => {
it("finishes git creation before provisioning at bounded concurrency", async () => {
const flow: string[] = []
const gates = [Promise.withResolvers<void>(), Promise.withResolvers<void>()]
const entered = [Promise.withResolvers<void>(), Promise.withResolvers<void>()]
const state = { addSession: mock(() => {}), armAutoName: mock(() => {}) }
const ctx = {
id: "project-1",
stateManager: () => state,
peekState: () => state,
worktreeManager: () => ({ removeWorktree: mock(async () => {}) }),
} as unknown as ProjectContext
const host = {
log: mock(() => {}),
post: mock((msg: { type: string; sessionId?: string }) => {
if (msg.type === "agentManager.sendInitialMessage") flow.push(`prompt:${msg.sessionId}`)
}),
createOnDisk: mock(async (opts: { branchName?: string }) => {
const index = opts.branchName?.endsWith("_v2") ? 1 : opts.branchName?.endsWith("_v3") ? 2 : 0
flow.push(`git:${index}`)
return {
worktree: { id: `wt-${index}` },
result: { path: `/repo/wt-${index}`, branch: `branch-${index}`, parentBranch: "main" },
} as CreateWorktreeOnDiskResult
}),
runSetup: mock(async (dir: string) => {
const index = Number(dir.at(-1)!)
flow.push(`setup:${index}`)
if (index < 2) {
entered[index]?.resolve()
await gates[index]?.promise
}
}),
createSession: mock(async (dir: string) => ({ id: `session-${dir.at(-1)!}` }) as Session),
autoName: () => ({ enabled: false }),
register: mock(() => {}),
notifyReady: mock(() => {}),
sessions: { register: mock(() => {}) },
promptName: mock(() => {}),
capture: mock(() => {}),
error: mock(() => {}),
} as unknown as MultiVersionHost
const pending = createMultiVersion(ctx, host, {
type: "agentManager.createMultiVersion",
text: "Fix it",
branchName: "fix-it",
versions: 3,
})
await Promise.all(entered.map((entry) => entry.promise))
expect(flow.slice(0, 5)).toEqual(["git:0", "git:1", "git:2", "setup:0", "setup:1"])
expect(flow).not.toContain("setup:2")
gates.forEach((gate) => gate.resolve())
await pending
expect(flow).toContain("setup:2")
expect(flow.filter((event) => event.startsWith("prompt:"))).toHaveLength(3)
})
})
@@ -98,8 +98,8 @@ describe("Agent Manager sandbox startup", () => {
)
test("reconciles before exposing or prompting the session", () => {
// In createVersion the sandbox gate runs before the session is exposed.
const start = flow.indexOf("async function createVersion")
// In provisionVersion the sandbox gate runs before the session is exposed.
const start = flow.indexOf("async function provisionVersion")
const version = flow.slice(start, flow.indexOf("\n/**", start + 1))
const gate = version.indexOf("await reconcileSandbox")
const register = version.indexOf("host.register", gate)
@@ -122,8 +122,8 @@ describe("Agent Manager sandbox startup", () => {
expect(abort).toBeGreaterThan(discard)
// The created sessions feed the initial prompt phase.
const prompts = flow.slice(flow.indexOf("async function sendInitialPrompts"))
expect(prompts).toContain("buildInitialMessages(created")
const prompts = flow.slice(flow.indexOf("function sendInitialPrompt"))
expect(prompts).toContain("buildInitialMessages([created]")
expect(prompts).toContain('type: "agentManager.sendInitialMessage"')
})
@@ -3,6 +3,7 @@ import {
cycleVariant,
getAgentVariant,
getVariant,
preserveVariant,
sessionVariantKeys,
sessionVariants,
transferVariants,
@@ -121,3 +122,24 @@ describe("cycleVariant", () => {
expect(cycleVariant("low", [])).toBeUndefined()
})
})
describe("preserveVariant", () => {
it("keeps an exact variant", () => {
expect(preserveVariant("high", ["low", "high"])).toBe("high")
expect(preserveVariant("thinking", ["instant", "thinking"])).toBe("thinking")
expect(preserveVariant("default", ["default", "thinking"])).toBe("default")
})
it("falls back to the nearest supported effort", () => {
expect(preserveVariant("max", ["high", "xhigh"])).toBe("xhigh")
expect(preserveVariant("high", ["low", "medium"])).toBe("medium")
expect(preserveVariant("max", ["none", "low"])).toBe("low")
})
it("does not cross binary or custom variant families", () => {
expect(preserveVariant("thinking", ["low", "high"])).toBeUndefined()
expect(preserveVariant("instant", ["low", "high"])).toBeUndefined()
expect(preserveVariant("turbo", ["low", "high"])).toBeUndefined()
expect(preserveVariant("high", ["instant", "thinking"])).toBeUndefined()
})
})
@@ -0,0 +1,66 @@
import { describe, expect, it } from "bun:test"
import { createSessionVariants } from "../../webview-ui/src/context/session-variants"
import type { ExtensionMessage, ModelSelection } from "../../webview-ui/src/types/messages"
const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" }
function setup(session?: string) {
const selections: Record<string, string> = {}
const messages: Array<{ type: string; key?: string; value?: string }> = []
const order: string[] = []
let handler: ((message: ExtensionMessage) => void) | undefined
const variants = createSessionVariants({
selections: () => selections,
set: (key, value) => {
selections[key] = value
},
selected: () => model,
session: () => session,
agent: () => "code",
find: () => ({ variants: { low: {}, high: {} } }),
post: (message) => {
order.push("post")
messages.push(message)
},
listen: (next) => {
order.push("listen")
handler = next
return () => order.push("unsub")
},
})
return { variants, selections, messages, order, dispatch: (message: ExtensionMessage) => handler?.(message) }
}
describe("session variants", () => {
it("subscribes before requesting persisted variants and returns cleanup", () => {
const state = setup()
const unsub = state.variants.load()
expect(state.order).toEqual(["listen", "post"])
expect(state.messages).toEqual([{ type: "requestVariants" }])
unsub()
expect(state.order).toEqual(["listen", "post", "unsub"])
})
it("loads global variants without restoring stale session variants", () => {
const state = setup()
state.variants.load()
state.dispatch({
type: "variantsLoaded",
variants: { "agent/code/anthropic/claude-sonnet-4": "high", "session/old/model": "low" },
})
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
})
it("persists global selections but keeps session selections local", () => {
const global = setup()
global.variants.select("high")
expect(global.messages).toEqual([
{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "high" },
])
const scoped = setup("session-a")
scoped.variants.select("low")
expect(scoped.selections).toEqual({ "session/session-a/anthropic/claude-sonnet-4": "low" })
expect(scoped.messages).toEqual([])
})
})
@@ -1,19 +1,80 @@
import { describe, expect, it } from "bun:test"
import { cleanOutput, macCaptureArgs, parseDshowAudioDevices, useMacCapture } from "../../src/speech-to-text/capture"
import {
cleanOutput,
ffmpegCaptureArgs,
ffmpegPipeArgs,
macCaptureArgs,
parseDshowAudioDevices,
useMacCapture,
} from "../../src/speech-to-text/capture"
describe("macCaptureArgs", () => {
it("records 16 kHz mono PCM with the built-in AVFoundation bridge", () => {
const args = macCaptureArgs("/tmp/speech.wav")
it("records 16 kHz mono AAC at 24 kbps with the built-in AVFoundation bridge", () => {
const args = macCaptureArgs("/tmp/speech.m4a")
expect(args.slice(0, 3)).toEqual(["-l", "JavaScript", "-e"])
expect(args.at(-1)).toBe("/tmp/speech.wav")
expect(args.at(-1)).toBe("/tmp/speech.m4a")
expect(args[3]).toContain("AVAudioRecorder")
expect(args[3]).toContain("numberWithDouble(16000)")
expect(args[3]).toContain("numberWithUnsignedInt(1633772320), $.AVFormatIDKey")
expect(args[3]).toContain("numberWithDouble(16000), $.AVSampleRateKey")
expect(args[3]).toContain("numberWithInt(1), $.AVNumberOfChannelsKey")
expect(args[3]).toContain("numberWithInt(24000), $.AVEncoderBitRateKey")
expect(args[3]).toContain('console.log("ready")')
})
})
describe("ffmpeg args", () => {
it("builds AAC capture arguments with faststart", () => {
const args = ffmpegCaptureArgs(["-f", "avfoundation", "-i", ":default"], "/tmp/speech.m4a")
expect(args).toEqual([
"-y",
"-f",
"avfoundation",
"-i",
":default",
"-c:a",
"aac",
"-b:a",
"24k",
"-ar",
"16000",
"-ac",
"1",
"-movflags",
"+faststart",
"/tmp/speech.m4a",
])
})
it("builds pipe capture arguments for Linux PipeWire", () => {
const args = ffmpegPipeArgs("/tmp/speech.m4a")
expect(args).toEqual([
"-y",
"-f",
"s16le",
"-ar",
"16000",
"-ac",
"1",
"-i",
"pipe:0",
"-c:a",
"aac",
"-b:a",
"24k",
"-ar",
"16000",
"-ac",
"1",
"-movflags",
"+faststart",
"/tmp/speech.m4a",
])
})
})
describe("useMacCapture", () => {
it("preserves explicit FFmpeg overrides", () => {
expect(useMacCapture("darwin", {})).toBe(true)
@@ -188,4 +188,76 @@ describe("useSlashCommand sandbox action", () => {
expect(ctx.slash.results()[0]?.description).toBe("Toggle sandbox")
ctx.dispose()
})
it("opens review options from the top-level command", () => {
const ctx = setup(() => {})
const state = { text: "/review" }
const textarea = {
value: state.text,
setSelectionRange: () => {},
focus: () => {},
} as unknown as HTMLTextAreaElement
ctx.slash.onInput("/rev", 4)
expect(ctx.slash.results()).toContainEqual(
expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }),
)
ctx.slash.select(ctx.slash.results().find((c) => c.name === "review")!, textarea, (text) => (state.text = text))
expect(state.text).toBe("/review ")
expect(ctx.slash.results().map((command) => command.name)).toEqual([
"review uncommitted",
"review staged",
"review unpushed",
"review branch",
"review quick",
])
ctx.dispose()
})
it("completes nested review actions and closes for free text", () => {
const ctx = setup(() => {})
const state = { text: "/review unp" }
const textarea = {
value: state.text,
setSelectionRange: () => {},
focus: () => {},
} as unknown as HTMLTextAreaElement
ctx.slash.onInput(state.text, state.text.length)
expect(ctx.slash.results().map((command) => command.name)).toEqual(["review unpushed"])
ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text))
expect(state.text).toBe("/review unpushed ")
ctx.slash.onInput("/review focus on auth", 20)
expect(ctx.slash.show()).toBe(false)
ctx.dispose()
})
it("preserves model, agent, and variant metadata on loaded server commands", () => {
const ctx = setup(() => {})
ctx.fire({
type: "commandsLoaded",
commands: [
{
name: "ship",
description: "Ship PR",
agent: "code",
model: "openai/gpt-5.6-luna-fast",
variant: "xhigh",
hints: ["deploy"],
},
],
})
ctx.slash.onInput("/ship", 5)
const matches = ctx.slash.results()
expect(matches).toHaveLength(1)
expect(matches[0]?.name).toBe("ship")
expect(matches[0]?.agent).toBe("code")
expect(matches[0]?.model).toBe("openai/gpt-5.6-luna-fast")
expect(matches[0]?.variant).toBe("xhigh")
ctx.dispose()
})
})
@@ -21,7 +21,6 @@ import type {
AgentManagerKeybindingsMessage,
AgentManagerMultiVersionProgressMessage,
AgentManagerSendInitialMessage,
AgentManagerBranchesMessage,
AgentManagerWorktreeDiffMessage,
AgentManagerWorktreeDiffFileMessage,
AgentManagerWorktreeDiffLoadingMessage,
@@ -43,7 +42,6 @@ import type {
SectionState,
SessionInfo,
SessionCreatedMessage,
BranchInfo,
TerminalDestination,
TerminalFont,
} from "../src/types/messages"
@@ -78,6 +76,7 @@ import { ProviderShell } from "../src/context/provider-shell"
import { ChatView } from "../src/components/chat"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { DefaultBaseBranchDialog } from "./DefaultBaseBranchDialog"
import { createModeRouter } from "./mode-router"
import { ProjectList } from "./ProjectList"
import { SidebarBody } from "./SidebarBody"
@@ -152,7 +151,6 @@ import type { ReviewComment } from "../diff-viewer/review-comments"
import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
import { BranchSelect } from "../src/components/shared/BranchSelect"
import { randomColor } from "./section-colors"
import { createNewTaskDrafts } from "./new-task-drafts"
import {
@@ -180,6 +178,7 @@ import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "./side-panel-layo
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, hasQuestionOption } from "./focus"
import { usePendingCreate } from "./pending-create"
import "./agent-manager.css"
import "./agent-manager-review.css"
import { cycleAgent as cycle } from "../src/context/session-agent"
@@ -198,36 +197,10 @@ interface SetupState {
type SidebarSelection = typeof LOCAL | string | null
type SidePanel = "diff" | "pr" | "terminal" | null
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
// Fallback keybindings before extension sends resolved ones
const MAX_JUMP_INDEX = 9
const defaultBindings: Record<string, string> = {
previousSession: isMac ? "⌘⌥↑" : "Ctrl+Alt+↑",
nextSession: isMac ? "⌘⌥↓" : "Ctrl+Alt+↓",
previousTab: isMac ? "⌘⌥←" : "Ctrl+Alt+←",
nextTab: isMac ? "⌘⌥→" : "Ctrl+Alt+→",
search: isMac ? "⌘F" : "Ctrl+F",
showTerminal: isMac ? "⌘/" : "Ctrl+/",
newTerminal: isMac ? "⌘⇧T" : "Ctrl+Shift+T",
runScript: isMac ? "⌘E" : "Ctrl+E",
toggleDiff: isMac ? "⌘D" : "Ctrl+D",
showShortcuts: isMac ? "⌘⇧/" : "Ctrl+Shift+/",
newTab: isMac ? "⌘T" : "Ctrl+T",
closeTab: isMac ? "⌘W" : "Ctrl+W",
newWorktree: isMac ? "⌘N" : "Ctrl+N",
quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N",
closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W",
openWorktree: isMac ? "⌘⇧O" : "Ctrl+Shift+O",
openPR: isMac ? "⌘⇧R" : "Ctrl+Shift+R",
agentManagerOpen: isMac ? "⌘⇧M" : "Ctrl+Shift+M",
cycleAgentMode: isMac ? "⌘." : "Ctrl+.",
cyclePreviousAgentMode: isMac ? "⌘⇧." : "Ctrl+Shift+.",
...Object.fromEntries(
Array.from({ length: MAX_JUMP_INDEX }, (_, i) => [`jumpTo${i + 1}`, isMac ? `${i + 1}` : `Ctrl+${i + 1}`]),
),
}
const SIDE_RESIZE_INTERVAL_MS = 32
import { parseBindingTokens } from "./keybind-tokens"
import { defaultBindings } from "./keybind-defaults"
const AgentManagerContent: Component = () => {
const { t } = useLanguage()
@@ -266,11 +239,16 @@ const AgentManagerContent: Component = () => {
const [currentProjectId, setCurrentProjectId] = createSignal<string | undefined>()
const [projectStates, setProjectStates] = createSignal<Record<string, AgentManagerStateMessage>>({})
const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId()
const creation = usePendingCreate(activeProjectId, (projectId, worktreeId) =>
vscode.postMessage({
type: "agentManager.activateSelection",
target: { projectId, kind: "worktree", worktreeId },
}),
)
const isActivePayload = (pid: string | undefined) =>
projectList().length === 0 || pid === undefined || pid === activeProjectId()
const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main"
const hasConfiguredBranch = () => !!defaultBaseBranch()
const DEFAULT_SIDEBAR_WIDTH = 260
const MIN_SIDEBAR_WIDTH = 200
@@ -282,6 +260,14 @@ const AgentManagerContent: Component = () => {
persisted: persisted ?? {},
activeId: () => currentProjectId() ?? "single",
})
const defaultBase = (id: string) => {
const store = registry.ensure(id)
return (
store.defaultBaseBranch() ??
store.localStats()?.branch ??
(id === activeProjectId() ? repoDetectedBranch() : undefined)
)
}
const localSessionIDs = () => registry.active().tabs.ids()
const setLocalSessionIDs = (next: string[] | ((prev: string[]) => string[])) => registry.active().tabs.set(next)
/** Remove a session ID from the local tab (no-op if absent). */
@@ -308,6 +294,7 @@ const AgentManagerContent: Component = () => {
let pendingSidebarWidth: number | undefined
let sideRaf: number | undefined
let pendingSideWidth: number | undefined
let sideResizeTime = 0
const [history, setHistory] = createSignal(false)
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
@@ -323,10 +310,16 @@ const AgentManagerContent: Component = () => {
const resizeSide = (width: number) => {
pendingSideWidth = clampPanelWidth(width, window.innerWidth)
if (sideRaf !== undefined) return
sideRaf = requestAnimationFrame(() => {
const flush = (time: number) => {
if (time - sideResizeTime < SIDE_RESIZE_INTERVAL_MS) {
sideRaf = requestAnimationFrame(flush)
return
}
sideRaf = undefined
sideResizeTime = time
setPanelWidth(pendingSideWidth!)
})
}
sideRaf = requestAnimationFrame(flush)
}
const showSideTerminal = () => {
setHistory(false)
@@ -399,9 +392,16 @@ const AgentManagerContent: Component = () => {
{ defer: true },
),
)
type FocusOwner = "prompt" | { terminal: string }
const focusMemory = new Map<string, FocusOwner>()
let focusInputUntil = 0
const focusPrompt = () => {
focusInputUntil = Date.now() + 500
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
terms.setActiveId(undefined)
terms.setFocusedId(undefined)
requestChatFocus(true)
}
const focusKey = () => {
const context = terms.sideKey()
const sessionID = session.currentSessionID() ?? activePendingId() ?? "new"
@@ -433,6 +433,7 @@ const AgentManagerContent: Component = () => {
return terminalVisible() ? false : true
}
const restoreFocus = () => {
if (Date.now() < focusInputUntil) return
const key = focusKey()
const owner = focusMemory.get(key)
if (owner && owner !== "prompt") {
@@ -1169,6 +1170,8 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "sessionNext") projectNav.step("down")
else if (msg.action === "tabPrevious") navigateTab("left")
else if (msg.action === "tabNext") navigateTab("right")
else if (msg.action === "terminalPrevious") cycleTerminal("previous")
else if (msg.action === "terminalNext") cycleTerminal("next")
else if (msg.action === "search") {
if (!sidebarCollapsed()) sidebarSearchMenu?.open()
else {
@@ -1192,11 +1195,13 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "advancedWorktree") showNewWorktreeDialog()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts()
else if (msg.action === "focusInput") requestChatFocus(true)
else if (msg.action === "focusInput") focusPrompt()
else if (msg.action === "focusSearch")
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
else if (msg.action === "newTerminal") termHandlers.requestNew()
else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
else if (msg.action === "newTerminal") {
if (terms.sideFocusedId()) termHandlers.addSide()
else termHandlers.requestNew()
} else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
if (!mode.dispatch(1)) cycleAgent(1)
} else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) {
if (!mode.dispatch(-1)) cycleAgent(-1)
@@ -1207,7 +1212,6 @@ const AgentManagerContent: Component = () => {
}
}
window.addEventListener("message", handler)
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
@@ -1221,8 +1225,9 @@ const AgentManagerContent: Component = () => {
if (["t", "w", "n", "d", "e", "f"].includes(e.key.toLowerCase()) && !e.shiftKey) {
e.preventDefault()
}
// Prevent defaults for shift variants (close worktree, advanced/new/open worktree, open PR)
if (["w", "n", "o", "r"].includes(e.key.toLowerCase()) && e.shiftKey) {
// Prevent browser defaults for shift variants (new terminal, close worktree,
// advanced/new/open worktree, open PR, terminal cycling)
if (["t", "m", "w", "n", "o", "r", "[", "]"].includes(e.key.toLowerCase()) && e.shiftKey) {
e.preventDefault()
}
// Prevent browser defaults for shortcuts help (Cmd/Ctrl+Shift+/)
@@ -1371,6 +1376,7 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.worktreeSetup") {
const ev = msg as AgentManagerWorktreeSetupMessage
creation.setup(ev)
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
const updateBusy: Setter<Map<string, WorktreeBusyState>> = (value) => store.setBusy(value)
if (ev.status === "ready" || ev.status === "error") {
@@ -1412,6 +1418,8 @@ const AgentManagerContent: Component = () => {
}
}
if (msg.type === "agentManager.importResult" && !msg.success) creation.abandon(msg.projectId)
if (msg.type === "agentManager.sessionAdded") {
const ev = msg as { type: string; sessionId: string; worktreeId: string }
saveTabMemory()
@@ -1453,6 +1461,7 @@ const AgentManagerContent: Component = () => {
// When a multi-version progress update arrives, mark newly created worktrees as loading
if ((msg as { type: string }).type === "agentManager.multiVersionProgress") {
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
if (ev.status === "done") creation.abandon(ev.projectId)
if (ev.status === "done" && ev.groupId) {
// Clear busy state for all worktrees in this group
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
@@ -1732,99 +1741,15 @@ const AgentManagerContent: Component = () => {
const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript)
const handleChangeDefaultBaseBranch = () => {
const [search, setSearch] = createSignal("")
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [loading, setLoading] = createSignal(true)
const [highlighted, setHighlighted] = createSignal(-1)
const unsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
setBranches(ev.branches)
if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch)
setLoading(false)
}
})
vscode.postMessage({ type: "agentManager.requestBranches" })
const filtered = createMemo(() => {
const s = search().toLowerCase()
if (!s) return branches()
return branches().filter((b) => b.name.toLowerCase().includes(s))
})
const selectBranch = (name: string | undefined) => {
vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name })
setDefaultBaseBranch(name)
dialog.close()
}
const handleKeyDown = (e: KeyboardEvent) => {
const items = filtered()
// offset by 1 for auto-detect option (-1 = auto-detect)
const total = items.length + 1
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
setHighlighted((prev) => Math.min(prev + 1, total - 2))
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
setHighlighted((prev) => Math.max(prev - 1, -1))
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const idx = highlighted()
if (idx === -1) {
selectBranch(undefined)
} else {
const branch = items[idx]
if (branch) selectBranch(branch.name)
}
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
dialog.close()
}
}
dialog.show(() => {
onCleanup(unsub)
return (
<Dialog title={t("agentManager.worktree.defaultBaseBranch")} fit>
<div class="am-default-base-branch">
<BranchSelect
branches={filtered()}
loading={loading()}
search={search()}
onSearch={(v) => {
setSearch(v)
setHighlighted(-1)
}}
onSelect={(b) => selectBranch(b.name)}
onSearchKeyDown={handleKeyDown}
selected={defaultBaseBranch()}
highlighted={highlighted()}
onHighlight={setHighlighted}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
loadingLabel={t("agentManager.import.loadingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
defaultName={defaultBaseBranch()}
autoOption={{
label: t("agentManager.worktree.defaultBaseBranchAuto"),
hint: repoDetectedBranch(),
active: !hasConfiguredBranch(),
highlighted: highlighted() === -1,
onSelect: () => selectBranch(undefined),
}}
/>
</div>
</Dialog>
)
})
dialog.show(() => (
<DefaultBaseBranchDialog
selected={defaultBaseBranch()}
detected={repoDetectedBranch()}
onSelect={setDefaultBaseBranch}
onDetected={setRepoDetectedBranch}
onClose={() => dialog.close()}
/>
))
}
const handleShowKeyboardShortcuts = () => {
@@ -1871,7 +1796,15 @@ const AgentManagerContent: Component = () => {
if (!loaded()) return
expandSidebar()
dialog.show(() => (
<NewWorktreeDialog mode={mode} onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />
<NewWorktreeDialog
mode={mode}
onClose={() => dialog.close()}
projectId={multiProject() ? activeProjectId() : undefined}
projects={multiProject() ? projectList : undefined}
activeProjectId={activeProjectId()}
defaultBase={defaultBase}
onCreate={creation.schedule}
/>
))
}
@@ -2080,6 +2013,8 @@ const AgentManagerContent: Component = () => {
handlers: termHandlers,
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
focusedId: () => terms.sideFocusedId(),
count: () => terms.sidesForContext(terms.sideKey()).length,
isScript: terms.isScript,
hide: () => {
cancelAmbientSetup()
setSidePanel(null)
@@ -2198,16 +2133,24 @@ const AgentManagerContent: Component = () => {
})
}
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
const cycleTerminal = (direction: "previous" | "next") => {
const focused = terms.focusedId()
const placement = terms.sideFocusedId() || (!focused && terminalVisible()) ? "side" : "tab"
return termHandlers.cycle(direction, placement)
}
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
// A focused side terminal owns Cmd+W while its panel is visible
// closing a chat tab out from under the user's cursor would be
// surprising. Only that terminal dies; the panel keeps the rest.
// A focused side terminal owns Cmd+W while its panel is visible.
// Closing a chat tab out from under the user's cursor would be surprising.
if (sidePanel() === "terminal" && terms.sideFocusedId()) {
if (sideCtl.close()) return
}
if (termHandlers.closeFocused()) {
tabFocus.restore()
return
}
if (termHandlers.closeActive()) {
tabFocus.restore()
return
@@ -2348,6 +2291,8 @@ const AgentManagerContent: Component = () => {
selection={selection() ?? undefined}
currentSessionID={session.currentSessionID}
mode={mode}
defaultBase={defaultBase}
onCreate={creation.schedule}
bindings={kb()}
t={t}
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
@@ -2524,7 +2469,7 @@ const AgentManagerContent: Component = () => {
>
<div class={`am-main-pane ${terms.activeId() ? "am-main-pane-terminal-active" : ""}`}>
{/* Keep terminal tabs mounted so output streams across worktree switches. */}
{renderTerminalLayer({ state: terms })}
{renderTerminalLayer({ state: terms, onFocusPrompt: focusPrompt })}
{/* Session-less context (e.g. a worktree mid-provisioning): the
empty state lives in the main pane so the side terminal
panel can render next to it. */}
@@ -2682,6 +2627,9 @@ const AgentManagerContent: Component = () => {
state={terms}
contextKey={terms.sideKey}
visible={() => sidePanel() === "terminal"}
nextKeybind={kb().nextTerminal ?? ""}
closeKeybind={kb().closeTab ?? ""}
onFocusPrompt={focusPrompt}
onSelect={(id) => termHandlers.selectSide(id)}
onClose={(id) => {
cancelAmbientSetup()
@@ -0,0 +1,110 @@
/** @jsxImportSource solid-js */
import { createMemo, createSignal, onCleanup, type Component } from "solid-js"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { BranchSelect } from "../src/components/shared/BranchSelect"
import { useLanguage } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import type { AgentManagerBranchesMessage, BranchInfo } from "../src/types/messages"
interface Props {
selected?: string
detected?: string
onSelect: (branch?: string) => void
onDetected: (branch: string) => void
onClose: () => void
}
export const DefaultBaseBranchDialog: Component<Props> = (props) => {
const { t } = useLanguage()
const vscode = useVSCode()
const [search, setSearch] = createSignal("")
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [loading, setLoading] = createSignal(true)
const [highlighted, setHighlighted] = createSignal(-1)
const filtered = createMemo(() => {
const value = search().toLowerCase()
return value ? branches().filter((branch) => branch.name.toLowerCase().includes(value)) : branches()
})
const select = (branch?: string) => {
vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch })
props.onSelect(branch)
props.onClose()
}
const unsub = vscode.onMessage((message) => {
if (message.type !== "agentManager.branches") return
const event = message as AgentManagerBranchesMessage
setBranches(event.branches)
if (event.defaultBranch) props.onDetected(event.defaultBranch)
setLoading(false)
})
onCleanup(unsub)
vscode.postMessage({ type: "agentManager.requestBranches" })
const keydown = (event: KeyboardEvent) => {
const items = filtered()
const total = items.length + 1
if (event.key === "ArrowDown") {
event.preventDefault()
event.stopPropagation()
setHighlighted((value) => Math.min(value + 1, total - 2))
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
event.stopPropagation()
setHighlighted((value) => Math.max(value - 1, -1))
return
}
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
const index = highlighted()
if (index === -1) {
select()
return
}
const branch = items[index]
if (branch) select(branch.name)
return
}
if (event.key !== "Escape") return
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<Dialog title={t("agentManager.worktree.defaultBaseBranch")} fit>
<div class="am-default-base-branch">
<BranchSelect
branches={filtered()}
loading={loading()}
search={search()}
onSearch={(value) => {
setSearch(value)
setHighlighted(-1)
}}
onSelect={(branch) => select(branch.name)}
onSearchKeyDown={keydown}
selected={props.selected}
highlighted={highlighted()}
onHighlight={setHighlighted}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
loadingLabel={t("agentManager.import.loadingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
defaultName={props.selected}
autoOption={{
label: t("agentManager.worktree.defaultBaseBranchAuto"),
hint: props.detected,
active: !props.selected,
highlighted: highlighted() === -1,
onSelect: () => select(),
}}
/>
</div>
</Dialog>
)
}
@@ -2,10 +2,11 @@
/** @jsxImportSource solid-js */
import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js"
import { type Component, For, Show, createSignal, createEffect, createMemo, on, onMount, onCleanup } from "solid-js"
import type {
AgentManagerBranchesMessage,
AgentManagerImportResultMessage,
AgentProjectSnapshot,
BranchInfo,
EnhancePromptResultMessage,
EnhancePromptErrorMessage,
@@ -22,7 +23,7 @@ import { useServer } from "../src/context/server"
import { useSession } from "../src/context/session"
import { useProvider } from "../src/context/provider"
import { useConfig } from "../src/context/config"
import { cycleVariant } from "../src/context/session-variant-store"
import { cycleVariant, preserveVariant } from "../src/context/session-variant-store"
import { ModelSelectorBase } from "../src/components/shared/ModelSelector"
import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
@@ -49,10 +50,11 @@ import { BranchSelect, BranchSelectPopover } from "../src/components/shared/Bran
import { tracker } from "./telemetry"
import { cycleAgent } from "../src/context/session-agent"
import type { ModeRouter } from "./mode-router"
import { ProjectSelect } from "./ProjectSelect"
type VersionCount = 1 | 2 | 3 | 4
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox"])
const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox", "project"])
const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt"
type DialogTab = "new" | "import"
@@ -124,8 +126,11 @@ function sanitizeBranchName(name: string): string {
export const NewWorktreeDialog: Component<{
onClose: () => void
defaultBaseBranch?: string
defaultBase?: (projectId: string) => string | undefined
projectId?: string
projects?: () => AgentProjectSnapshot[]
activeProjectId?: string
onCreate?: (projectId: string) => void
mode: ModeRouter
}> = (props) => {
const { t } = useLanguage()
@@ -140,11 +145,20 @@ export const NewWorktreeDialog: Component<{
const click = metrics.click
const [tab, setTab] = createSignal<DialogTab>("new")
const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId)
const [projectOpen, setProjectOpen] = createSignal(false)
const projects = () => props.projects?.() ?? []
const showProject = () => projects().length > 0
const projectLabel = () => projects().find((item) => item.id === project())?.label ?? ""
const base = () => {
const id = project()
return id ? props.defaultBase?.(id) : undefined
}
// --- Shared branch data (used by both New tab's base branch selector and Import tab) ---
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [branchesLoading, setBranchesLoading] = createSignal(false)
const [defaultBranch, setDefaultBranch] = createSignal(props.defaultBaseBranch ?? "main")
const [defaultBranch, setDefaultBranch] = createSignal(base() ?? "main")
const [branchSearch, setBranchSearch] = createSignal("")
// --- New tab state ---
@@ -254,7 +268,7 @@ export const NewWorktreeDialog: Component<{
return
}
const stored = variant()
if (!stored || !list.includes(stored)) setVariant(list[0])
if (!stored || !list.includes(stored)) setVariant(preserveVariant(stored, list) ?? list[0])
})
createEffect(() => {
@@ -367,18 +381,25 @@ export const NewWorktreeDialog: Component<{
if (session.agents().length < 2) hidden.add("agents")
if (variants().length === 0) hidden.add("variant")
if (!sandboxVisible()) hidden.add("sandbox")
if (!showProject()) hidden.add("project")
return hidden
},
WORKTREE_PROMPT_COMMANDS,
WORKTREE_PROMPT_SCOPE,
[
{
name: "project",
description: t("agentManager.dialog.project.select"),
hints: [],
action: () => setProjectOpen(true),
},
],
)
const onFocusPrompt = () => restorePrompt()
window.addEventListener("focusPrompt", onFocusPrompt)
onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt))
onMount(() => {
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId })
// Resize textarea if restoring a cached prompt
if (prompt()) adjustHeight()
const focus = () => {
@@ -394,6 +415,20 @@ export const NewWorktreeDialog: Component<{
})
})
// Branch data and base-branch defaults belong to the selected project. Other
// dialog state deliberately survives project changes.
createEffect(
on(project, (id) => {
setBranches([])
setBranchSearch("")
setHighlightedIndex(0)
setBaseBranch(null)
setDefaultBranch(id ? (props.defaultBase?.(id) ?? "main") : "main")
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches", projectId: id })
}),
)
const effectiveBaseBranch = () => baseBranch() ?? defaultBranch()
const filteredBranches = createMemo(() => {
@@ -427,10 +462,12 @@ export const NewWorktreeDialog: Component<{
const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined
const count = total()
const sel = isCompare ? null : model()
const target = project()
if (target) props.onCreate?.(target)
vscode.postMessage({
type: "agentManager.createMultiVersion",
projectId: props.projectId,
projectId: target,
text,
name: name().trim() || undefined,
versions: count,
@@ -579,12 +616,14 @@ export const NewWorktreeDialog: Component<{
const importUnsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
if (ev.projectId !== project()) return
setBranches(ev.branches)
if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)
if (!base()) setDefaultBranch(ev.defaultBranch)
setBranchesLoading(false)
}
if (msg.type === "agentManager.importResult") {
const ev = msg as AgentManagerImportResultMessage
if (ev.projectId !== project()) return
setPrPending(false)
setImportPending(false)
if (ev.success) {
@@ -623,7 +662,9 @@ export const NewWorktreeDialog: Component<{
const url = prUrl().trim()
if (!url || isPending()) return
setPrPending(true)
vscode.postMessage({ type: "agentManager.importFromPR", projectId: props.projectId, url })
const target = project()
if (target) props.onCreate?.(target)
vscode.postMessage({ type: "agentManager.importFromPR", projectId: target, url })
}
const handleBranchSelect = (name: string) => {
@@ -632,7 +673,9 @@ export const NewWorktreeDialog: Component<{
setImportPending(true)
setBranchOpen(false)
setBranchSearch("")
vscode.postMessage({ type: "agentManager.importFromBranch", projectId: props.projectId, branch: name })
const target = project()
if (target) props.onCreate?.(target)
vscode.postMessage({ type: "agentManager.importFromBranch", projectId: target, branch: name })
}
return (
@@ -655,6 +698,62 @@ export const NewWorktreeDialog: Component<{
>
{t("agentManager.dialog.tab.import")}
</button>
{/* Project scope applies to both New and Import tabs. */}
<Show when={showProject()}>
<div class="am-nv-project-inline">
<div class="am-selector-wrapper">
<DeferredPopover
open={projectOpen()}
onOpenChange={setProjectOpen}
placement="bottom-start"
flip={false}
sameWidth
portal={false}
deferDismiss
class="am-dropdown"
trigger={
<button
class="am-selector-trigger"
type="button"
aria-label={t("agentManager.dialog.project.select")}
disabled={starting() || isPending()}
>
<span class="am-selector-left">
<Icon name="folder" size="small" />
<Show
when={projectLabel()}
fallback={
<span class="am-selector-value am-selector-placeholder">
{t("agentManager.dialog.project.select")}
</span>
}
>
<span class="am-selector-value">{projectLabel()}</span>
</Show>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<ProjectSelect
projects={projects()}
selected={project()}
onSelect={(id) => {
track("project_select", { changed: id !== props.activeProjectId })
setProject(id)
setProjectOpen(false)
}}
labels={{
untrusted: t("agentManager.dialog.project.untrusted"),
missing: t("agentManager.dialog.project.missing"),
}}
/>
</DeferredPopover>
</div>
</div>
</Show>
</div>
{/* New tab */}
@@ -772,7 +871,12 @@ export const NewWorktreeDialog: Component<{
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => {
if (pid && mid) setModel({ providerID: pid, modelID: mid })
if (!pid || !mid) return
const current = effectiveVariant()
const next = { providerID: pid, modelID: mid }
const list = Object.keys(provider.findModel(next)?.variants ?? {})
setModel(next)
setVariant(preserveVariant(current, list))
}}
onPick={restorePrompt}
onCancel={restorePrompt}
@@ -39,6 +39,8 @@ interface Props {
selection?: string
currentSessionID?: () => string | undefined
mode: ModeRouter
defaultBase?: (projectId: string) => string | undefined
onCreate?: (projectId: string) => void
busy?: (projectId: string, id: string) => boolean
working?: (projectId: string, id: string) => boolean
localBusy?: (projectId: string) => boolean
@@ -141,12 +143,14 @@ export const ProjectList: Component<Props> = (props) => {
return select({ projectId: item.projectId, kind: "session", sessionId: item.sessionId })
}
const newWorktree = (projectId: string) => {
const state = props.states[projectId]
dialog.show(() => (
<NewWorktreeDialog
projectId={projectId}
projects={() => props.projects}
activeProjectId={props.selectedProject}
defaultBase={props.defaultBase}
onCreate={props.onCreate}
mode={props.mode}
defaultBaseBranch={state?.defaultBaseBranch ?? props.local[projectId]?.branch}
onClose={() => dialog.close()}
/>
))

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