Merge branch 'main' into feature/am-pr-view

This commit is contained in:
cmanu
2026-08-07 08:49:49 -07:00
170 changed files with 3841 additions and 1706 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Align multi-project Agent Manager header actions with the worktree controls.
@@ -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 @@
---
"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,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Route Agent Manager tool-launched sessions to the project that owns the tool event directory, keep sandboxed worktree sessions inside their active worktree, and wait for busy managed sessions before prompting them.
+1 -1
View File
@@ -2,4 +2,4 @@
"kilo-code": patch
---
Fix the sidebar navigation bar (New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, Settings) disappearing when the Kilo Code view is docked in the Secondary Side Bar. The navigation is now rendered inside the webview itself, so it stays visible regardless of where the view is docked.
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.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Move the "why was this tool call approved" line to after the tool output instead of between the header and body, add an icon to it, and add a Display setting to hide it.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager terminals aligned and correctly wrapped when the terminal panel is narrow.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep recent sessions and the Show History action inset and usable in narrow VS Code sidebars.
@@ -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 @@
---
"@kilocode/cli": patch
---
Remove unsupported `kilo web` CLI command.
+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
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Long skill folder paths and URLs shown in the tooltip on the Skills settings page now wrap inside the viewport instead of overflowing on a single line.
@@ -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.
+12 -21
View File
@@ -470,6 +470,8 @@
},
"devDependencies": {
"@axe-core/playwright": "4.11.3",
"@babel/core": "^7.28.4",
"@babel/preset-typescript": "^7.27.1",
"@playwright/test": "1.57.0",
"@storybook/addon-a11y": "10.2.10",
"@storybook/addon-docs": "10.2.10",
@@ -481,6 +483,7 @@
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
"babel-preset-solid": "^1.9.9",
"esbuild": "^0.27.2",
"esbuild-plugin-solid": "^0.6.0",
"eslint": "^9.39.2",
@@ -984,23 +987,23 @@
},
},
"trustedDependencies": [
"esbuild",
"protobufjs",
"web-tree-sitter",
"esbuild",
"tree-sitter-bash",
"protobufjs",
],
"patchedDependencies": {
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -5443,10 +5446,6 @@
"@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@storybook/addon-links/storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="],
"@storybook/addon-onboarding/storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="],
@@ -6153,14 +6152,6 @@
"@smithy/util-stream/@smithy/core/@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-json/effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"@storybook/addon-links/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-links/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"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="
"x86_64-linux": "sha256-y6PZR6BsVZcsK/qJ0XvJBevukYzVJ8NRLHRbNSuSzb8=",
"aarch64-linux": "sha256-0ozGLgGTHajlHqofixtjJv/dggq25MhWYIqmfmb+6Z0=",
"aarch64-darwin": "sha256-IEYJothLBDT20BmLamBqM+pciFMQtWy8Um1YjFjbKz4=",
"x86_64-darwin": "sha256-k2bQGKTkvIZmB2+I2Fy3TPm/NZ0BbGjxp9esxtGuUbI="
}
}
+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",
@@ -531,6 +531,7 @@ type Endpoint14_1Input = {
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
readonly title?: Endpoint14_1Request["payload"]["title"]
readonly env?: Endpoint14_1Request["payload"]["env"]
readonly size?: Endpoint14_1Request["payload"]["size"]
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
raw["pty.create"]({
@@ -541,6 +542,7 @@ const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Inpu
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
size: input?.["size"],
},
}).pipe(Effect.mapError(mapClientError))
+1
View File
@@ -840,6 +840,7 @@ export function make(options: ClientOptions) {
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
size: input?.["size"],
},
successStatus: 200,
declaredStatuses: [400, 401],
+13
View File
@@ -2632,6 +2632,7 @@ export type PtysCreateInput = {
readonly cwd?: string
readonly title?: string
readonly env?: { readonly [x: string]: string }
readonly size?: { readonly rows: number; readonly cols: number }
}["command"]
readonly args?: {
readonly command?: string
@@ -2639,6 +2640,7 @@ export type PtysCreateInput = {
readonly cwd?: string
readonly title?: string
readonly env?: { readonly [x: string]: string }
readonly size?: { readonly rows: number; readonly cols: number }
}["args"]
readonly cwd?: {
readonly command?: string
@@ -2646,6 +2648,7 @@ export type PtysCreateInput = {
readonly cwd?: string
readonly title?: string
readonly env?: { readonly [x: string]: string }
readonly size?: { readonly rows: number; readonly cols: number }
}["cwd"]
readonly title?: {
readonly command?: string
@@ -2653,6 +2656,7 @@ export type PtysCreateInput = {
readonly cwd?: string
readonly title?: string
readonly env?: { readonly [x: string]: string }
readonly size?: { readonly rows: number; readonly cols: number }
}["title"]
readonly env?: {
readonly command?: string
@@ -2660,7 +2664,16 @@ export type PtysCreateInput = {
readonly cwd?: string
readonly title?: string
readonly env?: { readonly [x: string]: string }
readonly size?: { readonly rows: number; readonly cols: number }
}["env"]
readonly size?: {
readonly command?: string
readonly args?: ReadonlyArray<string>
readonly cwd?: string
readonly title?: string
readonly env?: { readonly [x: string]: string }
readonly size?: { readonly rows: number; readonly cols: number }
}["size"]
}
export type PtysCreateOutput = {
+11 -1
View File
@@ -214,7 +214,17 @@ const layer = Layer.effect(
}
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
// kilocode_change start - spawn with initial terminal dimensions
const proc = yield* Effect.sync(() =>
spawn(command, args, {
name: "xterm-256color",
cwd,
env,
cols: input.size?.cols,
rows: input.size?.rows,
}),
)
// kilocode_change end
const info: Info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
@@ -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 |
@@ -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.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1bc2a28b80d8dc5aea4c03609efc13a23a2c88103e56df741155d9e794fc96f7
size 2422
oid sha256:d49870048a42ea9c0166143fbb364b0d9ee45d786e3b1346fcd0eda76c533bdb
size 2414
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3f86dabc39700b3a86508c1e6499a546eeabf720832b62c9ce12f2d97378ebd
size 34649
oid sha256:00092414080e2f70fb9b3166e8d06a1692af93e33717d0ab8fea5fe96e6a6982
size 34468
+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
+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.
@@ -507,21 +507,25 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
}
}
/* "why was this allowed" line inside a tool's expanded body */
/* "why was this allowed" line inside a tool's expanded body. Styled like
[data-component="tool-hint"] (muted, italic) so it reads as ambient
context rather than a call to action, and recedes the way reasoning text does. */
[data-slot="tool-approval-line"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: 4px;
padding: 4px 0 6px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: italic;
line-height: var(--line-height-normal);
color: var(--text-weak);
opacity: 0.9;
[data-slot="tool-approval-decision"] {
font-weight: var(--font-weight-medium);
color: var(--text-strong);
svg {
flex-shrink: 0;
color: var(--text-weak);
}
[data-slot="tool-approval-rule"] {
+13 -7
View File
@@ -44,18 +44,24 @@ export function BasicTool(props: BasicToolProps) {
writeToolOpen(key(), open)
props.onOpenChange?.(open)
}
// Renders after the body/tool list, not before — it's context about what
// happened, not part of the header.
const details = () => (
<div data-slot="basic-tool-details">
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
{props.children}
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
</div>
)
if (!("children" in props) && !inBody()) {
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
}
// A <Show>, not a plain `if`: inBody() tracks the visibility toggle, which can
// flip after mount (Settings), so the branch must stay reactive.
return (
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
{details()}
</Base>
<Show
when={"children" in props || inBody()}
fallback={<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />}
>
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
{details()}
</Base>
</Show>
)
}
@@ -49,7 +49,7 @@ import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
export { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
export { ToolApprovalProvider, resolveToolApproval, ToolApprovalVisibilityProvider } from "./tool-approval"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
@@ -1,4 +1,5 @@
import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js"
import { Icon } from "./icon"
/**
* Explains why a tool call was auto-approved, inside the expanded tool row.
@@ -30,8 +31,23 @@ export function ToolApprovalProvider(props: ParentProps<{ value: Accessor<ToolAp
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
}
/**
* Whether the approval line should render at all. Hosts that expose a "hide
* auto-approval reason" display setting wrap their tree in
* `ToolApprovalVisibilityProvider`; without one, the line stays visible.
*/
const VisibilityContext = createContext<Accessor<boolean>>(() => true)
export function ToolApprovalVisibilityProvider(props: ParentProps<{ value: Accessor<boolean> }>) {
return <VisibilityContext.Provider value={props.value}>{props.children}</VisibilityContext.Provider>
}
/** Read the approval for the tool row below, gated by the visibility toggle
* here (not per call site) so a new `ToolApprovalProvider` usage can't forget it. */
export function useToolApproval() {
return useContext(Context)
const value = useContext(Context)
const visible = useContext(VisibilityContext)
return () => (visible() ? value() : undefined)
}
/** Read the raw approval payload off a tool part's metadata, if present. */
@@ -79,6 +95,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
const manual = () => props.display.approval.source === "manual"
return (
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
<Icon name="shield" size="small" />
<span data-slot="tool-approval-decision">{props.display.decision}</span>
<Show when={!manual()}>
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
+1 -1
View File
@@ -3,4 +3,4 @@
* Only add icons here that are actually used esbuild/Vite will
* tree-shake unused exports but explicit re-exports keep the API small.
*/
export { WandSparkles } from "lucide-solid"
export { default as WandSparkles } from "lucide-solid/icons/wand-sparkles"
+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`.
+172 -114
View File
@@ -1,10 +1,96 @@
const esbuild = require("esbuild")
const path = require("path")
const { solidPlugin } = require("esbuild-plugin-solid")
const fs = require("fs")
const crypto = require("crypto")
const core = require("@babel/core")
const solid = require("babel-preset-solid")
const ts = require("@babel/preset-typescript")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
/**
* Cache transformed Solid JSX files in memory and on disk to avoid
* re-parsing and re-transforming unchanged files across builds and webviews.
*/
const solidCacheDir = path.join(__dirname, "node_modules", ".cache", "esbuild-solid")
const solidMemCache = new Map()
const buildScriptHash = crypto
.createHash("sha256")
.update(fs.readFileSync(__filename, "utf8"))
.update(require("babel-preset-solid/package.json").version || "")
.update(require("@babel/preset-typescript/package.json").version || "")
.digest("hex")
.slice(0, 8)
if (!fs.existsSync(solidCacheDir)) {
try {
fs.mkdirSync(solidCacheDir, { recursive: true })
} catch (err) {
console.warn("[esbuild] could not create solid cache directory", err)
}
}
const cachedSolidPlugin = {
name: "esbuild:solid-cached",
setup(build) {
build.onLoad({ filter: /\.(t|j)sx$/ }, async (args) => {
let mtime = 0
let size = 0
try {
const st = fs.statSync(args.path)
mtime = st.mtimeMs
size = st.size
} catch (err) {
console.warn("[esbuild] could not stat source file for cache key", args.path, err)
}
const cacheKey = `${args.path}:${mtime}:${size}:${buildScriptHash}`
const memHit = solidMemCache.get(cacheKey)
if (memHit) return { contents: memHit, loader: "js" }
const diskKey = crypto.createHash("sha256").update(cacheKey).digest("hex") + ".js"
const diskPath = path.join(solidCacheDir, diskKey)
if (fs.existsSync(diskPath)) {
try {
const diskCode = fs.readFileSync(diskPath, "utf8")
solidMemCache.set(cacheKey, diskCode)
return { contents: diskCode, loader: "js" }
} catch (err) {
console.warn("[esbuild] cache read failed, rebuilding", diskPath, err)
}
}
const source = fs.readFileSync(args.path, "utf8")
const { name, ext } = path.parse(args.path)
const filename = name + ext
const result = await core.transformAsync(source, {
presets: [
[solid, {}],
[ts, {}],
],
filename,
sourceMaps: "inline",
})
if (result?.code === void 0 || result.code === null) {
throw new Error("No result was provided from Babel")
}
if (solidMemCache.size > 2000) solidMemCache.clear()
solidMemCache.set(cacheKey, result.code)
try {
fs.writeFileSync(diskPath, result.code)
} catch (err) {
console.warn("[esbuild] cache write failed", diskPath, err)
}
return { contents: result.code, loader: "js" }
})
},
}
/**
* Force all solid-js imports (from kilo-ui and the webview) to resolve to
* the **same** copy so SolidJS contexts are shared across packages.
@@ -123,7 +209,7 @@ const svgSpritePlugin = {
name: "svg-sprite-inline",
setup(build) {
build.onLoad({ filter: /sprite\.svg$/ }, (args) => {
const content = require("fs").readFileSync(args.path, "utf8")
const content = fs.readFileSync(args.path, "utf8")
return {
contents: `
const svg = ${JSON.stringify(content)};
@@ -160,69 +246,8 @@ const cssPackageResolvePlugin = {
},
}
function createBrowserWebviewContext(entryPoint, outfile) {
return esbuild.context({
entryPoints: [entryPoint],
bundle: true,
format: "iife",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile,
logLevel: "silent",
loader: {
".woff": "file",
".woff2": "file",
".ttf": "file",
},
plugins: [
solidDedupePlugin,
pierreWorkerAliasPlugin,
markdownWorkerUrlPlugin,
svgSpritePlugin,
cssPackageResolvePlugin,
solidPlugin(),
esbuildProblemMatcherPlugin,
],
})
}
// Bundle Pierre's Shiki worker into a single self-contained asset that the
// webviews load off the main thread for syntax highlighting.
function createShikiWorkerContext() {
return esbuild.context({
entryPoints: ["kilo-shiki-worker"],
bundle: true,
format: "iife",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile: "dist/shiki-worker.js",
logLevel: "silent",
plugins: [shikiWorkerEntryPlugin, esbuildProblemMatcherPlugin],
})
}
function createMarkdownShikiWorkerContext() {
return esbuild.context({
entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")],
bundle: true,
format: "esm",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile: "dist/markdown-shiki-worker.js",
logLevel: "silent",
plugins: [esbuildProblemMatcherPlugin],
})
}
async function main() {
// Build extension
const extensionCtx = await esbuild.context({
function getExtensionConfig() {
return {
entryPoints: ["src/extension.ts"],
bundle: true,
format: "cjs",
@@ -239,68 +264,101 @@ async function main() {
outfile: "dist/extension.js",
external: ["vscode"],
logLevel: "silent",
plugins: [esbuildProblemMatcherPlugin],
})
plugins: watch ? [esbuildProblemMatcherPlugin] : [],
}
}
// Build Agent Manager webview (SolidJS, shares components with sidebar)
const agentManagerCtx = await createBrowserWebviewContext(
"webview-ui/agent-manager/index.tsx",
"dist/agent-manager.js",
)
function getWebviewsConfig() {
return {
entryPoints: {
"agent-manager": "webview-ui/agent-manager/index.tsx",
kiloclaw: "webview-ui/kiloclaw/index.tsx",
marketplace: "webview-ui/marketplace/index.tsx",
"diff-viewer": "webview-ui/diff-viewer/index.tsx",
"diff-virtual": "webview-ui/diff-virtual/index.tsx",
webview: "webview-ui/src/index.tsx",
},
outdir: "dist",
bundle: true,
format: "iife",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
logLevel: "silent",
loader: {
".woff": "file",
".woff2": "file",
".ttf": "file",
},
plugins: [
solidDedupePlugin,
pierreWorkerAliasPlugin,
markdownWorkerUrlPlugin,
svgSpritePlugin,
cssPackageResolvePlugin,
cachedSolidPlugin,
...(watch ? [esbuildProblemMatcherPlugin] : []),
],
}
}
// Build KiloClaw webview (SolidJS, standalone chat panel)
const kiloClawCtx = await createBrowserWebviewContext("webview-ui/kiloclaw/index.tsx", "dist/kiloclaw.js")
function getShikiWorkerConfig() {
return {
entryPoints: ["kilo-shiki-worker"],
bundle: true,
format: "iife",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile: "dist/shiki-worker.js",
logLevel: "silent",
plugins: [shikiWorkerEntryPlugin, ...(watch ? [esbuildProblemMatcherPlugin] : [])],
}
}
// Build Marketplace webview (SolidJS, standalone catalog panel)
const marketplaceCtx = await createBrowserWebviewContext("webview-ui/marketplace/index.tsx", "dist/marketplace.js")
function getMarkdownShikiWorkerConfig() {
return {
entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")],
bundle: true,
format: "esm",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile: "dist/markdown-shiki-worker.js",
logLevel: "silent",
plugins: watch ? [esbuildProblemMatcherPlugin] : [],
}
}
// Build Diff Viewer webview (SolidJS, reuses Agent Manager diff components)
const diffViewerCtx = await createBrowserWebviewContext("webview-ui/diff-viewer/index.tsx", "dist/diff-viewer.js")
// Build Diff Virtual webview (lightweight single-file diff for permission approval)
const diffVirtualCtx = await createBrowserWebviewContext("webview-ui/diff-virtual/index.tsx", "dist/diff-virtual.js")
// Build webview
const webviewCtx = await createBrowserWebviewContext("webview-ui/src/index.tsx", "dist/webview.js")
// Build the shared Shiki highlighting worker asset
const shikiWorkerCtx = await createShikiWorkerContext()
const markdownShikiWorkerCtx = await createMarkdownShikiWorkerContext()
async function main() {
const extensionConfig = getExtensionConfig()
const webviewsConfig = getWebviewsConfig()
const shikiWorkerConfig = getShikiWorkerConfig()
const markdownShikiWorkerConfig = getMarkdownShikiWorkerConfig()
if (watch) {
const [extensionCtx, webviewsCtx, shikiWorkerCtx, markdownShikiWorkerCtx] = await Promise.all([
esbuild.context(extensionConfig),
esbuild.context(webviewsConfig),
esbuild.context(shikiWorkerConfig),
esbuild.context(markdownShikiWorkerConfig),
])
await Promise.all([
extensionCtx.watch(),
webviewCtx.watch(),
agentManagerCtx.watch(),
diffViewerCtx.watch(),
diffVirtualCtx.watch(),
kiloClawCtx.watch(),
marketplaceCtx.watch(),
webviewsCtx.watch(),
shikiWorkerCtx.watch(),
markdownShikiWorkerCtx.watch(),
])
} else {
await Promise.all([
extensionCtx.rebuild(),
webviewCtx.rebuild(),
agentManagerCtx.rebuild(),
kiloClawCtx.rebuild(),
marketplaceCtx.rebuild(),
diffViewerCtx.rebuild(),
diffVirtualCtx.rebuild(),
shikiWorkerCtx.rebuild(),
markdownShikiWorkerCtx.rebuild(),
])
await Promise.all([
extensionCtx.dispose(),
webviewCtx.dispose(),
agentManagerCtx.dispose(),
diffViewerCtx.dispose(),
diffVirtualCtx.dispose(),
kiloClawCtx.dispose(),
marketplaceCtx.dispose(),
shikiWorkerCtx.dispose(),
markdownShikiWorkerCtx.dispose(),
esbuild.build(extensionConfig),
esbuild.build(webviewsConfig),
esbuild.build(shikiWorkerConfig),
esbuild.build(markdownShikiWorkerConfig),
])
}
}
+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] },
},
{
+137 -4
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",
@@ -141,6 +147,41 @@
"category": "Kilo Code",
"icon": "$(settings-gear)"
},
{
"command": "kilo-code.new.sidebarTitle.plusButtonClicked",
"title": "New Task",
"icon": "$(add)"
},
{
"command": "kilo-code.new.sidebarTitle.agentManagerOpen",
"title": "Agent Manager",
"icon": "$(organization)"
},
{
"command": "kilo-code.new.sidebarTitle.kiloClawOpen",
"title": "KiloClaw",
"icon": "$(comment-discussion)"
},
{
"command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked",
"title": "Marketplace",
"icon": "$(extensions)"
},
{
"command": "kilo-code.new.sidebarTitle.historyButtonClicked",
"title": "History",
"icon": "$(history)"
},
{
"command": "kilo-code.new.sidebarTitle.profileButtonClicked",
"title": "Profile",
"icon": "$(account)"
},
{
"command": "kilo-code.new.sidebarTitle.settingsButtonClicked",
"title": "Settings",
"icon": "$(settings-gear)"
},
{
"command": "kilo-code.new.openInTab",
"title": "Open in Tab",
@@ -436,12 +477,76 @@
],
"menus": {
"commandPalette": [
{
"command": "kilo-code.new.sidebarTitle.plusButtonClicked",
"when": "false"
},
{
"command": "kilo-code.new.sidebarTitle.historyButtonClicked",
"when": "false"
},
{
"command": "kilo-code.new.sidebarTitle.agentManagerOpen",
"when": "false"
},
{
"command": "kilo-code.new.sidebarTitle.kiloClawOpen",
"when": "false"
},
{
"command": "kilo-code.new.agentManager.advancedWorktree",
"when": "false"
},
{
"command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked",
"when": "false"
},
{
"command": "kilo-code.new.sidebarTitle.profileButtonClicked",
"when": "false"
},
{
"command": "kilo-code.new.sidebarTitle.settingsButtonClicked",
"when": "false"
}
],
"view/title": [
{
"command": "kilo-code.new.sidebarTitle.plusButtonClicked",
"group": "navigation@0",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.historyButtonClicked",
"group": "navigation@1",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.agentManagerOpen",
"group": "navigation@2",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.kiloClawOpen",
"group": "navigation@3",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked",
"group": "navigation@4",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.profileButtonClicked",
"group": "navigation@5",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.sidebarTitle.settingsButtonClicked",
"group": "navigation@6",
"when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor"
}
],
"view/title": [],
"scm/title": [
{
"command": "kilo-code.new.generateCommitMessage",
@@ -461,6 +566,26 @@
"command": "kilo-code.new.openInTab",
"group": "navigation",
"when": "true"
},
{
"command": "kilo-code.new.plusButtonClicked",
"group": "navigation@0",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.historyButtonClicked",
"group": "navigation@1",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.profileButtonClicked",
"group": "navigation@2",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
},
{
"command": "kilo-code.new.settingsButtonClicked",
"group": "navigation@3",
"when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor"
}
],
"editor/context": [
@@ -1066,6 +1191,11 @@
"default": false,
"description": "Show tokens-per-second (prompt-processing / text-generation) badges on assistant messages and the task header"
},
"kilo-code.new.showAutoApprovalReason": {
"type": "boolean",
"default": true,
"description": "Show a line on tool calls explaining why they were auto-approved (matched rule, agent default, YOLO mode, etc.)"
},
"kilo-code.new.chat.shiftTabCyclesVariant": {
"type": "boolean",
"default": true,
@@ -1101,12 +1231,12 @@
"prepare:cli-binary": "bun script/local-bin.ts",
"prepare:sdk": "bun script/prepare-sdk.ts",
"build:launch": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check:production",
"compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run build:check",
"watch": "bun run rebuild-sdk && bun run --parallel watch:esbuild watch:tsc",
"compile": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check",
"watch": "bun run prepare:sdk && bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun run prepare:cli-binary && node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"watch:cli": "bun script/watch-cli.ts",
"package": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run build:check:production",
"package": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check:production",
"build:check": "bun run --parallel check-types check-types:webview lint bundle",
"build:check:production": "bun run --parallel check-types check-types:webview lint bundle:production",
"bundle": "bun esbuild.js",
@@ -1138,6 +1268,8 @@
},
"devDependencies": {
"@axe-core/playwright": "4.11.3",
"@babel/core": "^7.28.4",
"@babel/preset-typescript": "^7.27.1",
"@playwright/test": "1.57.0",
"@storybook/addon-a11y": "10.2.10",
"@storybook/addon-docs": "10.2.10",
@@ -1149,6 +1281,7 @@
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
"babel-preset-solid": "^1.9.9",
"esbuild": "^0.27.2",
"esbuild-plugin-solid": "^0.6.0",
"eslint": "^9.39.2",
+7 -5
View File
@@ -252,13 +252,15 @@ async function ensureBuiltBinary(): Promise<string> {
)
}
// Use the repository-pinned Bun version throughout. Newer canaries can fail compilation
// and must not cause packaged snapshots to fall back to the browser-mode source wrapper.
const pkg = await Bun.file(join(repoDir, "package.json")).json()
const bun = String(pkg.packageManager)
log("Installing dependencies in opencode package...")
await $`bunx ${bun} install --frozen-lockfile`.cwd(opencodeDir)
await $`bunx ${bun} run build --single --skip-install`.cwd(opencodeDir)
log("Building CLI binary...")
try {
await $`bunx ${bun} run build --single --skip-install`.cwd(opencodeDir)
} catch (err) {
log(`Pinned bunx build failed (${err}), running via active bun runtime...`)
await $`bun run script/build.ts --single --skip-install`.cwd(opencodeDir)
}
const built = await findKiloBinaryInOpencodeDist()
if (!built) {
+15 -2
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"
@@ -186,6 +186,10 @@ import {
import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project/paths"
import { validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"
import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings"
import {
buildAutoApprovalReasonSettingMessage,
watchAutoApprovalReasonConfig,
} from "./kilo-provider/auto-approval-reason-settings"
let maxCost = 0
@@ -419,6 +423,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private indexingConfigDisposable: vscode.Disposable | null = null
private chatConfigDisposable: vscode.Disposable | null = null
private throughputConfigDisposable: vscode.Disposable | null = null
private autoApprovalReasonConfigDisposable: vscode.Disposable | null = null
private telemetryStateDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
@@ -1002,6 +1007,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg))
this.throughputConfigDisposable?.dispose()
this.throughputConfigDisposable = watchThroughputConfig((msg) => this.postMessage(msg))
this.autoApprovalReasonConfigDisposable?.dispose()
this.autoApprovalReasonConfigDisposable = watchAutoApprovalReasonConfig((msg) => this.postMessage(msg))
this.telemetryStateDisposable?.dispose()
this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg))
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
@@ -1832,6 +1839,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage(buildThroughputSettingMessage())
this.postMessage(buildAutoApprovalReasonSettingMessage())
this.postMessage({ type: "extensionDataReady" })
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
@@ -4072,6 +4080,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage(buildThroughputSettingMessage())
this.postMessage(buildAutoApprovalReasonSettingMessage())
this.sendWorkStyle()
await ModelState.reset(this.client, (msg) => this.postMessage(msg))
@@ -4909,7 +4918,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
title: "Kilo Code",
port: this.connectionService.getServerInfo()?.port,
extraStyles: `.container { height: 100vh; }`,
topBar: this.opts.hideTopBar !== true,
// 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",
})
}
@@ -4998,6 +5010,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.indexingConfigDisposable?.dispose()
this.chatConfigDisposable?.dispose()
this.throughputConfigDisposable?.dispose()
this.autoApprovalReasonConfigDisposable?.dispose()
this.telemetryStateDisposable?.dispose()
this.autoApproveBridge?.dispose()
this.visibleTaskStreams.clear()
@@ -53,8 +53,10 @@ import {
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init"
import { createLocalDiff } from "./local-diff"
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
import { handleToolEvent } from "./tool-project"
import { sandboxSessionMetadata } from "../shared/sandbox-session"
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import { createOrchestrationBridge } from "./orchestration-setup"
import type { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import { pruneSubagents } from "./prune-subagents"
import { startSession } from "./mcp-warmup"
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
@@ -253,22 +255,20 @@ export class AgentManagerProvider implements Disposable {
this.statsPoller = pollers.stats
this.prBridge = pollers.pr
this.projectPollers = pollers.projects
this.orchestration = new AgentManagerOrchestrationBridge(this.connectionService, {
root: () => this.getRoot(),
state: () => this.state,
ready: async () => {
this.stateReady ??= this.initializeState()
await this.stateReady
return this.state
},
stats: () => this.statsPoller.snapshot(),
prs: () => this.prBridge.snapshot(),
push: () => this.pushState(),
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
close: async (id) => {
await this.onCloseSession(id)
this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id })
},
this.orchestration = createOrchestrationBridge({
connectionService: this.connectionService,
contexts: this.contexts,
projectScope: this.projectScope,
getRoot: () => this.getRoot(),
getState: () => this.state,
getStateReady: () => this.stateReady,
initStateReady: () => (this.stateReady = this.initializeState()),
getStats: () => this.statsPoller.snapshot(),
getPrs: () => this.prBridge.snapshot(),
pushState: (ctx) => this.pushState(ctx),
hasPanelSession: (id) => this.panelSessions.has(id),
closeSession: (id) => this.onCloseSession(id),
postSessionClosed: (id) => this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }),
log: (...args) => this.log(...args),
})
this.unsubTool = this.connectionService.onEventFiltered(
@@ -1083,14 +1083,16 @@ export class AgentManagerProvider implements Disposable {
}
private onToolEvent(event: unknown, directory?: string): void {
const properties = (event as { properties?: unknown }).properties
const req = parseToolRequest(properties)
if (!req) return
if (directory) {
req.directory = directory
req.projectId ??= this.contexts.byDirectory(directory)?.id
}
void this.startToolRequest(req)
handleToolEvent(
event,
directory,
{
byDirectory: (value) => this.contexts.byDirectory(value),
usable: (id) => this.contexts.usable(id),
},
this.projectScope,
(req) => this.startToolRequest(req),
)
}
private async startToolRequest(req: ToolRequest): Promise<void> {
@@ -1698,7 +1700,6 @@ export class AgentManagerProvider implements Disposable {
panel.reveal(false)
focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
}
public isActive(): boolean {
return this.panel?.active === true
}
@@ -1830,19 +1831,15 @@ export class AgentManagerProvider implements Disposable {
public async createFromSidebar(baseBranch?: string, branchName?: string): Promise<void> {
this.openPanel()
const panel = this.panel
if (!panel) return
if (!(await this.waitForPanelReady(panel))) return
if (!this.panel || !(await this.waitForPanelReady(this.panel))) return
await this.waitForStateReady("createFromSidebar")
await this.onCreateWorktree(baseBranch, branchName)
}
public async openAdvancedWorktree(): Promise<void> {
this.openPanel()
const panel = this.panel
if (!panel) return
if (!(await this.waitForPanelActive(panel))) return
if (!(await this.waitForPanelReady(panel))) return
if (!this.panel || !(await this.waitForPanelActive(this.panel)) || !(await this.waitForPanelReady(this.panel)))
return
await this.waitForStateReady("openAdvancedWorktree")
queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" }))
}
@@ -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
}
@@ -41,14 +41,15 @@ interface Failure {
}
interface Options {
root(): string | undefined
ready(): Promise<WorktreeStateManager | undefined>
state(): WorktreeStateManager | undefined
stats(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(): Map<string, PRStatus>
push(): void
managed(sessionID: string): boolean
close(sessionID: string): Promise<void>
root(directory?: string): string | undefined
ready(directory?: string): Promise<WorktreeStateManager | undefined>
state(directory?: string): WorktreeStateManager | undefined
stats(directory?: string): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(directory?: string): Map<string, PRStatus>
push(directory?: string): void
managed(sessionID: string, directory?: string): boolean
close(sessionID: string, directory?: string): Promise<void>
directories?(): string[]
log(...args: unknown[]): void
}
@@ -108,6 +109,7 @@ export class AgentManagerOrchestrationBridge {
})
})
this.unsubscribeDirectories = connection.registerDirectoryProvider(() => {
if (this.options.directories) return this.options.directories()
const root = this.options.root()
const dirs =
this.options
@@ -181,8 +183,8 @@ export class AgentManagerOrchestrationBridge {
}
private async admit(request: Request, directory: string): Promise<void> {
const state = await this.options.ready()
const root = this.options.root()
const state = await this.options.ready(directory)
const root = this.options.root(directory)
if (this.disposed || this.settled.has(request.id)) return
if (!state || !root) {
const accepted = await this.reject(request.id, directory, {
@@ -232,7 +234,7 @@ export class AgentManagerOrchestrationBridge {
private async run(request: Request, origin: Origin, active: Active): Promise<void> {
try {
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, active))
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin, active))
if (!outcome || this.disposed || active.cancelled) return
this.rememberOutcome(request.id, outcome)
const accepted =
@@ -248,10 +250,10 @@ export class AgentManagerOrchestrationBridge {
}
}
private async execute(request: Request, active: Active): Promise<Outcome | undefined> {
private async execute(request: Request, origin: Origin, active: Active): Promise<Outcome | undefined> {
try {
const state = await this.options.ready()
const root = this.options.root()
const state = await this.options.ready(origin.directory)
const root = this.options.root(origin.directory)
if (!state || !root)
throw new OrchestrationError("workspace_unavailable", "Agent Manager requires an open workspace")
if (this.disposed || active.cancelled) return
@@ -260,7 +262,7 @@ export class AgentManagerOrchestrationBridge {
// Git stats are refreshed by the poller independently. A forced refresh
// here can spawn one diff/ahead-behind pair per worktree and exceed the
// host request timeout before the overview can return its IDs.
const stats = await this.options.stats()
const stats = await this.options.stats(origin.directory)
if (this.disposed || active.cancelled) return
const result = await overview({
client,
@@ -269,7 +271,7 @@ export class AgentManagerOrchestrationBridge {
titles: this.titles,
filter: request.filter,
stats,
prs: this.options.prs(),
prs: this.options.prs(origin.directory),
})
return { result: { operation: "overview", overview: result } }
}
@@ -288,7 +290,7 @@ export class AgentManagerOrchestrationBridge {
}
if (request.operation === "move") {
move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID })
this.options.push()
this.options.push(origin.directory)
if (this.disposed || active.cancelled) return
return {
result: {
@@ -299,10 +301,10 @@ export class AgentManagerOrchestrationBridge {
},
}
}
if (!this.options.managed(request.targetSessionID)) {
if (!this.options.managed(request.targetSessionID, origin.directory)) {
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
}
await this.options.close(request.targetSessionID)
await this.options.close(request.targetSessionID, origin.directory)
if (this.disposed || active.cancelled) return
return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } }
} catch (error) {
@@ -319,6 +319,7 @@ export async function prompt(input: {
text: string
messageID: string
signal?: AbortSignal
idleTimeoutMs?: number
}): Promise<void> {
if (input.signal?.aborted) return
const managed = input.state.getSession(input.sessionID)
@@ -342,15 +343,7 @@ export async function prompt(input: {
if (!(await sameManagedDirectory(response.data.directory, dir))) {
throw new OrchestrationError("cross_workspace", "The managed session belongs to a different workspace directory")
}
const status = await input.client.session.status({ directory: dir })
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
const activity = status.data?.[input.sessionID]?.type ?? "idle"
if (activity !== "idle") {
throw new OrchestrationError(
"unavailable_session",
`The managed session is ${activity}; only idle sessions can be prompted`,
)
}
await waitForIdle(input.client, dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000)
if (input.signal?.aborted) return
await input.client.session.promptAsync(
{
@@ -364,6 +357,29 @@ export async function prompt(input: {
)
}
async function waitForIdle(
client: KiloClient,
directory: string,
sessionID: string,
signal: AbortSignal | undefined,
timeout: number,
start = Date.now(),
): Promise<void> {
if (signal?.aborted) return
const status = await client.session.status({ directory })
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
const activity = status.data?.[sessionID]?.type ?? "idle"
if (activity === "idle") return
if (Date.now() - start >= timeout) {
throw new OrchestrationError(
"unavailable_session",
`The managed session is still ${activity}; only idle sessions can be prompted`,
)
}
await new Promise<void>((resolve) => setTimeout(resolve, 250))
return waitForIdle(client, directory, sessionID, signal, timeout, start)
}
export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void {
const session = input.state.getSession(input.sessionID)
if (!session)
@@ -0,0 +1,78 @@
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import type { ProjectContexts } from "./project/contexts"
import type { ProjectContext } from "./project/context"
import type { ProjectScope } from "./project/scope"
import type { WorktreeStateManager } from "./WorktreeStateManager"
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
import type { PRStatus } from "./types"
import { initContextState } from "./project/init"
export interface OrchestrationBridgeDeps {
connectionService: KiloConnectionService
contexts: ProjectContexts
projectScope: ProjectScope
getRoot: () => string | undefined
getState: () => WorktreeStateManager | undefined
getStateReady: () => Promise<void> | undefined
initStateReady: () => Promise<void>
getStats: () => Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
getPrs: () => Map<string, PRStatus>
pushState: (ctx?: ProjectContext) => void
hasPanelSession: (id: string) => boolean
closeSession: (id: string) => Promise<unknown>
postSessionClosed: (id: string) => void
log: (...args: unknown[]) => void
}
export function createOrchestrationBridge(deps: OrchestrationBridgeDeps): AgentManagerOrchestrationBridge {
return new AgentManagerOrchestrationBridge(deps.connectionService, {
root: (dir) => (dir ? deps.contexts.byDirectory(dir)?.root : undefined) ?? deps.getRoot(),
state: (dir) => (dir ? deps.contexts.byDirectory(dir)?.peekState() : undefined) ?? deps.getState(),
ready: async (dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
if (ctx && ctx.id !== deps.contexts.active()?.id) {
await initContextState(ctx, (...args) => deps.log(...args))
return ctx.stateManager()
}
const ready = deps.getStateReady() ?? deps.initStateReady()
await ready
return deps.getState()
},
stats: () => deps.getStats(),
prs: () => deps.getPrs(),
push: (dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
deps.pushState(ctx)
},
managed: (id, dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
if (ctx) return ctx.hasLiveSession(id) || !!ctx.peekState()?.getSession(id)
return deps.hasPanelSession(id) || !!deps.getState()?.getSession(id)
},
close: async (id, dir) => {
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
if (ctx) {
await deps.projectScope.run(ctx, () => deps.closeSession(id))
} else {
await deps.closeSession(id)
}
deps.postSessionClosed(id)
},
directories: () => {
const all: string[] = []
for (const ctx of deps.contexts.values()) {
all.push(ctx.root)
for (const wt of ctx.peekState()?.getWorktrees() ?? []) {
if (wt.path) all.push(wt.path)
}
}
if (all.length === 0) {
const root = deps.getRoot()
if (root) all.push(root)
}
return all
},
log: (...args) => deps.log(...args),
})
}
@@ -100,6 +100,10 @@ export class ProjectContexts {
return this.contexts.get(id)
}
values(): IterableIterator<ProjectContext> {
return this.contexts.values()
}
/** The context that owns a directory: its root or one of its worktree paths. */
byDirectory(dir: string): ProjectContext | undefined {
for (const ctx of this.contexts.values()) {
@@ -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 })
}
@@ -51,6 +51,7 @@ interface Entry {
export class TerminalManager {
private readonly entries = new Map<string, Entry>()
private readonly restarts = new Map<string, Promise<void>>()
private readonly pending = new Map<string, { cols: number; rows: number }>()
constructor(private readonly deps: TerminalManagerDeps) {}
@@ -67,7 +68,14 @@ export class TerminalManager {
worktreeId: string | null
cwd: string
title: string
cols?: number
rows?: number
}): Promise<{ terminalId: string; worktreeId: string | null; title: string; wsUrl: string }> {
const initial =
this.pending.get(params.terminalId) ??
(params.cols !== undefined && params.rows !== undefined ? { cols: params.cols, rows: params.rows } : undefined)
this.pending.delete(params.terminalId)
const client = this.deps.getClient()
const { data, error } = await client.pty.create({
directory: params.cwd,
@@ -76,6 +84,7 @@ export class TerminalManager {
// xterm's DOM renderer cannot draw the Unicode sextant glyphs used by
// Kilo's modern wordmark, so use the compatible logo in embedded tabs.
env,
size: initial,
})
if (error || !data) {
const err = error instanceof Error ? error.message : String(error ?? "unknown error")
@@ -89,15 +98,39 @@ export class TerminalManager {
title: data.title ?? params.title,
}
this.entries.set(params.terminalId, entry)
// If a resize arrived while pty.create was in flight that differed from `initial`, apply it now.
const latest = this.pending.get(params.terminalId)
if (latest && (latest.cols !== initial?.cols || latest.rows !== initial?.rows)) {
this.pending.delete(params.terminalId)
const { error: resizeErr } = await client.pty.update({
directory: entry.cwd,
ptyID: entry.ptyID,
size: latest,
})
if (resizeErr) {
const err = resizeErr instanceof Error ? resizeErr.message : String(resizeErr)
this.deps.log(`Initial terminal resize failed (${params.terminalId}): ${err}`)
}
}
const wsUrl = this.deps.buildWsUrl(entry.ptyID, entry.cwd)
this.deps.log(`Terminal created: ${params.terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`)
return { terminalId: params.terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl }
}
/** Forward a resize event to the backend PTY. Missing terminals are a no-op. */
/**
* Forward a resize event to the backend PTY.
*
* If the terminal creation is still in flight, dimensions are queued into
* `pending` and applied during PTY initialization before the WebSocket
* URL is returned.
*/
async resize(terminalId: string, cols: number, rows: number): Promise<void> {
const entry = this.entries.get(terminalId)
if (!entry) return
if (!entry) {
this.pending.set(terminalId, { cols, rows })
return
}
this.pending.delete(terminalId)
const client = this.deps.getClient()
const { error } = await client.pty.update({
directory: entry.cwd,
@@ -126,6 +159,7 @@ export class TerminalManager {
* failed delete would be silently logged as a successful close and
* the server-side PTY would linger until `kilo serve` exits. */
async close(terminalId: string): Promise<void> {
this.pending.delete(terminalId)
const entry = this.entries.get(terminalId)
if (!entry) return
this.entries.delete(terminalId)
@@ -187,6 +221,7 @@ export class TerminalManager {
* is sampled mid-shutdown.
*/
async dispose(): Promise<void> {
this.pending.clear()
const snapshot = [...this.entries.values()]
if (snapshot.length === 0) {
this.entries.clear()
@@ -88,7 +88,7 @@ export class TerminalRouter {
handle(m: AgentManagerInMessage): boolean {
if (!isTerminalMessage(m)) return false
if (m.type === "agentManager.terminal.create") {
void this.handleCreate(m.createId, m.placement, m.worktreeId)
void this.handleCreate(m.createId, m.placement, m.worktreeId, m.cols, m.rows)
return true
}
if (m.type === "agentManager.terminal.close") {
@@ -132,7 +132,13 @@ export class TerminalRouter {
return manager.dispose()
}
private async handleCreate(createId: string, placement: TerminalPlacement, worktreeId: string | null): Promise<void> {
private async handleCreate(
createId: string,
placement: TerminalPlacement,
worktreeId: string | null,
cols?: number,
rows?: number,
): Promise<void> {
const generation = this.generation
const manager = this.manager
const cwd = this.resolveCwd(worktreeId)
@@ -155,7 +161,7 @@ export class TerminalRouter {
// Join the shared backend connection instead of racing its synchronous
// client accessor when this is the first Kilo action in the window.
await this.deps.getClientAsync()
const created = await manager.create({ terminalId: createId, worktreeId, cwd, title })
const created = await manager.create({ terminalId: createId, worktreeId, cwd, title, cols, rows })
if (generation !== this.generation) {
await manager.close(created.terminalId)
return
@@ -0,0 +1,32 @@
import type { ToolRequest } from "./tool-start"
import { parseToolRequest } from "./tool-start"
export function routeToolRequest<T extends { projectId?: string; directory?: string }, C extends { id: string }>(
input: T,
directory: string | undefined,
deps: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined },
): { request: T; owner?: C } {
const request = directory ? { ...input, directory } : input
const owner =
(directory && deps.byDirectory(directory)) ?? (request.projectId ? deps.usable(request.projectId) : undefined)
if (!owner) return { request }
return { request: { ...request, projectId: owner.id }, owner }
}
export function handleToolEvent<C extends { id: string }>(
event: unknown,
directory: string | undefined,
contexts: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined },
scope: { run: <T>(owner: C, fn: () => Promise<T>) => Promise<T> },
start: (req: ToolRequest) => Promise<void>,
): void {
const properties = (event as { properties?: unknown }).properties
const req = parseToolRequest(properties)
if (!req) return
const routed = routeToolRequest(req, directory, contexts)
if (routed.owner) {
void scope.run(routed.owner, () => start(routed.request))
return
}
void start(routed.request)
}
@@ -961,6 +961,8 @@ interface TerminalCreateIn {
placement: TerminalPlacement
/** null for LOCAL, worktree id otherwise */
worktreeId: string | null
cols?: number
rows?: number
}
interface TerminalCloseIn {
@@ -30,14 +30,17 @@ export class WorktreeImporter {
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)
+37 -1
View File
@@ -17,7 +17,7 @@ import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-bac
import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager"
import { AttentionService } from "./services/attention"
import { BrowserAutomationService } from "./services/browser-automation"
import { TelemetryProxy } from "./services/telemetry"
import { TelemetryEventName, TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
import { registerToggleAutoApprove } from "./commands/toggle-auto-approve"
@@ -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)
@@ -339,8 +344,39 @@ export function activate(context: vscode.ExtensionContext) {
}),
)
// Sidebar menus use wrapper commands so this event measures real title button presses,
// not programmatic opens, shortcuts, or editor title commands.
const track = (button: string, command: string) => {
TelemetryProxy.capture(TelemetryEventName.TITLE_BUTTON_CLICKED, {
button,
surface: "sidebar_title",
})
void vscode.commands.executeCommand(command)
}
// Register toolbar button command handlers
context.subscriptions.push(
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.plusButtonClicked", () => {
track("new_task", "kilo-code.new.plusButtonClicked")
}),
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.historyButtonClicked", () => {
track("history", "kilo-code.new.historyButtonClicked")
}),
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.agentManagerOpen", () => {
track("agent_manager", "kilo-code.new.agentManagerOpen")
}),
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.kiloClawOpen", () => {
track("kiloclaw", "kilo-code.new.kiloClawOpen")
}),
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.marketplaceButtonClicked", () => {
track("marketplace", "kilo-code.new.marketplaceButtonClicked")
}),
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.profileButtonClicked", () => {
track("profile", "kilo-code.new.profileButtonClicked")
}),
vscode.commands.registerCommand("kilo-code.new.sidebarTitle.settingsButtonClicked", () => {
track("settings", "kilo-code.new.settingsButtonClicked")
}),
vscode.commands.registerCommand("kilo-code.new.plusButtonClicked", () => {
const tab = activeTabProvider()
if (tab) tab.postMessage({ type: "action", action: "plusButtonClicked" })
@@ -0,0 +1,19 @@
import * as vscode from "vscode"
type Post = (msg: unknown) => void
export function buildAutoApprovalReasonSettingMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new")
return {
type: "autoApprovalReasonSettingLoaded" as const,
visible: config.get<boolean>("showAutoApprovalReason", true),
}
}
export function watchAutoApprovalReasonConfig(post: Post): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("kilo-code.new.showAutoApprovalReason")) {
post(buildAutoApprovalReasonSettingMessage())
}
})
}
@@ -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 { buildAutoApprovalReasonSettingMessage } from "./auto-approval-reason-settings"
import { handleModelUsageMessage, type ModelUsageMessage } from "./model-usage"
type Ctx = {
@@ -71,6 +72,10 @@ export async function routeEarlyMessage(
ctx.post(buildThroughputSettingMessage())
return true
}
if (message.type === "requestAutoApprovalReasonSetting") {
ctx.post(buildAutoApprovalReasonSettingMessage())
return true
}
if (message.type === "requestSpeechToTextModels") {
await ctx.speechToTextModels()
return true
@@ -24,7 +24,7 @@ type Recording = Input & {
type Audio = {
data: string
format: "wav"
format: "m4a"
model: string
language?: string
}
@@ -39,16 +39,17 @@ 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)
if (!recorder || !recorder.prepareToRecord || !recorder.record) throw new Error("Could not start recording")
if (!recorder || !recorder.prepareToRecord || !recorder.record) {
const description = error[0] && error[0].localizedDescription
throw new Error(description ? description.js : "Could not start recording")
}
console.log("ready")
$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
recorder.stop
@@ -68,7 +69,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 +106,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 +169,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 +208,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 +242,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))
+21
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 ")
@@ -32,6 +32,19 @@ async function assertRowContained(row: Locator, card: Locator, label: string) {
)
}
async function assertTooltipFitsViewport(content: Locator, label: string, page: Page) {
const tipBox = await content.boundingBox()
const viewport = page.viewportSize()!
expect(tipBox, `${label}: tooltip bounding box`).not.toBeNull()
// Kobalte's PopperRoot defaults to overflowPadding: 8, so the floating
// tooltip is allowed to extend up to 8px past each viewport edge before
// the shift middleware stops nudging it.
expect(tipBox!.x, `${label}: tooltip left edge inside viewport`).toBeGreaterThanOrEqual(-9)
expect(tipBox!.x + tipBox!.width, `${label}: tooltip right edge inside viewport`).toBeLessThanOrEqual(
viewport.width + 9,
)
}
test.describe("skills settings responsive layout", () => {
test("folder-path and URL rows stay contained and the × button remains visible at a narrow viewport", async ({
page,
@@ -73,6 +86,7 @@ test.describe("skills settings responsive layout", () => {
await trigger.hover()
const content = page.locator('[data-component="tooltip"]').filter({ hasText: seeded })
await expect(content, `Kilo Tooltip exposes full path on hover: ${seeded}`).toBeVisible()
await assertTooltipFitsViewport(content, `path tooltip "${seeded}"`, page)
}
for (const seeded of [SEEDED_URL, SEEDED_URL_2]) {
@@ -99,6 +113,7 @@ test.describe("skills settings responsive layout", () => {
await trigger.hover()
const content = page.locator('[data-component="tooltip"]').filter({ hasText: seeded })
await expect(content, `Kilo Tooltip exposes full URL on hover: ${seeded}`).toBeVisible()
await assertTooltipFitsViewport(content, `URL tooltip "${seeded}"`, page)
}
for (const [label, card] of [
@@ -50,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"),
@@ -64,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")
@@ -365,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",
)
@@ -375,8 +376,9 @@ 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", () => {
@@ -459,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()
@@ -865,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
@@ -873,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()")
})
})
@@ -33,7 +33,9 @@ describe("AgentManagerOrchestrationBridge", () => {
fs.rmSync(root, { recursive: true, force: true })
})
function harness() {
function harness(
overrides?: Partial<Parameters<(typeof AgentManagerOrchestrationBridge.prototype)["constructor"]>[1]>,
) {
const replies: unknown[] = []
const rejections: unknown[] = []
const lists = new Map<string, AgentManagerRequest[]>()
@@ -49,8 +51,8 @@ describe("AgentManagerOrchestrationBridge", () => {
const push = mock(() => undefined)
const client = {
session: {
get: mock(async () => ({
data: { id: "ses_target", directory: dir, title: "Target" } as Session,
get: mock(async ({ sessionID, directory }: { sessionID?: string; directory?: string }) => ({
data: { id: sessionID ?? "ses_target", directory: directory ?? dir, title: "Target" } as Session,
})),
status: mock(async () => ({ data: {} })),
promptAsync,
@@ -100,17 +102,17 @@ describe("AgentManagerOrchestrationBridge", () => {
getClient: () => client,
}
const bridge = new AgentManagerOrchestrationBridge(connection as never, {
root: () => root,
ready: async () => state,
state: () => state,
stats: async () => {
root: (dir) => (overrides?.root ? overrides.root(dir) : root),
ready: async (dir) => (overrides?.ready ? overrides.ready(dir) : state),
state: (dir) => (overrides?.state ? overrides.state(dir) : state),
stats: async (dir) => {
statsCalls.push(1)
return { worktrees: [] }
return overrides?.stats ? overrides.stats(dir) : { worktrees: [] }
},
prs: () => new Map(),
push,
managed: (id) => managed.has(id),
close,
prs: (dir) => (overrides?.prs ? overrides.prs(dir) : new Map()),
push: (dir) => (overrides?.push ? overrides.push(dir) : push()),
managed: (id, dir) => (overrides?.managed ? overrides.managed(id, dir) : managed.has(id)),
close: async (id, dir) => (overrides?.close ? overrides.close(id, dir) : close(id, dir)),
log: () => undefined,
})
const request = (value: AgentManagerRequest, directory = root) =>
@@ -195,7 +197,7 @@ describe("AgentManagerOrchestrationBridge", () => {
await waitFor(() => test.replies.length === 2)
expect(test.close).toHaveBeenCalledTimes(1)
expect(test.close).toHaveBeenCalledWith("ses_target")
expect(test.close).toHaveBeenCalledWith("ses_target", root)
expect(test.replies).toEqual([
{
requestID: "amr_stop",
@@ -294,7 +296,7 @@ describe("AgentManagerOrchestrationBridge", () => {
await waitFor(() => test.replies.length === 1)
expect(state.getSession("ses_live")).toBeUndefined()
expect(test.close).toHaveBeenCalledWith("ses_live")
expect(test.close).toHaveBeenCalledWith("ses_live", root)
expect(test.replies[0]).toEqual({
requestID: "amr_stop_live",
directory: root,
@@ -389,4 +391,39 @@ describe("AgentManagerOrchestrationBridge", () => {
expect(test.promptAsync).toHaveBeenCalledTimes(1)
test.bridge.dispose()
})
it("handles requests for secondary project directories in multi-project mode", async () => {
const secondaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "am-orchestration-secondary-"))
fs.mkdirSync(path.join(secondaryRoot, ".kilo"), { recursive: true })
const secondaryState = new WorktreeStateManager(secondaryRoot, () => undefined)
secondaryState.addSession("ses_secondary", null)
const test = harness({
root: (d) => (d === secondaryRoot ? secondaryRoot : root),
ready: async (d) => (d === secondaryRoot ? secondaryState : state),
state: (d) => (d === secondaryRoot ? secondaryState : state),
})
test.request(
{
id: "amr_secondary",
sessionID: "ses_caller",
operation: "prompt",
targetSessionID: "ses_secondary",
prompt: "Hello from secondary",
},
secondaryRoot,
)
await waitFor(() => test.replies.length === 1)
expect(test.promptAsync).toHaveBeenCalledTimes(1)
expect(test.replies[0]).toEqual({
requestID: "amr_secondary",
directory: secondaryRoot,
result: { operation: "prompt", sessionID: "ses_secondary", delivered: true },
})
test.bridge.dispose()
await secondaryState.flush()
fs.rmSync(secondaryRoot, { recursive: true, force: true })
})
})
@@ -202,6 +202,25 @@ describe("Agent Manager orchestration domain", () => {
)
})
it("waits for a busy managed session to become idle before prompting", async () => {
const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" })
state.addSession("ses_wait", managed.id)
let calls = 0
const promptAsync = mock(async () => ({ data: undefined }))
const client = {
session: {
get: mock(async () => ({ data: { id: "ses_wait", directory: worktree, title: "Wait" } as Session })),
status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })),
promptAsync,
},
} as unknown as KiloClient
await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" })
expect(client.session.status).toHaveBeenCalledTimes(2)
expect(promptAsync).toHaveBeenCalledTimes(1)
})
it("rejects unknown, stale, cross-workspace, and busy targets", async () => {
const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" })
state.addSession("ses_target", managed.id)
@@ -231,7 +250,15 @@ describe("Agent Manager orchestration domain", () => {
data: { ses_target: { type: "busy" } },
}))
await expect(
prompt({ client, root, state, sessionID: "ses_target", text: "Continue", messageID: "amr_busy" }),
prompt({
client,
root,
state,
sessionID: "ses_target",
text: "Continue",
messageID: "amr_busy",
idleTimeoutMs: 0,
}),
).rejects.toMatchObject({
code: "unavailable_session",
} satisfies Partial<OrchestrationError>)
@@ -39,6 +39,29 @@ test("does not refit hidden terminal buffers during resize", () => {
expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()"))
})
test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => {
expect(terminal).toContain("convertEol: false")
expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"')
expect(terminal.indexOf("term.loadAddon(new UnicodeGraphemesAddon())")).toBeLessThan(
terminal.indexOf("open(props.wsUrl)"),
)
})
test("fits and forces the initial PTY dimensions before socket attach", () => {
expect(terminal).toContain("const syncSize = (force = false)")
expect(terminal).toContain("if (props.active) syncSize(true)")
expect(terminal.indexOf("fitNow()\n open(props.wsUrl)")).toBeGreaterThan(-1)
})
test("re-sends dimensions when an optimistic terminal receives its PTY", () => {
const created = terminal.match(
/if \(message\.terminalId === props\.terminalId && !ws\) \{([\s\S]*?)\n \}/,
)?.[1]
expect(created).toBeDefined()
expect(created).toContain("fitNow()")
expect(created!.indexOf("fitNow()")).toBeLessThan(created!.indexOf("open(message.wsUrl)"))
})
test("clamps the restored inspector width to the shared layout bounds", () => {
expect(clampPanelWidth(undefined, 1200)).toBe(600)
expect(clampPanelWidth(500, 1200)).toBe(500)
@@ -343,4 +343,62 @@ describe("Agent Manager terminal routing", () => {
})
await router.dispose()
})
it("applies initial create dimensions and queues resize messages before creation settles", async () => {
const creates: Array<Record<string, unknown>> = []
const updates: Array<{ ptyID: string; size?: { cols: number; rows: number } }> = []
let createResolver: ((value: { data: { id: string; title: string } }) => void) | undefined
const client = {
pty: {
create: (params: Record<string, unknown>) =>
new Promise<{ data: { id: string; title: string } }>((resolve) => {
creates.push(params)
createResolver = resolve
}),
remove: async () => ({ data: true }),
update: async (params: { ptyID: string; size?: { cols: number; rows: number } }) => {
updates.push(params)
return { data: true }
},
},
} as unknown as KiloClient
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: () => undefined,
getTerminalFont: () => font,
})
router.handle({
type: "agentManager.terminal.create",
createId: "queued",
placement: "side",
worktreeId: null,
cols: 60,
rows: 20,
})
await wait()
expect(creates).toHaveLength(1)
expect(creates[0]?.size).toEqual({ cols: 60, rows: 20 })
// Send a resize before pty.create settles (optimistic side terminal layout)
router.handle({
type: "agentManager.terminal.resize",
terminalId: "queued",
cols: 55,
rows: 18,
})
await wait()
expect(updates).toHaveLength(0)
createResolver?.({ data: { id: "pty-queued", title: "Terminal 1" } })
await wait()
expect(updates).toEqual([{ directory: "/workspace", ptyID: "pty-queued", size: { cols: 55, rows: 18 } }])
await router.dispose()
})
})
@@ -83,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")
@@ -0,0 +1,31 @@
import { describe, expect, it } from "bun:test"
import { routeToolRequest } from "../../src/agent-manager/tool-project"
describe("Agent Manager tool project routing", () => {
it("routes by the event directory before any explicit project id", () => {
const secondary = { id: "prj-secondary" }
const request = routeToolRequest({ requestID: "am-1", projectId: "prj-active", mode: "worktree" }, "/secondary", {
byDirectory: (dir) => (dir === "/secondary" ? secondary : undefined),
usable: () => ({ id: "prj-active" }),
})
expect(request.owner).toBe(secondary)
expect(request.request).toEqual({
requestID: "am-1",
projectId: "prj-secondary",
mode: "worktree",
directory: "/secondary",
})
})
it("uses an explicit usable project when no event directory is available", () => {
const project = { id: "prj-secondary" }
const request = routeToolRequest({ requestID: "am-2", projectId: "prj-secondary", mode: "local" }, undefined, {
byDirectory: () => undefined,
usable: (id) => (id === project.id ? project : undefined),
})
expect(request.owner).toBe(project)
expect(request.request.projectId).toBe("prj-secondary")
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { builtinModules } from "node:module"
const ROOT = path.resolve(import.meta.dir, "../..")
const PKG_FILE = path.join(ROOT, "package.json")
const ESBUILD_FILE = path.join(ROOT, "esbuild.js")
const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
function extractPackageName(specifier: string): string | null {
if (specifier.startsWith(".") || specifier.startsWith("/")) return null
if (BUILTINS.has(specifier)) return null
if (specifier.startsWith("node:")) return null
if (specifier.startsWith("@")) {
const parts = specifier.split("/")
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier
}
return specifier.split("/")[0]
}
function findImportsAndRequires(content: string): string[] {
const specifiers = new Set<string>()
const requireRegex = /require\(["']([^"']+)["']\)/g
const importRegex = /(?:import|from)\s+["']([^"']+)["']/g
for (const match of content.matchAll(requireRegex)) {
specifiers.add(match[1])
}
for (const match of content.matchAll(importRegex)) {
specifiers.add(match[1])
}
return Array.from(specifiers)
}
describe("Build Script Dependency Declarations", () => {
it("esbuild.js must declare all imported/required packages in package.json", () => {
const pkg = JSON.parse(fs.readFileSync(PKG_FILE, "utf8"))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
"vscode",
])
const esbuildContent = fs.readFileSync(ESBUILD_FILE, "utf8")
const specifiers = findImportsAndRequires(esbuildContent)
const undeclared: string[] = []
for (const spec of specifiers) {
const pkgName = extractPackageName(spec)
if (pkgName && !declared.has(pkgName)) {
undeclared.push(`${spec} (package: ${pkgName})`)
}
}
expect(undeclared).toEqual([])
})
})
@@ -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"')
})
@@ -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,81 @@
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("error[0] && error[0].localizedDescription")
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()
})
})
@@ -24,17 +24,24 @@ afterEach(async () => {
)
})
function gitExec(args: string[]) {
const res = Bun.spawnSync(args, { stdout: "ignore", stderr: "pipe" })
if (res.exitCode !== 0) {
const err = Buffer.from(res.stderr).toString("utf8")
throw new Error(`git command failed (${args.join(" ")}): ${err}`)
}
}
/** Create a temp git repo with an initial commit (required for worktrees). */
async function createTempRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-"))
tempDirs.push(dir)
const git = simpleGit(dir)
await git.init()
await git.addConfig("user.email", "test@test.com")
await git.addConfig("user.name", "Test")
gitExec(["git", "init", "-b", "main", dir])
gitExec(["git", "-C", dir, "config", "user.email", "test@test.com"])
gitExec(["git", "-C", dir, "config", "user.name", "Test"])
await fs.writeFile(path.join(dir, "README.md"), "init")
await git.add(".")
await git.commit("initial commit")
gitExec(["git", "-C", dir, "add", "."])
gitExec(["git", "-C", dir, "commit", "-m", "initial commit"])
return dir
}
@@ -51,31 +58,18 @@ async function changedFiles(cwd: string): Promise<string[]> {
/** Create a temp repo with a bare origin remote so origin/<branch> refs exist. */
async function createTempRepoWithOrigin(): Promise<{ bare: string; clone: string }> {
// Use a non-bare seed repo to control the initial branch name, then clone bare
const seed = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-seed-"))
tempDirs.push(seed)
const seedGit = simpleGit(seed)
await seedGit.init()
await seedGit.addConfig("user.email", "test@test.com")
await seedGit.addConfig("user.name", "Test")
await fs.writeFile(path.join(seed, "README.md"), "init")
await seedGit.add(".")
await seedGit.commit("initial commit")
// Ensure branch is named "main" regardless of system default
const seedBranch = (await seedGit.revparse(["--abbrev-ref", "HEAD"])).trim()
if (seedBranch !== "main") await seedGit.raw(["branch", "-m", seedBranch, "main"])
// Clone to bare, then clone again as working copy
const bare = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-bare-"))
tempDirs.push(bare)
await simpleGit().clone(seed, bare, ["--bare"])
const clone = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-clone-"))
tempDirs.push(clone)
await simpleGit().clone(bare, clone)
const cloneGit = simpleGit(clone)
await cloneGit.addConfig("user.email", "test@test.com")
await cloneGit.addConfig("user.name", "Test")
tempDirs.push(bare, clone)
gitExec(["git", "init", "--bare", "-b", "main", bare])
gitExec(["git", "clone", bare, clone])
gitExec(["git", "-C", clone, "config", "user.email", "test@test.com"])
gitExec(["git", "-C", clone, "config", "user.name", "Test"])
await fs.writeFile(path.join(clone, "README.md"), "init")
gitExec(["git", "-C", clone, "add", "."])
gitExec(["git", "-C", clone, "commit", "-m", "initial commit"])
gitExec(["git", "-C", clone, "push", "-u", "origin", "main"])
return { bare, clone }
}
@@ -77,6 +77,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"
@@ -154,7 +155,6 @@ import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
import { randomColor } from "./section-colors"
import { createMarkdownRender } from "./review-preferences"
import { createChangeDefaultBaseBranch } from "./default-base-branch"
import { createSidebarCollapse } from "./sidebar-collapse"
import { createNewTaskDrafts } from "./new-task-drafts"
import {
@@ -199,39 +199,10 @@ interface SetupState {
type SidebarSelection = typeof LOCAL | string | null
export type SidePanelState = SidePanel | 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 SIDE_RESIZE_INTERVAL_MS = 32
const defaultBindings: Record<string, string> = {
previousSession: isMac ? "⌘⌥↑" : "Ctrl+Alt+↑",
nextSession: isMac ? "⌘⌥↓" : "Ctrl+Alt+↓",
previousTab: isMac ? "⌘⌥←" : "Ctrl+Alt+←",
nextTab: isMac ? "⌘⌥→" : "Ctrl+Alt+→",
previousTerminal: isMac ? "⌘⇧[" : "Ctrl+Shift+[",
nextTerminal: isMac ? "⌘⇧]" : "Ctrl+Shift+]",
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}`]),
),
}
import { parseBindingTokens } from "./keybind-tokens"
import { defaultBindings } from "./keybind-defaults"
const AgentManagerContent: Component = () => {
const { t } = useLanguage()
@@ -280,7 +251,6 @@ const AgentManagerContent: Component = () => {
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
@@ -1791,16 +1761,17 @@ const AgentManagerContent: Component = () => {
}
const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript)
const handleChangeDefaultBaseBranch = createChangeDefaultBaseBranch({
vscode,
dialog,
t,
defaultBaseBranch,
setDefaultBaseBranch,
setRepoDetectedBranch,
repoDetectedBranch,
hasConfiguredBranch,
})
const handleChangeDefaultBaseBranch = () => {
dialog.show(() => (
<DefaultBaseBranchDialog
selected={defaultBaseBranch()}
detected={repoDetectedBranch()}
onSelect={setDefaultBaseBranch}
onDetected={setRepoDetectedBranch}
onClose={() => dialog.close()}
/>
))
}
const handleShowKeyboardShortcuts = () => {
const categories = buildShortcutCategories(kb(), t)
@@ -2454,9 +2425,6 @@ const AgentManagerContent: Component = () => {
onTogglePR={togglePRPanel}
terminalDestination={sideCtl.destination}
terminalDestinationActive={() => sidePanel() === SidePanel.Terminal}
terminalDestinationFocused={() =>
sideCtl.destination() === "agentManager" && terms.sideFocusedId() !== undefined
}
terminalKeybind={() => kb().showTerminal ?? ""}
onTerminalDestinationOpen={() => {
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>
)
}
@@ -36,7 +36,6 @@ export const ProjectsSection: Component<ProjectsSectionProps> = (props) => (
label={<span class="am-section-label">{props.t("agentManager.projects")}</span>}
actions={
<div class="am-projects-tools">
{props.tools}
<IconButton
icon="plus"
size="small"
@@ -44,6 +43,7 @@ export const ProjectsSection: Component<ProjectsSectionProps> = (props) => (
label={props.t("agentManager.project.add")}
onClick={props.onAdd}
/>
{props.tools}
</div>
}
/>
@@ -60,7 +60,6 @@ export interface TabBarProps {
onTogglePR: () => void
terminalDestination: () => TerminalDestination
terminalDestinationActive: () => boolean
terminalDestinationFocused: () => boolean
terminalKeybind: () => string
onTerminalDestinationOpen: () => void
onTerminalDestinationChoose: (destination: TerminalDestination) => void
@@ -281,7 +280,6 @@ export const TabBar: Component<TabBarProps> = (props) => (
<TerminalDestinationButton
destination={props.terminalDestination}
active={props.terminalDestinationActive}
focused={props.terminalDestinationFocused}
keybind={props.terminalKeybind}
onOpen={props.onTerminalDestinationOpen}
onChoose={props.onTerminalDestinationChoose}
@@ -281,12 +281,14 @@ html[data-theme="kilo-vscode"]
.am-project-body .am-section-header {
padding-left: 6px;
padding-right: 6px;
}
/* This heading sits outside the projects list, so it needs its own inset to
land on the same glyph line as the rows inside the list. */
.am-projects > .am-section-header {
padding-left: 2px;
padding-right: 2px;
}
.am-section-label {
@@ -328,7 +330,11 @@ html[data-theme="kilo-vscode"]
.am-project-actions {
display: flex;
align-items: center;
gap: 2px;
justify-content: space-between;
/* The project row needs 20px + 24px + 20px; the worktree row uses the
remaining space for its split-button and settings gap. */
width: 64px;
gap: 0;
}
.am-project {
@@ -0,0 +1,32 @@
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const MAX_JUMP_INDEX = 9
/** Fallback keybindings before the extension sends resolved ones. */
export const defaultBindings: Record<string, string> = {
previousSession: isMac ? "⌘⌥↑" : "Ctrl+Alt+↑",
nextSession: isMac ? "⌘⌥↓" : "Ctrl+Alt+↓",
previousTab: isMac ? "⌘⌥←" : "Ctrl+Alt+←",
nextTab: isMac ? "⌘⌥→" : "Ctrl+Alt+→",
previousTerminal: isMac ? "⌘⇧[" : "Ctrl+Shift+[",
nextTerminal: isMac ? "⌘⇧]" : "Ctrl+Shift+]",
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}`]),
),
}
@@ -20,8 +20,6 @@ interface Props {
destination: Accessor<TerminalDestination>
/** True while the embedded terminal panel is showing. */
active: Accessor<boolean>
/** True while the embedded terminal owns DOM focus. */
focused: Accessor<boolean>
keybind: Accessor<string>
onOpen: () => void
onChoose: (destination: TerminalDestination) => void
@@ -29,10 +27,6 @@ interface Props {
export const TerminalDestinationButton: Component<Props> = (props) => {
const { t } = useLanguage()
const title = () =>
props.destination() === "agentManager" && props.focused()
? t("agentManager.shortcuts.toggleTerminal")
: t("agentManager.tab.openTerminal")
const item = (destination: TerminalDestination, label: string) => (
<DropdownMenu.Item onSelect={() => props.onChoose(destination)}>
<span class="am-menu-check">
@@ -45,7 +39,7 @@ export const TerminalDestinationButton: Component<Props> = (props) => {
)
return (
<div class="am-split-button">
<TooltipKeybind title={title()} keybind={props.keybind()} placement="bottom">
<TooltipKeybind title={t("agentManager.tab.terminal")} keybind={props.keybind()} placement="bottom">
<IconButton
icon="console"
size="small"
@@ -152,7 +152,10 @@ export const TerminalTab: Component<Props> = (props) => {
onMount(() => {
const term = new Terminal({
convertEol: true,
// PTY output already contains terminal line endings. Converting LF to
// CRLF here corrupts raw PTY output and is especially visible when a
// narrow terminal wraps and redraws the prompt.
convertEol: false,
cursorBlink: true,
cursorInactiveStyle: "outline",
fontFamily: props.font.fontFamily,
@@ -164,17 +167,11 @@ export const TerminalTab: Component<Props> = (props) => {
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
// Fit on the next frame — `host` might still have 0px dimensions
// during the initial layout pass otherwise.
requestAnimationFrame(() => {
try {
fit.fit()
} catch (err) {
// Host still detached at mount time. ResizeObserver will retry
// once layout kicks in. Logged so regressions don't hide.
log("initial fit() threw", err)
}
})
// Unicode width must be configured before the first PTY bytes are parsed.
// Loading it later can leave already-wrapped graphemes with stale cell
// widths, which moves the cursor in narrow terminals.
term.loadAddon(new UnicodeGraphemesAddon())
term.unicode.activeVersion = "15-graphemes"
// Pass Agent Manager hotkeys through to the parent key handler so
// ⌘T / ⌘⇧T / ⌘W / terminal cycling / ⌘⌥← still work while focused.
@@ -356,11 +353,9 @@ export const TerminalTab: Component<Props> = (props) => {
}
const disposeData = term.onData(send)
const disposeBinary = term.onBinary(send)
open(props.wsUrl)
// These addons are not needed to paint the initial prompt. Defer them
// until after the first frame so their startup work, especially the
// Unicode 15 width tables, does not delay the shell connection.
// until after the first frame so their startup work does not delay the
// shell connection.
const loadAddons = () => {
deferred = undefined
if (closed) return
@@ -375,16 +370,8 @@ export const TerminalTab: Component<Props> = (props) => {
)
// OSC 52 clipboard support for shell programs such as tmux and neovim.
term.loadAddon(new ClipboardAddon())
// Use grapheme-aware width tables for newer emoji and ZWJ sequences.
term.loadAddon(new UnicodeGraphemesAddon())
term.unicode.activeVersion = "15-graphemes"
term.refresh(0, Math.max(0, term.rows - 1))
}
frame = requestAnimationFrame(() => {
frame = undefined
deferred = requestAnimationFrame(loadAddons)
})
const restarted = (url: string) => {
open(url)
}
@@ -395,10 +382,12 @@ export const TerminalTab: Component<Props> = (props) => {
let resizeTimer: ReturnType<typeof setTimeout> | undefined
let lastCols = term.cols
let lastRows = term.rows
const syncSize = () => {
if (term.cols === lastCols && term.rows === lastRows) return
let synced = false
const syncSize = (force = false) => {
if (!force && synced && term.cols === lastCols && term.rows === lastRows) return
lastCols = term.cols
lastRows = term.rows
synced = true
vscode.postMessage({
type: "agentManager.terminal.resize",
terminalId: props.terminalId,
@@ -406,6 +395,16 @@ export const TerminalTab: Component<Props> = (props) => {
rows: term.rows,
})
}
const fitNow = () => {
try {
fit.fit()
if (props.active) syncSize(true)
} catch (err) {
// Host still detached at mount time. ResizeObserver will retry
// once layout kicks in. Logged so regressions don't hide.
log("fit() threw", err)
}
}
const ro = new ResizeObserver(() => {
if (!props.active) return
try {
@@ -422,6 +421,16 @@ export const TerminalTab: Component<Props> = (props) => {
resizeTimer = setTimeout(syncSize, RESIZE_DEBOUNCE_MS)
})
ro.observe(host)
// Wait for the first committed layout before attaching the socket. This
// prevents the shell from emitting its first prompt at xterm's default
// 80 columns, which is most visible in a narrow side panel.
frame = requestAnimationFrame(() => {
frame = undefined
if (closed) return
fitNow()
open(props.wsUrl)
deferred = requestAnimationFrame(loadAddons)
})
// ---- Repaint recovery ----
//
@@ -452,7 +461,7 @@ export const TerminalTab: Component<Props> = (props) => {
if (!isRenderable()) return
try {
fit.fit()
syncSize()
syncSize(!synced)
} catch (err) {
// Layout not settled yet; ResizeObserver retries on next change.
log("repaint fit() threw", err)
@@ -485,6 +494,9 @@ export const TerminalTab: Component<Props> = (props) => {
if (message.terminalId === props.terminalId && !ws) {
term.options.fontFamily = message.font.fontFamily
term.options.fontSize = message.font.fontSize
// Optimistic side terminals can fit before their backend PTY exists;
// force the first resize again once the created response arrives.
fitNow()
scheduleRepaint()
open(message.wsUrl)
}
@@ -3,11 +3,9 @@
*
* Extracted from AgentManagerApp.tsx to keep that file under the
* `max-lines` lint cap. Owns the destination preference plus the toggle
* semantics of the toolbar button / `Cmd/Ctrl+/` shortcut, so the
* embedded terminal behaves like the diff panel: press once to reveal,
* press again while focused to hide, and press while visible but unfocused
* to return focus to the shell. Hiding never kills the terminal only the
* explicit close action does.
* semantics: the toolbar button toggles visibility, while `Cmd/Ctrl+/`
* reveals, focuses when unfocused, and hides when focused.
* Hiding never kills the terminal only the explicit close action does.
*
* ## Destination state ownership
*
@@ -109,14 +107,15 @@ export function createSideTerminal(deps: SideTerminalDeps) {
if (wasFocused) deps.refocus()
}
const toggle = () => {
const toggle = (trigger: "keyboard_shortcut" | "tab_toolbar" = "keyboard_shortcut") => {
if (deps.visible()) {
if (!deps.focusedId()) {
if (trigger === "keyboard_shortcut" && !deps.focusedId()) {
deps.handlers.requestSide()
return
}
const was = deps.focusedId() !== undefined
deps.hide()
handoff(true)
handoff(was)
return
}
deps.handlers.requestSide()
@@ -149,7 +148,7 @@ export function createSideTerminal(deps: SideTerminalDeps) {
const target = destination()
deps.track("terminal", trigger, { destination: target })
if (target === "agentManager") {
toggle()
toggle(trigger)
return
}
deps.openVscode()
@@ -628,10 +628,32 @@ function newId(): string {
}
/**
* Build the close-terminal handler the main component wires to the
* close button. Picks the next visible tab before dropping the entry
* so focus flows naturally; notifies the extension last.
* Estimate initial terminal geometry from the current DOM container.
* Provides best-effort columns and rows so PTY spawn avoids the default
* 80-column line width before the first xterm fit pass commits.
*/
function measureInitialDimensions(
placement: TerminalPlacement,
font: TerminalFont,
): { cols: number; rows: number } | undefined {
if (typeof document === "undefined") return undefined
const selector =
placement === "side"
? ".am-side-terminal-layer, .am-side-terminal, .am-diff-panel-wrapper"
: ".am-terminal-layer, .am-detail-stack"
const host = document.querySelector(selector) as HTMLElement | null
const rect = host?.getBoundingClientRect()
if (!rect || rect.width <= 0 || rect.height <= 0) return undefined
const cellWidth = font.fontSize > 0 ? font.fontSize * 0.6 : 7.2
const cellHeight = font.fontSize > 0 ? font.fontSize * 1.2 : 14.4
const availableWidth = Math.max(0, rect.width - 30)
const availableHeight = Math.max(0, rect.height - 16)
return {
cols: Math.max(10, Math.floor(availableWidth / cellWidth)),
rows: Math.max(3, Math.floor(availableHeight / cellHeight)),
}
}
export function createTerminalHandlers(deps: TerminalHandlerDeps) {
const activate = (id: string) => {
deps.state.setActiveId(id)
@@ -645,11 +667,15 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
const requestNew = () => {
const sel = deps.getSelection()
if (sel === null) return
const font = deps.getFont()
const dims = measureInitialDimensions("tab", font)
deps.postMessage({
type: "agentManager.terminal.create",
createId: newId(),
placement: "tab",
worktreeId: sel === deps.LOCAL ? null : sel,
cols: dims?.cols,
rows: dims?.rows,
})
}
@@ -659,12 +685,14 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
// project-namespaced state key and must not leak into the message.
const sel = deps.getSelection()
const id = newId()
const font = deps.getFont()
const dims = measureInitialDimensions("side", font)
deps.state.beginSide(key, id)
deps.state.add(key === deps.LOCAL ? null : key, {
id,
title: "Terminal",
wsUrl: "",
font: deps.getFont(),
font,
placement: "side",
})
deps.state.setSideActive(key, id)
@@ -674,6 +702,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
createId: id,
placement: "side",
worktreeId: sel === null || sel === deps.LOCAL ? null : sel,
cols: dims?.cols,
rows: dims?.rows,
})
}
+5 -2
View File
@@ -311,8 +311,11 @@ const AppContent: Component = () => {
// Set synchronously in the webview HTML by KiloProvider so it's available
// before this component ever mounts (see buildWebviewHtml/_getHtmlForWebview).
// Dedicated single-purpose panels (Settings, Profile, Sub-Agent Viewer) set
// KILO_TOP_BAR = false since navigating away from them makes no sense.
// False for dedicated single-purpose panels (Settings, Profile, Sub-Agent
// Viewer) always, and for the Sidebar/"Open in Tab" outside Cursor — real
// VS Code's native title bar toolbar already covers those. Defaults to
// true only when unset entirely (e.g. Storybook, which doesn't render the
// real page HTML).
const host = window as { KILO_TOP_BAR?: boolean; KILO_TOP_BAR_SURFACE?: string }
const showTopBar = host.KILO_TOP_BAR !== false
const topBarSurface = host.KILO_TOP_BAR_SURFACE ?? "sidebar_title"
@@ -1239,6 +1239,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pendingId,
context,
origin ?? null,
{
agent: matched.agent,
model: matched.model,
variant: matched.variant,
},
)
} else {
session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data, origin ?? null)
@@ -1,8 +1,10 @@
/**
* Renders New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, and
* Settings inside the webview. VS Code's native `view/title` toolbar renders
* outside the webview DOM and disappears in the Secondary Side Bar with no way
* to detect or work around that this bar guarantees the actions stay visible.
* Settings inside the webview, as a fallback for Cursor only (see isCursorHost()
* in src/utils.ts). Cursor's Secondary Side Bar support is unreliable for
* extension-contributed `view/title` toolbars, which render outside the webview
* DOM with no API to detect or work around the failure. Real VS Code renders the
* native toolbar fine everywhere, so it keeps using that instead of this bar.
*/
import { Component, For } from "solid-js"
@@ -45,13 +47,12 @@ export const SidebarTopBar: Component<SidebarTopBarProps> = (props) => {
type: "openAgentManager" | "openKiloClaw" | "openMarketplacePanel" | "openProfilePanel" | "openSettingsPanel",
) => vscode.postMessage({ type })
const actions: (Action | "spacer")[] = [
const actions: Action[] = [
{ key: "newTask", codicon: "add", button: "new_task", run: () => props.onNewTask() },
{ key: "history", codicon: "history", button: "history", run: () => props.onHistory() },
{ key: "agentManager", codicon: "organization", button: "agent_manager", run: () => open("openAgentManager") },
{ key: "kiloClaw", codicon: "comment-discussion", button: "kiloclaw", run: () => open("openKiloClaw") },
{ key: "marketplace", codicon: "extensions", button: "marketplace", run: () => open("openMarketplacePanel") },
"spacer",
{ key: "profile", codicon: "account", button: "profile", run: () => open("openProfilePanel") },
{ key: "settings", codicon: "settings-gear", button: "settings", run: () => open("openSettingsPanel") },
]
@@ -60,7 +61,6 @@ export const SidebarTopBar: Component<SidebarTopBarProps> = (props) => {
<div class="sidebar-top-bar" role="toolbar" aria-label={language.t("sidebar.topBar.label")}>
<For each={actions}>
{(action) => {
if (action === "spacer") return <div class="sidebar-top-bar-spacer" />
const label = language.t(`sidebar.topBar.${action.key}`)
return (
<Tooltip value={label} placement="bottom">
@@ -903,7 +903,7 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": index() < skillPaths().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<Tooltip value={path} class="settings-skills-row-trigger">
<Tooltip value={path} class="settings-skills-row-trigger" contentClass="settings-skills-tooltip-content">
<span
style={{
width: "100%",
@@ -960,7 +960,7 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": index() < skillUrls().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<Tooltip value={url} class="settings-skills-row-trigger">
<Tooltip value={url} class="settings-skills-row-trigger" contentClass="settings-skills-tooltip-content">
<span
style={{
width: "100%",
@@ -104,6 +104,19 @@ const DisplayTab: Component = () => {
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.display.autoApprovalReason.title")}
description={language.t("settings.display.autoApprovalReason.description")}
>
<Switch
checked={Boolean(settings()["showAutoApprovalReason"] ?? true)}
onChange={(checked: boolean) => updateSetting("showAutoApprovalReason", checked)}
hideLabel
>
{language.t("settings.display.autoApprovalReason.title")}
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.display.terminalCommand.title")}
description={language.t("settings.display.terminalCommand.description")}
@@ -77,6 +77,7 @@ function loadedSettings(message: ExtensionMessage): Record<string, unknown> | un
return { "chat.shiftTabCyclesVariant": message.settings.shiftTabCyclesVariant }
}
if (message.type === "throughputSettingLoaded") return { showTokenThroughput: message.visible }
if (message.type === "autoApprovalReasonSettingLoaded") return { showAutoApprovalReason: message.visible }
}
export const ConfigProvider: ParentComponent = (props) => {
@@ -13,6 +13,7 @@ import { useConfig } from "./config"
import { useVSCode } from "./vscode"
import type { ExtensionMessage } from "../types/messages"
import { applyFontSize, clampFontSize, readFontSize } from "../font-size"
import { ToolApprovalVisibilityProvider } from "@kilocode/kilo-ui/message-part"
interface DisplayContextValue {
reasoningAutoCollapse: Accessor<boolean>
@@ -23,6 +24,8 @@ interface DisplayContextValue {
// every AssistantMessage and the aggregated row in TaskHeader, so flipping
// the setting once updates both surfaces without round-trips.
throughputVisible: Accessor<boolean>
// Whether the "why was this tool call approved" line renders on tool calls.
autoApprovalReasonVisible: Accessor<boolean>
}
export const DisplayContext = createContext<DisplayContextValue>()
@@ -33,15 +36,20 @@ export const DisplayProvider: ParentComponent = (props) => {
const reasoningAutoCollapse = createMemo(() => config().auto_collapse_reasoning ?? false)
const [fontSize, setFontSizeSignal] = createSignal(readFontSize())
const [throughputVisible, setThroughputVisible] = createSignal(false)
const [autoApprovalReasonVisible, setAutoApprovalReasonVisible] = createSignal(true)
// Request the throughput toggle once on mount; the extension posts back
// Request both toggles once on mount; the extension posts back
// (and onDidChangeConfiguration forwards subsequent edits).
onMount(() => vscode.postMessage({ type: "requestThroughputSetting" }))
onMount(() => {
vscode.postMessage({ type: "requestThroughputSetting" })
vscode.postMessage({ type: "requestAutoApprovalReasonSetting" })
})
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
if (message.type === "ready" && message.fontSize !== undefined) setFontSizeSignal(clampFontSize(message.fontSize))
if (message.type === "fontSizeChanged") setFontSizeSignal(clampFontSize(message.fontSize))
if (message.type === "throughputSettingLoaded") setThroughputVisible(Boolean(message.visible))
if (message.type === "autoApprovalReasonSettingLoaded") setAutoApprovalReasonVisible(Boolean(message.visible))
})
createEffect(() => {
@@ -62,9 +70,13 @@ export const DisplayProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "updateSetting", key: "fontSize", value: next })
},
throughputVisible,
autoApprovalReasonVisible,
}}
>
{props.children}
{/* Bridges the toggle into kilo-ui's generic gate so every tool render hides the line consistently. */}
<ToolApprovalVisibilityProvider value={autoApprovalReasonVisible}>
{props.children}
</ToolApprovalVisibilityProvider>
</DisplayContext.Provider>
)
}
@@ -0,0 +1,73 @@
import type { Accessor } from "solid-js"
import type { ExtensionMessage, ModelSelection } from "../types/messages"
import { getAgentVariant, getVariant, preserveVariant, variantKey } from "./session-variant-store"
interface Model {
variants?: Record<string, unknown>
}
type Message = { type: "requestVariants" } | { type: "persistVariant"; key: string; value: string }
interface Options {
selections: Accessor<Record<string, string>>
set: (key: string, value: string) => void
selected: (sessionID?: string) => ModelSelection | null
session: Accessor<string | undefined>
agent: (sessionID?: string) => string
find: (selection: ModelSelection) => Model | undefined
post: (message: Message) => void
listen: (handler: (message: ExtensionMessage) => void) => () => void
}
export function createSessionVariants(options: Options) {
const list = (sessionID?: string) => {
const selection = options.selected(sessionID)
if (!selection) return []
return Object.keys(options.find(selection)?.variants ?? {})
}
const agent = (name: string, selection: ModelSelection | null) => {
if (!selection) return undefined
return getAgentVariant(options.selections(), selection, options.find(selection), name)
}
const current = (sessionID?: string) => {
const sid = sessionID ?? options.session()
const selection = options.selected(sid)
if (!selection) return undefined
const variants = list(sid)
if (variants.length === 0) return undefined
return getVariant(options.selections(), selection, variants, options.agent(sid), sid)
}
const select = (value: string, sessionID?: string) => {
const sid = sessionID ?? options.session()
const selection = options.selected(sid)
if (!selection) return
const key = variantKey(selection, options.agent(sid), sid)
options.set(key, value)
if (!sid) options.post({ type: "persistVariant", key, value })
}
const carry = (selection: ModelSelection, value: string | undefined, name: string, sessionID?: string) => {
const next = preserveVariant(value, Object.keys(options.find(selection)?.variants ?? {}))
if (!next) return
const key = variantKey(selection, name, sessionID)
options.set(key, next)
if (!sessionID) options.post({ type: "persistVariant", key, value: next })
}
const load = () => {
const unsub = options.listen((message) => {
if (message.type !== "variantsLoaded") return
for (const [key, value] of Object.entries(message.variants)) {
if (key.startsWith("session/")) continue
options.set(key, value)
}
})
options.post({ type: "requestVariants" })
return unsub
}
return { carry, list, agent, current, select, load }
}
@@ -75,14 +75,8 @@ import { PartStash } from "./part-stash"
import { mergeParts } from "./session-parts"
import { mergeMessages, sameReconcileShape } from "./session-merge"
import { state as todoState } from "./todo-revert"
import {
getAgentVariant,
getVariant,
preserveVariant,
sessionVariantKeys,
transferVariants,
variantKey,
} from "./session-variant-store"
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { createSessionVariants } from "./session-variants"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
@@ -290,6 +284,7 @@ interface SessionContextValue {
draftID?: string,
context?: string,
origin?: string | null,
overrides?: { agent?: string; model?: string; variant?: string },
) => void
abort: () => void
compact: () => void
@@ -671,13 +666,18 @@ export const SessionProvider: ParentComponent = (props) => {
})
}
function carryVariant(selection: ModelSelection, current: string | undefined, agent: string, sessionID?: string) {
const value = preserveVariant(current, Object.keys(provider.findModel(selection)?.variants ?? {}))
if (!value) return
const key = variantKey(selection, agent, sessionID)
setStore("variantSelections", key, value)
if (!sessionID) vscode.postMessage({ type: "persistVariant", key, value })
}
const variants = createSessionVariants({
selections: () => store.variantSelections,
set: (key, value) => setStore("variantSelections", key, value),
selected,
session: currentSessionID,
agent: agentForScope,
find: provider.findModel,
post: vscode.postMessage,
listen: vscode.onMessage,
})
const { carry: carryVariant, list: variantList, agent: variantForAgent, current: currentVariant } = variants
const selectVariant = variants.select
function selectModel(providerID: string, modelID: string, sessionID?: string) {
const sid = sessionID ?? currentSessionID()
@@ -931,50 +931,7 @@ export const SessionProvider: ParentComponent = (props) => {
clearTimeout(fallback)
})
const variantList = (sessionID?: string) => {
const sel = selected(sessionID)
if (!sel) return []
const model = provider.findModel(sel)
if (!model?.variants) return []
return Object.keys(model.variants)
}
function variantForAgent(agentName: string, sel: ModelSelection | null) {
if (!sel) return undefined
const model = provider.findModel(sel)
return getAgentVariant(store.variantSelections, sel, model, agentName)
}
const currentVariant = (sessionID?: string) => {
const sid = sessionID ?? currentSessionID()
const sel = selected(sid)
if (!sel) return undefined
const list = variantList(sid)
if (list.length === 0) return undefined
return getVariant(store.variantSelections, sel, list, agentForScope(sid), sid)
}
const selectVariant = (value: string, sessionID?: string) => {
const sid = sessionID ?? currentSessionID()
const sel = selected(sid)
if (!sel) return
const key = variantKey(sel, agentForScope(sid), sid)
setStore("variantSelections", key, value)
if (!sid) vscode.postMessage({ type: "persistVariant", key, value })
}
// Load persisted variants from extension globalState
const unsubVariants = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "variantsLoaded") return
for (const [k, v] of Object.entries(message.variants)) {
if (k.startsWith("session/")) continue
setStore("variantSelections", k, v)
}
})
vscode.postMessage({ type: "requestVariants" })
onCleanup(unsubVariants)
onCleanup(variants.load())
// Load persisted per-mode model selections from model.json via extension host.
// Uses replace semantics so a reset (empty payload) clears old entries.
@@ -2362,31 +2319,51 @@ export const SessionProvider: ParentComponent = (props) => {
draftID?: string,
context?: string,
origin?: string | null,
overrides?: { agent?: string; model?: string; variant?: string },
) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot send command: not connected")
return
}
// Cloud previews need import-then-command; post importAndSend with command metadata
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const selection = providerID && modelID ? { providerID, modelID } : selected(sid)
recordModelUsage(selection?.providerID, selection?.modelID)
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
const scope = effectiveDraftID ?? sid
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
if (overrides?.agent) {
selectAgent(overrides.agent, scope)
}
if (overrides?.model) {
const parsed = parseModelString(overrides.model)
if (parsed) {
selectModel(parsed.providerID, parsed.modelID, scope)
}
}
if (overrides?.variant) {
selectVariant(overrides.variant, scope)
}
const effectiveSelection = selected(scope)
const effectiveProvider = effectiveSelection?.providerID ?? providerID
const effectiveModel = effectiveSelection?.modelID ?? modelID
recordModelUsage(effectiveProvider, effectiveModel)
// Cloud previews need import-then-command; post importAndSend with command metadata
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
? cloudPreviewId()
: null
if (preview) {
const scope = draftID ?? sid
const agent = promptAgent(scope)
vscode.postMessage({
type: "importAndSend",
cloudSessionId: preview,
text: `/${command} ${args}`.trim(),
messageID: Identifier.ascending("message"),
providerID,
modelID,
providerID: effectiveProvider,
modelID: effectiveModel,
agent,
variant: currentVariant(scope),
files,
@@ -2403,9 +2380,6 @@ export const SessionProvider: ParentComponent = (props) => {
dismissQuestion(q.id)
}
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
const scope = effectiveDraftID ?? sid
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
if (scope) {
clearClose(scope)
addOptimistic(scope, messageID, `/${command} ${args}`.trim(), files)
@@ -2424,8 +2398,8 @@ export const SessionProvider: ParentComponent = (props) => {
messageID,
sessionID: sid,
draftID: effectiveDraftID,
providerID,
modelID,
providerID: effectiveProvider,
modelID: effectiveModel,
agent,
variant: currentVariant(scope),
files,
@@ -144,6 +144,21 @@ export function useSlashCommand(
{ name: "memory auto on", description: "Enable automatic memory saves", hints: [] },
{ name: "memory auto off", description: "Disable automatic memory saves", hints: [] },
{ name: "memory purge confirm", description: "Delete all project memory files", hints: [] },
{
name: "review",
description: "Review code changes [uncommitted, staged, unpushed, branch, commit, pr]",
hints: ["code-review", "diff"],
nested: true,
},
{ name: "review uncommitted", description: "Review uncommitted changes (staged, unstaged, untracked)", hints: [] },
{ name: "review staged", description: "Review staged changes only", hints: [] },
{ name: "review unpushed", description: "Review local commits ahead of upstream", hints: [] },
{ name: "review branch", description: "Review current branch against base branch", hints: [] },
{
name: "review quick",
description: "Fast single-pass review with minimal token usage",
hints: ["--quick", "fast"],
},
{
name: "export",
description: "Export the current session transcript as Markdown",
@@ -240,6 +255,15 @@ export function useSlashCommand(
lower,
)
}
if (q.startsWith("review ")) {
const matches = list.filter((cmd) => cmd.name.startsWith("review "))
if (q === "review ") return matches
const lower = q.toLowerCase()
return sortByScore(
matches.filter((cmd) => cmd.name.toLowerCase().startsWith(lower)),
lower,
)
}
const root = list.filter((cmd) => !cmd.name.includes(" "))
if (!q) return root
const lower = q.toLowerCase()
@@ -275,12 +299,24 @@ export function useSlashCommand(
return
}
const memory = before.match(/^\/(?:memory|mem)\s+([^\n]*)$/i)
if (!memory) return close()
const value = `memory ${memory[1]}`.toLowerCase()
if (!commands().some((cmd) => cmd.name.toLowerCase().startsWith(value))) return close()
request()
setQuery(value)
setIndex(0)
if (memory) {
const value = `memory ${memory[1]}`.toLowerCase()
if (!commands().some((cmd) => cmd.name.toLowerCase().startsWith(value))) return close()
request()
setQuery(value)
setIndex(0)
return
}
const review = before.match(/^\/review\s+([^\n]*)$/i)
if (review) {
const value = `review ${review[1]}`.toLowerCase()
if (!commands().some((cmd) => cmd.name.toLowerCase().startsWith(value))) return close()
request()
setQuery(value)
setIndex(0)
return
}
return close()
}
const select = (
+10 -7
View File
@@ -872,7 +872,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "وجهات الشبكة المسموح بها",
"settings.sandboxing.allowedHosts.description":
"وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443. تنطبق التغييرات على الجلسات الجديدة.",
"وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443.",
"settings.sandboxing.writablePaths.title": "مسارات قابلة للكتابة إضافية",
"settings.sandboxing.writablePaths.description":
"مسارات نظام ملفات إضافية يسمح صندوق الرمل بالكتابة إليها (مثل /tmp، /var/log). يتم دمجها مع مسارات الكتابة الافتراضية عندما يكون صندوق الرمل نشطًا.",
@@ -1105,19 +1105,22 @@ export const dict = {
"settings.display.shiftTabCycle.title": "تبديل جهد الاستدلال باستخدام Shift+Tab",
"settings.display.shiftTabCycle.description":
"اضغط على Shift+Tab في حقل إدخال الموجه للتبديل إلى مستوى جهد الاستدلال التالي. عطّل هذا الخيار للاحتفاظ بـ Shift+Tab للتنقل بين عناصر التركيز باستخدام لوحة المفاتيح.",
"settings.display.terminalCommand.title": "Terminal Command Blocks",
"settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.",
"settings.display.terminalCommand.expanded": "Expanded",
"settings.display.terminalCommand.collapsed": "Collapsed",
"settings.display.terminalCommand.title": "كتل أوامر الطرفية",
"settings.display.terminalCommand.description": "اختر ما إذا كانت كتل أوامر الطرفية تبدأ موسّعة أم مطوية.",
"settings.display.terminalCommand.expanded": "موسّعة",
"settings.display.terminalCommand.collapsed": "مطوية",
"settings.display.codeEdit.title": "كتل تعديلات التعليمات البرمجية",
"settings.display.codeEdit.description":
"اختر ما إذا كانت الكتل التي تعرض تعديلات التعليمات البرمجية والفروقات تبدأ موسّعة أم مطوية.",
"settings.display.codeEdit.expanded": "موسّعة",
"settings.display.codeEdit.collapsed": "مطوية",
"settings.display.tokenThroughput.title": "Show Token Throughput",
"settings.display.tokenThroughput.title": "إظهار إنتاجية الرموز",
"settings.display.tokenThroughput.description":
"Display the text-generation rate (tokens/sec) on the latest assistant message and in the task header. Hidden by default to keep the chat uncluttered.",
"عرض معدل توليد النص (رموز/ثانية) على آخر رسالة من المساعد وفي رأس المهمة. مخفي بشكل افتراضي للحفاظ على تنظيم المحادثة.",
"settings.display.autoApprovalReason.title": "إظهار سبب الموافقة التلقائية",
"settings.display.autoApprovalReason.description":
"إظهار سطر عند استدعاءات الأدوات يوضح سبب الموافقة التلقائية عليها (قاعدة مطابقة، إعداد افتراضي للوكيل، وضع YOLO، إلخ).",
"chat.throughput.tooltip":
"Average {{speed}} tokens/s for this turn. Includes output and reasoning tokens; excludes tool execution and waiting time.",
+11 -7
View File
@@ -903,7 +903,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Destinos de rede permitidos",
"settings.sandboxing.allowedHosts.description":
"Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443. As alterações se aplicam a novas sessões.",
"Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Caminhos graváveis adicionais",
"settings.sandboxing.writablePaths.description":
"Caminhos adicionais do sistema de arquivos onde o sandbox permite gravação (por exemplo, /tmp, /var/log). Eles são mesclados com os caminhos graváveis padrão quando o sandbox está ativo.",
@@ -1148,19 +1148,23 @@ export const dict = {
"settings.display.shiftTabCycle.title": "Alternar o esforço de raciocínio com Shift+Tab",
"settings.display.shiftTabCycle.description":
"Pressione Shift+Tab em um campo de entrada de prompt para alternar para o próximo nível de esforço de raciocínio. Desative para manter Shift+Tab para navegação de foco pelo teclado.",
"settings.display.terminalCommand.title": "Terminal Command Blocks",
"settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.",
"settings.display.terminalCommand.expanded": "Expanded",
"settings.display.terminalCommand.collapsed": "Collapsed",
"settings.display.terminalCommand.title": "Blocos de comando do terminal",
"settings.display.terminalCommand.description":
"Escolha se os blocos de comando do terminal começam expandidos ou recolhidos.",
"settings.display.terminalCommand.expanded": "Expandidos",
"settings.display.terminalCommand.collapsed": "Recolhidos",
"settings.display.codeEdit.title": "Blocos de edição de código",
"settings.display.codeEdit.description":
"Escolha se os blocos que exibem edições de código e diferenças começam expandidos ou recolhidos.",
"settings.display.codeEdit.expanded": "Expandidos",
"settings.display.codeEdit.collapsed": "Recolhidos",
"settings.display.tokenThroughput.title": "Show Token Throughput",
"settings.display.tokenThroughput.title": "Mostrar taxa de tokens",
"settings.display.tokenThroughput.description":
"Display the text-generation rate (tokens/sec) on the latest assistant message and in the task header. Hidden by default to keep the chat uncluttered.",
"Exibe a taxa de geração de texto (tokens/s) na última mensagem do assistente e no cabeçalho da tarefa. Oculto por padrão para manter o chat organizado.",
"settings.display.autoApprovalReason.title": "Mostrar motivo da aprovação automática",
"settings.display.autoApprovalReason.description":
"Mostra uma linha nas chamadas de ferramentas explicando por que foram aprovadas automaticamente (regra correspondente, padrão do agente, modo YOLO, etc.).",
"chat.throughput.tooltip":
"Average {{speed}} tokens/s for this turn. Includes output and reasoning tokens; excludes tool execution and waiting time.",
+11 -7
View File
@@ -896,7 +896,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Dozvoljena mrežna odredišta",
"settings.sandboxing.allowedHosts.description":
"DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443. Promjene se primjenjuju na nove sesije.",
"DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Dodatne upisive putanje",
"settings.sandboxing.writablePaths.description":
"Dodatne putanje sistema datoteka u koje sandbox dozvoljava upis (npr. /tmp, /var/log). Spajaju se sa zadanim upisivim putanjama kada je sandbox aktivan.",
@@ -1138,19 +1138,23 @@ export const dict = {
"settings.display.shiftTabCycle.title": "Promijeni napor razmišljanja pomoću Shift+Tab",
"settings.display.shiftTabCycle.description":
"Pritisnite Shift+Tab u polju za unos upita da pređete na sljedeći nivo napora razmišljanja. Onemogućite ovu opciju kako biste zadržali Shift+Tab za navigaciju fokusom putem tastature.",
"settings.display.terminalCommand.title": "Terminal Command Blocks",
"settings.display.terminalCommand.description": "Choose whether terminal command blocks start expanded or collapsed.",
"settings.display.terminalCommand.expanded": "Expanded",
"settings.display.terminalCommand.collapsed": "Collapsed",
"settings.display.terminalCommand.title": "Blokovi terminalskih naredbi",
"settings.display.terminalCommand.description":
"Odaberite da li blokovi terminalskih naredbi počinju prošireni ili sažeti.",
"settings.display.terminalCommand.expanded": "Prošireni",
"settings.display.terminalCommand.collapsed": "Sažeti",
"settings.display.codeEdit.title": "Blokovi izmjena koda",
"settings.display.codeEdit.description":
"Odaberite da li će blokovi koji prikazuju izmjene koda i razlike u početku biti prošireni ili sažeti.",
"settings.display.codeEdit.expanded": "Prošireni",
"settings.display.codeEdit.collapsed": "Sažeti",
"settings.display.tokenThroughput.title": "Show Token Throughput",
"settings.display.tokenThroughput.title": "Prikaži protok tokena",
"settings.display.tokenThroughput.description":
"Display the text-generation rate (tokens/sec) on the latest assistant message and in the task header. Hidden by default to keep the chat uncluttered.",
"Prikazuje brzinu generisanja teksta (tokena/s) na najnovijoj poruci asistenta i u zaglavlju zadatka. Podrazumevano skriveno radi urednijeg chata.",
"settings.display.autoApprovalReason.title": "Prikaži razlog automatskog odobravanja",
"settings.display.autoApprovalReason.description":
"Prikazuje red uz pozive alata koji objašnjava zašto su automatski odobreni (odgovarajuće pravilo, podrazumevana vrijednost agenta, YOLO režim itd.).",
"chat.throughput.tooltip":
"Average {{speed}} tokens/s for this turn. Includes output and reasoning tokens; excludes tool execution and waiting time.",

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