mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-31 01:37:28 +08:00
Merge branch 'main' into feature/am-pr-view
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Remove unsupported `kilo web` CLI command.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1bc2a28b80d8dc5aea4c03609efc13a23a2c88103e56df741155d9e794fc96f7
|
||||
size 2422
|
||||
oid sha256:d49870048a42ea9c0166143fbb364b0d9ee45d786e3b1346fcd0eda76c533bdb
|
||||
size 2414
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e3f86dabc39700b3a86508c1e6499a546eeabf720832b62c9ce12f2d97378ebd
|
||||
size 34649
|
||||
oid sha256:00092414080e2f70fb9b3166e8d06a1692af93e33717d0ab8fea5fe96e6a6982
|
||||
size 34468
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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"] {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}`]),
|
||||
),
|
||||
}
|
||||
+1
-7
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user