mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'main' into pentagonal-storm
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Improve Agent Manager terminal focus and keyboard navigation. `Cmd+/` now focuses a visible embedded terminal before hiding it, `Cmd+Shift+T` creates a side terminal only while that terminal area has focus, and `Cmd+Shift+[` / `]` switch terminal tabs. `Cmd+Shift+M` focuses the Agent Manager prompt instead of opening VS Code Problems. `Cmd+W` hides the last side terminal instead of stopping its shell.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Add kilocode command-file endpoints so clients can list editable command/workflow files, inspect model and reasoning variant metadata, and remove them.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Translate the Agent Manager terminal focus shortcut label in all supported locales.
|
||||
@@ -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.
|
||||
@@ -14,6 +14,31 @@ The JetBrains plugin provides the best native JetBrains UX for working with an A
|
||||
|
||||
{% image src="/docs/img/jetbrains/plugin-auto-updates.png" alt="JetBrains Updates settings with Update plugins automatically enabled" width="900" caption="Enable automatic plugin updates to receive Kilo Code fixes and improvements." /%}
|
||||
|
||||
### Install with bundled Kilo Core
|
||||
|
||||
The Marketplace build is best for most users. Use the bundled Kilo Core build when your IDE cannot download the Kilo Core runtime after installation, such as on locked-down corporate networks, behind strict proxy or TLS inspection, in offline development environments, or where corporate policy blocks applications from downloading executables at runtime.
|
||||
|
||||
The bundled build ships the JetBrains plugin with Kilo Core included. The install is larger, but first launch does not need a separate runtime download.
|
||||
|
||||
1. Open **Settings → Plugins**
|
||||
2. Click the gear icon and choose **Manage Plugin Repositories...**
|
||||
3. Click **+** and add the Kilo Code repository URL:
|
||||
|
||||
```text
|
||||
https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
|
||||
```
|
||||
|
||||
4. Click **OK**, then install or update **Kilo Code** from **Settings → Plugins**
|
||||
5. Restart the IDE if prompted
|
||||
|
||||
{% image src="/docs/img/jetbrains/plugin-custom-repository-menu.png" alt="JetBrains Plugins settings with Manage Plugin Repositories selected from the gear menu" width="900" caption="Open Manage Plugin Repositories from the Plugins settings gear menu." /%}
|
||||
|
||||
{% image src="/docs/img/jetbrains/plugin-custom-repository-url.png" alt="JetBrains Custom Plugin Repositories dialog with the Kilo Code repository URL added" width="700" caption="Add the Kilo Code custom plugin repository URL." /%}
|
||||
|
||||
After restart, open the **Kilo Code** tool window and choose **... → Core**. The menu footer should show **Bundled Core** with the version and architecture.
|
||||
|
||||
{% image src="/docs/img/jetbrains/plugin-bundled-core.png" alt="Kilo Code tool window Core menu showing Bundled Core and the current architecture" width="900" caption="Confirm that the plugin is using Bundled Core." /%}
|
||||
|
||||
### If you used the v7 EAP {% #jetbrains-early-access %}
|
||||
|
||||
{% callout type="info" %}
|
||||
|
||||
@@ -232,7 +232,7 @@ See [Agent Manager Workflows](/docs/automate/agent-manager-workflows#merging-wor
|
||||
|
||||
## Terminals
|
||||
|
||||
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
|
||||
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session. If the embedded terminal is already visible but the prompt has focus, the same shortcut focuses the terminal without hiding it. Press it again while the terminal has focus to hide the panel.
|
||||
|
||||
### Choosing the Terminal Destination
|
||||
|
||||
@@ -243,11 +243,18 @@ The toolbar's terminal button is a split button: click it to open a terminal, or
|
||||
|
||||
The dropdown choice is remembered per panel and becomes the default for new panels. You can also set the default directly with the `kilo-code.new.agentManager.terminalButtonDestination` setting (`vscode` or `agentManager`). The `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) shortcut follows the same destination.
|
||||
|
||||
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal it and press again to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you close its tab in the panel.
|
||||
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal and focus it, press it while the panel is visible but another control has focus to move focus into the terminal, and press it again from the terminal to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you click its close button or type `exit` in the shell.
|
||||
|
||||
### Multiple Terminals
|
||||
|
||||
The side panel hosts multiple terminals per context (the local workspace or a worktree). The panel header is a tab strip: click a tab to switch, click **+** to open another terminal, and click **X** (or middle-click) to close a single terminal. Drag tabs to reorder them. Closing a terminal no longer hides the panel — closing the last one lands on the empty state. Pressing `Cmd+W` (macOS) / `Ctrl+W` (Windows/Linux) with a focused side terminal closes exactly that terminal.
|
||||
Agent Manager has two separate terminal tab strips:
|
||||
|
||||
- **Main terminal tabs** appear alongside the agent session tabs. With the prompt or a main terminal focused, press `Cmd+Shift+T` / `Ctrl+Shift+T` to create another main terminal tab.
|
||||
- **Side terminal tabs** appear in the terminal panel. Focus a side terminal, then press `Cmd+Shift+T` / `Ctrl+Shift+T` to create another side terminal. You can also click **+** in the side-terminal strip.
|
||||
|
||||
The shortcut follows terminal focus, not panel visibility. A visible side panel with the prompt focused still creates a main terminal tab. Press `Cmd+Shift+[` / `Ctrl+Shift+[` for the previous terminal or `Cmd+Shift+]` / `Ctrl+Shift+]` for the next terminal in the focused terminal strip. Drag tabs to reorder them. Pressing `Cmd+W` / `Ctrl+W` with a focused side terminal closes that terminal when other terminals remain. On the last side terminal, it hides the panel and keeps the shell alive; use its close button or type `exit` to stop it.
|
||||
|
||||
`Cmd+T` / `Ctrl+T` always creates a new agent session tab. It never creates a terminal.
|
||||
|
||||
New terminals are named "Terminal N" using the lowest free number, and tabs pick up the live title from the shell or running program, so a dev server or editor names its own tab.
|
||||
|
||||
@@ -256,7 +263,7 @@ New terminals are named "Terminal N" using the lowest free number, and tabs pick
|
||||
A common workflow is letting the agent work, then switching to the terminal to run tests or inspect the worktree, then switching back to control the agent:
|
||||
|
||||
1. **Agent Manager → Terminal:** Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to open and focus the terminal for the current session. The terminal runs inside the session's worktree, so commands like `npm test` or `git status` operate on the agent's isolated branch.
|
||||
2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This works from anywhere in VS Code — the terminal, another editor tab, or the sidebar.
|
||||
2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This explicit shortcut always targets the prompt and works from anywhere in VS Code — the terminal, another editor tab, or the sidebar. Returning to the panel by clicking its editor tab or switching windows restores the last focused control instead.
|
||||
|
||||
## Setup Scripts
|
||||
|
||||
@@ -392,11 +399,13 @@ Closing a managed worktree removes it from Agent Manager, deletes its `.kilo/wor
|
||||
| `Cmd+Shift+N` | `Ctrl+Shift+N` | Create a new worktree immediately |
|
||||
| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree |
|
||||
| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree |
|
||||
| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree |
|
||||
| `Cmd+W` | `Ctrl+W` | Close current tab |
|
||||
| `Cmd+T` | `Ctrl+T` | New agent session tab in worktree |
|
||||
| `Cmd+W` | `Ctrl+W` | Close the focused tab or terminal; the last side terminal hides instead of stopping |
|
||||
| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree |
|
||||
| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree |
|
||||
| `Cmd+/` | `Ctrl+/` | Focus terminal for current session |
|
||||
| `Cmd+/` | `Ctrl+/` | Focus terminal, or hide it when it already has focus |
|
||||
| `Cmd+Shift+T` | `Ctrl+Shift+T` | New side terminal when a side terminal is focused; otherwise new main terminal tab |
|
||||
| `Cmd+Shift+[` / `]` | `Ctrl+Shift+[` / `]` | Previous / next terminal |
|
||||
| `Cmd+D` | `Ctrl+D` | Toggle diff panel |
|
||||
| `Cmd+E` | `Ctrl+E` | Run / stop run script |
|
||||
| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 821 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -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
|
||||
|
||||
+40
@@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionListDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.ConfigDto
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
@@ -630,8 +631,12 @@ object KiloCliDataParser {
|
||||
CommandInfo(
|
||||
name = obj.str("name") ?: "",
|
||||
description = obj.str("description"),
|
||||
agent = obj.str("agent"),
|
||||
model = obj.str("model"),
|
||||
variant = obj.str("variant"),
|
||||
source = obj.str("source"),
|
||||
hints = obj["hints"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
|
||||
subtask = obj.flagOrNull("subtask"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,9 +667,34 @@ object KiloCliDataParser {
|
||||
CommandDto(
|
||||
name = name,
|
||||
description = obj.str("description"),
|
||||
agent = obj.str("agent"),
|
||||
model = obj.str("model"),
|
||||
variant = obj.str("variant"),
|
||||
source = obj.str("source"),
|
||||
hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
|
||||
template = obj.str("template"),
|
||||
subtask = obj.flagOrNull("subtask"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAgentBehaviorCommandFiles(raw: String): List<CommandFileDto> =
|
||||
raw.array().mapNotNull { item ->
|
||||
val obj = item.obj() ?: return@mapNotNull null
|
||||
val name = obj.str("name") ?: return@mapNotNull null
|
||||
val location = obj.str("location") ?: return@mapNotNull null
|
||||
CommandFileDto(
|
||||
name = name,
|
||||
description = obj.str("description"),
|
||||
agent = obj.str("agent"),
|
||||
model = obj.str("model"),
|
||||
variant = obj.str("variant"),
|
||||
source = obj.str("source"),
|
||||
builtin = obj.bool("builtin"),
|
||||
location = location,
|
||||
editable = obj.bool("editable"),
|
||||
content = obj.str("content"),
|
||||
subtask = obj.flagOrNull("subtask"),
|
||||
hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -713,6 +743,16 @@ object KiloCliDataParser {
|
||||
return if (prim.isString) prim.content else null
|
||||
}
|
||||
|
||||
fun parsePathConfig(raw: String): String? {
|
||||
val prim = runCatching { tryParseObject(raw)?.get("config")?.jsonPrimitive }.getOrNull() ?: return null
|
||||
return if (prim.isString) prim.content else null
|
||||
}
|
||||
|
||||
fun parsePathHome(raw: String): String? {
|
||||
val prim = runCatching { tryParseObject(raw)?.get("home")?.jsonPrimitive }.getOrNull() ?: return null
|
||||
return if (prim.isString) prim.content else null
|
||||
}
|
||||
|
||||
fun parseModelState(raw: String): ModelStateDto {
|
||||
val obj = tryParseObject(raw) ?: return ModelStateDto()
|
||||
return ModelStateDto(
|
||||
|
||||
+121
-7
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
@@ -75,7 +76,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
|
||||
override suspend fun reloadSkills(directory: String): Boolean {
|
||||
LOG.info("Skills reload requested dir=$directory")
|
||||
if (hasActiveSession(directory)) {
|
||||
if (hasActiveSession(directory, "Skills")) {
|
||||
LOG.warn("Skills reload blocked by active session dir=$directory")
|
||||
return false
|
||||
}
|
||||
@@ -136,6 +137,45 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
|
||||
override suspend fun commands(directory: String) = KiloCliDataParser.parseAgentBehaviorCommands(request(directory, "/command", null))
|
||||
|
||||
override suspend fun commandFiles(directory: String): List<CommandFileDto> =
|
||||
KiloCliDataParser.parseAgentBehaviorCommandFiles(request(directory, "/kilocode/command/files", null))
|
||||
|
||||
override suspend fun removeCommand(directory: String, location: String): Boolean =
|
||||
post(directory, "/kilocode/command/remove", JsonObject(mapOf("location" to JsonPrimitive(location))))
|
||||
|
||||
override suspend fun reloadCommands(directory: String): Boolean {
|
||||
LOG.info("Commands reload requested dir=$directory")
|
||||
if (hasActiveSession(directory, "Commands")) {
|
||||
LOG.warn("Commands reload blocked by active session dir=$directory")
|
||||
return false
|
||||
}
|
||||
runCatching { post(directory, "/instance/reload") }.onFailure { err ->
|
||||
LOG.warn("Commands reload failed dir=$directory", err)
|
||||
}.getOrThrow()
|
||||
LOG.info("Commands reload succeeded dir=$directory")
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
|
||||
LOG.info("Commands save requested dir=$directory count=${edits.size}")
|
||||
app.requireReady()
|
||||
val known = knownCommands(directory)
|
||||
val roots = commandRoots(directory)
|
||||
val paths = edits.map { (location, content) ->
|
||||
val path = writableCommandPath(directory, location, known, roots) ?: return false
|
||||
path to content
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
for ((path, content) in paths) {
|
||||
Files.createDirectories(path.parent)
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
LOG.info("Command files saved dir=$directory count=${paths.size}")
|
||||
LOG.info("Commands save reload deferred dir=$directory count=${paths.size}")
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun mcpStatus(directory: String) = KiloCliDataParser.parseMcpStatus(request(directory, "/mcp", null)).also { items ->
|
||||
LOG.info("MCP status returned dir=$directory count=${items.size}")
|
||||
}
|
||||
@@ -193,24 +233,24 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasActiveSession(directory: String): Boolean {
|
||||
private fun hasActiveSession(directory: String, label: String): Boolean {
|
||||
val active = app.sessions.statuses.value.filterValues { it.type != "idle" }
|
||||
if (active.isNotEmpty()) {
|
||||
LOG.info("Skills reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}")
|
||||
LOG.info("$label reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}")
|
||||
return true
|
||||
}
|
||||
val permissions = runCatching { app.chat.pendingPermissions(directory) }.onFailure { err ->
|
||||
LOG.warn("Skills reload pending permission check failed dir=$directory", err)
|
||||
LOG.warn("$label reload pending permission check failed dir=$directory", err)
|
||||
}.getOrDefault(emptyList())
|
||||
if (permissions.isNotEmpty()) {
|
||||
LOG.info("Skills reload pending permissions dir=$directory count=${permissions.size}")
|
||||
LOG.info("$label reload pending permissions dir=$directory count=${permissions.size}")
|
||||
return true
|
||||
}
|
||||
val questions = runCatching { app.chat.pendingQuestions(directory) }.onFailure { err ->
|
||||
LOG.warn("Skills reload pending question check failed dir=$directory", err)
|
||||
LOG.warn("$label reload pending question check failed dir=$directory", err)
|
||||
}.getOrDefault(emptyList())
|
||||
if (questions.isNotEmpty()) {
|
||||
LOG.info("Skills reload pending questions dir=$directory count=${questions.size}")
|
||||
LOG.info("$label reload pending questions dir=$directory count=${questions.size}")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -238,6 +278,11 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return items.mapNotNull { item -> resolveEditablePath(item) }.toSet()
|
||||
}
|
||||
|
||||
private suspend fun knownCommands(directory: String): Set<Path> {
|
||||
val items = commandFiles(directory)
|
||||
return items.mapNotNull { item -> resolveEditableCommandPath(item) }.toSet()
|
||||
}
|
||||
|
||||
private fun writablePath(directory: String, location: String, known: Set<Path>): Path? {
|
||||
val path = resolveSkillPath(location)
|
||||
if (path == null) {
|
||||
@@ -251,12 +296,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return path
|
||||
}
|
||||
|
||||
private fun writableCommandPath(directory: String, location: String, known: Set<Path>, roots: Set<Path>): Path? {
|
||||
val path = resolveCommandPath(location)
|
||||
if (path == null) {
|
||||
LOG.warn("Command save rejected: invalid location dir=$directory location=$location")
|
||||
return null
|
||||
}
|
||||
if (path in known || newCommandPath(path, roots)) return path
|
||||
LOG.warn("Command save rejected: unknown command dir=$directory path=$path")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun resolveEditablePath(skill: SkillDto): Path? {
|
||||
val path = resolveSkillPath(skill.location) ?: return null
|
||||
if (urlCached(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun resolveEditableCommandPath(command: CommandFileDto): Path? {
|
||||
if (!command.editable) return null
|
||||
return resolveCommandPath(command.location)
|
||||
}
|
||||
|
||||
private fun resolveSkillPath(location: String): Path? {
|
||||
val raw = normalizeWorkspacePath(location) ?: return null
|
||||
val path = try {
|
||||
@@ -268,6 +329,59 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return path
|
||||
}
|
||||
|
||||
private fun resolveCommandPath(location: String): Path? {
|
||||
val raw = normalizeWorkspacePath(location) ?: return null
|
||||
val path = try {
|
||||
Path.of(raw).normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
return null
|
||||
}
|
||||
if (!path.isAbsolute || path.fileName?.toString()?.endsWith(".md") != true) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private suspend fun commandRoots(directory: String): Set<Path> = buildSet {
|
||||
addProjectCommandRoots(this, directory)
|
||||
val paths = runCatching { request(directory, "/path", null) }.getOrNull()
|
||||
val config = paths?.let(KiloCliDataParser::parsePathConfig)
|
||||
if (config != null) addConfigCommandRoots(this, config)
|
||||
val home = paths?.let(KiloCliDataParser::parsePathHome)
|
||||
if (home != null) addHomeCommandRoots(this, home)
|
||||
}
|
||||
|
||||
private fun addProjectCommandRoots(roots: MutableSet<Path>, dir: String) {
|
||||
val base = try {
|
||||
Path.of(dir).normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
return
|
||||
}
|
||||
for (cfg in listOf(".kilo", ".kilocode")) {
|
||||
for (name in listOf("command", "commands")) roots.add(base.resolve(cfg).resolve(name).normalize())
|
||||
}
|
||||
}
|
||||
|
||||
private fun addConfigCommandRoots(roots: MutableSet<Path>, dir: String) {
|
||||
val base = try {
|
||||
Path.of(dir).normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
return
|
||||
}
|
||||
for (name in listOf("command", "commands")) roots.add(base.resolve(name).normalize())
|
||||
}
|
||||
|
||||
private fun addHomeCommandRoots(roots: MutableSet<Path>, home: String) {
|
||||
val base = try {
|
||||
Path.of(home).normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
return
|
||||
}
|
||||
for (cfg in listOf(".kilo", ".kilocode")) addConfigCommandRoots(roots, base.resolve(cfg).toString())
|
||||
}
|
||||
|
||||
private fun newCommandPath(path: Path, roots: Set<Path>): Boolean {
|
||||
return roots.any { root -> path.startsWith(root) }
|
||||
}
|
||||
|
||||
private fun urlCached(path: Path): Boolean {
|
||||
return cacheRoots().any { root -> path.startsWith(root.resolve("kilo").resolve("skills").normalize()) }
|
||||
}
|
||||
|
||||
+4
@@ -56,8 +56,12 @@ internal object KiloWorkspaceDtoMapper {
|
||||
fun command(c: CommandInfo) = CommandDto(
|
||||
name = c.name,
|
||||
description = c.description,
|
||||
agent = c.agent,
|
||||
model = c.model,
|
||||
variant = c.variant,
|
||||
source = c.source,
|
||||
hints = c.hints,
|
||||
subtask = c.subtask,
|
||||
)
|
||||
|
||||
fun skill(s: SkillInfo) = SkillDto(
|
||||
|
||||
+4
@@ -135,8 +135,12 @@ data class AgentInfo(
|
||||
data class CommandInfo(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val agent: String?,
|
||||
val model: String?,
|
||||
val variant: String?,
|
||||
val source: String?,
|
||||
val hints: List<String>,
|
||||
val subtask: Boolean?,
|
||||
)
|
||||
|
||||
data class SkillInfo(
|
||||
|
||||
+7
-1
@@ -1824,7 +1824,7 @@ class KiloCliDataParserTest {
|
||||
@Test
|
||||
fun `parseCommands - maps name, description, source, and hints`() {
|
||||
val raw = """[
|
||||
{"name":"init","description":"guided AGENTS.md setup","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command"},
|
||||
{"name":"init","description":"guided AGENTS.md setup","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command","subtask":true},
|
||||
{"name":"mcp-tool","template":"","hints":["${'$'}1","${'$'}2"],"source":"mcp"}
|
||||
]"""
|
||||
|
||||
@@ -1833,8 +1833,12 @@ class KiloCliDataParserTest {
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("init", result[0].name)
|
||||
assertEquals("guided AGENTS.md setup", result[0].description)
|
||||
assertEquals("reviewer", result[0].agent)
|
||||
assertEquals("anthropic/claude-sonnet-4-6", result[0].model)
|
||||
assertEquals("high", result[0].variant)
|
||||
assertEquals("command", result[0].source)
|
||||
assertEquals(listOf("\$ARGUMENTS"), result[0].hints)
|
||||
assertEquals(true, result[0].subtask)
|
||||
assertEquals("mcp", result[1].source)
|
||||
assertEquals(listOf("\$1", "\$2"), result[1].hints)
|
||||
}
|
||||
@@ -1885,6 +1889,8 @@ class KiloCliDataParserTest {
|
||||
fun `parsePathState - extracts state from valid path response`() {
|
||||
val raw = """{"home":"/home/user","state":"/home/user/.local/state/kilo","config":"/home/user/.config/kilo","worktree":"/project","directory":"/project"}"""
|
||||
assertEquals("/home/user/.local/state/kilo", KiloCliDataParser.parsePathState(raw))
|
||||
assertEquals("/home/user/.config/kilo", KiloCliDataParser.parsePathConfig(raw))
|
||||
assertEquals("/home/user", KiloCliDataParser.parsePathHome(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+70
@@ -119,6 +119,76 @@ class KiloAgentBehaviorRpcApiImplTest {
|
||||
assertEquals(1, mock.requestCount("/instance/reload"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command files and remove command call CLI endpoints`() = runBlocking {
|
||||
val dir = Files.createTempDirectory("kilo-command-test")
|
||||
val file = Files.createDirectories(dir.resolve("command")).resolve("review.md")
|
||||
Files.writeString(file, "---\ndescription: Review code\n---\n\nReview $" + "ARGUMENTS")
|
||||
mock.commandFiles = """[
|
||||
{"name":"review","description":"Review code","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review","subtask":true},
|
||||
{"name":"init","source":"command","builtin":true,"location":"builtin","editable":false,"content":"Init"}
|
||||
]""".trimIndent()
|
||||
val rpc = rpc()
|
||||
|
||||
val commands = rpc.commandFiles("/test project")
|
||||
assertEquals(listOf("review", "init"), commands.map { it.name })
|
||||
assertEquals(true, commands.single { it.name == "review" }.editable)
|
||||
assertEquals("reviewer", commands.single { it.name == "review" }.agent)
|
||||
assertEquals("anthropic/claude-sonnet-4-6", commands.single { it.name == "review" }.model)
|
||||
assertEquals("high", commands.single { it.name == "review" }.variant)
|
||||
assertEquals(true, commands.single { it.name == "review" }.subtask)
|
||||
assertEquals(false, commands.single { it.name == "init" }.editable)
|
||||
|
||||
assertTrue(rpc.removeCommand("/test project", file.toString()))
|
||||
assertEquals("{\"location\":\"$file\"}", mock.lastCommandRemoveBody)
|
||||
assertEquals(1, mock.requestCount("/kilocode/command/remove"))
|
||||
|
||||
mock.commandRemoveStatus = 400
|
||||
val err = assertFailsWith<RuntimeException> {
|
||||
rpc.removeCommand("/test", "/tmp/missing.md")
|
||||
}
|
||||
assertContains(err.message.orEmpty(), "HTTP 400")
|
||||
|
||||
assertTrue(rpc.reloadCommands("/test project"))
|
||||
assertEquals(1, mock.requestCount("/instance/reload"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save commands validates known and new project command paths`() = runBlocking {
|
||||
val project = Files.createTempDirectory("kilo-command-project")
|
||||
val known = Files.createDirectories(project.resolve(".kilo/command")).resolve("known.md")
|
||||
val added = project.resolve(".kilo/commands/new.md")
|
||||
val other = Files.createTempFile("kilo-command-other", ".md")
|
||||
Files.writeString(known, "old")
|
||||
Files.writeString(other, "old")
|
||||
mock.commandFiles = """[
|
||||
{"name":"known","source":"command","builtin":false,"location":"$known","editable":true,"content":"old"}
|
||||
]""".trimIndent()
|
||||
val rpc = rpc()
|
||||
|
||||
assertTrue(rpc.saveCommands(project.toString(), mapOf(known.toString() to "new", added.toString() to "created")))
|
||||
assertEquals("new", Files.readString(known))
|
||||
assertEquals("created", Files.readString(added))
|
||||
assertEquals(1, mock.requestCount("/kilocode/command/files"))
|
||||
|
||||
assertFalse(rpc.saveCommands(project.toString(), mapOf(other.toString() to "nope")))
|
||||
assertEquals("old", Files.readString(other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save commands validates new global command paths`() = runBlocking {
|
||||
val project = Files.createTempDirectory("kilo-command-project")
|
||||
val config = Files.createTempDirectory("kilo-command-config")
|
||||
val added = config.resolve("commands/global.md")
|
||||
mock.path = """{"home":"/tmp","state":"/tmp","config":"$config","worktree":"$project","directory":"$project"}"""
|
||||
val rpc = rpc()
|
||||
|
||||
assertTrue(rpc.saveCommands(project.toString(), mapOf(added.toString() to "global command")))
|
||||
|
||||
assertEquals("global command", Files.readString(added))
|
||||
assertEquals(1, mock.requestCount("/path"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `url cached skills are read only`() = runBlocking {
|
||||
val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote")
|
||||
|
||||
+10
-1
@@ -68,10 +68,12 @@ class MockCliServer : AutoCloseable {
|
||||
@Volatile var mcpStatus = 200
|
||||
@Volatile var mcpActionStatus = 200
|
||||
@Volatile var agentRemoveStatus = 200
|
||||
@Volatile var commandRemoveStatus = 200
|
||||
@Volatile var skillRemoveStatus = 200
|
||||
@Volatile var agentBuilderStatus = 200
|
||||
@Volatile var lastMcpActionPath: String? = null
|
||||
@Volatile var lastAgentRemoveBody: String? = null
|
||||
@Volatile var lastCommandRemoveBody: String? = null
|
||||
@Volatile var lastSkillRemoveBody: String? = null
|
||||
@Volatile var lastAgentBuilderPath: String? = null
|
||||
@Volatile var lastAgentBuilderBody: String? = null
|
||||
@@ -83,11 +85,13 @@ class MockCliServer : AutoCloseable {
|
||||
@Volatile var providersAfterAuthPut: String? = null
|
||||
@Volatile var agents = "[]"
|
||||
@Volatile var commands = "[]"
|
||||
@Volatile var commandFiles = "[]"
|
||||
@Volatile var skills = "[]"
|
||||
@Volatile var providersStatus = 200
|
||||
@Volatile var providerAuthStatus = 200
|
||||
@Volatile var agentsStatus = 200
|
||||
@Volatile var commandsStatus = 200
|
||||
@Volatile var commandFilesStatus = 200
|
||||
@Volatile var skillsStatus = 200
|
||||
|
||||
// File search responses
|
||||
@@ -370,7 +374,7 @@ class MockCliServer : AutoCloseable {
|
||||
respond(output, organizationSetStatus, "true")
|
||||
}
|
||||
path == "/global/event" -> handleSse(output, latch)
|
||||
path == "/path" -> respond(output, 200, this.path)
|
||||
bare == "/path" -> respond(output, 200, this.path)
|
||||
bare == "/provider" -> respond(output, providersStatus, providers)
|
||||
bare == "/provider/auth" -> respond(output, providerAuthStatus, providerAuth)
|
||||
bare == "/agent" -> respond(output, agentsStatus, agents)
|
||||
@@ -384,6 +388,11 @@ class MockCliServer : AutoCloseable {
|
||||
lastAgentRemoveBody = body
|
||||
respond(output, agentRemoveStatus, if (agentRemoveStatus == 200) "true" else """{"error":"Agent not found"}""")
|
||||
}
|
||||
bare == "/kilocode/command/files" -> respond(output, commandFilesStatus, commandFiles)
|
||||
bare == "/kilocode/command/remove" && method == "POST" -> {
|
||||
lastCommandRemoveBody = body
|
||||
respond(output, commandRemoveStatus, if (commandRemoveStatus == 200) "true" else """{"error":"Command not found"}""")
|
||||
}
|
||||
bare == "/kilocode/skill/remove" && method == "POST" -> {
|
||||
lastSkillRemoveBody = body
|
||||
respond(output, skillRemoveStatus, if (skillRemoveStatus == 200) "true" else """{"error":"Skill not found"}""")
|
||||
|
||||
+39
@@ -3,6 +3,7 @@ package ai.kilocode.client.testing
|
||||
import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
@@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.SkillDto
|
||||
class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var agents = emptyList<AgentDetailDto>()
|
||||
var skills = emptyList<SkillDto>()
|
||||
var commandFiles = emptyList<CommandFileDto>()
|
||||
var mcps = emptyList<McpStatusDto>()
|
||||
var mcpConfigs = emptyMap<String, McpServerConfigDto>()
|
||||
val agentCalls = mutableListOf<String>()
|
||||
@@ -19,6 +21,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
val skillRemovals = mutableListOf<Pair<String, String>>()
|
||||
val skillReloads = mutableListOf<String>()
|
||||
val skillSaves = mutableListOf<Triple<String, String, String>>()
|
||||
val commandCalls = mutableListOf<String>()
|
||||
val commandRemovals = mutableListOf<Pair<String, String>>()
|
||||
val commandReloads = mutableListOf<String>()
|
||||
val commandSaves = mutableListOf<Triple<String, String, String>>()
|
||||
val mcpCalls = mutableListOf<String>()
|
||||
val mcpConfigCalls = mutableListOf<String>()
|
||||
val mcpSaves = mutableListOf<Triple<String, String, McpConfigDto?>>()
|
||||
@@ -33,6 +39,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var afterMcpConnect: (suspend (String, String) -> Unit)? = null
|
||||
var createError: Exception? = null
|
||||
var skillsError: Exception? = null
|
||||
var commandFilesError: Exception? = null
|
||||
var removeError: Exception? = null
|
||||
var removeSkillError: Exception? = null
|
||||
var saveSkillError: Exception? = null
|
||||
@@ -42,6 +49,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var removeSkillResult = true
|
||||
var reloadSkillResult = true
|
||||
var saveSkillResult = true
|
||||
var removeCommandResult = true
|
||||
var reloadCommandResult = true
|
||||
var saveCommandResult = true
|
||||
var mcpConnectResult = true
|
||||
var mcpDisconnectResult = true
|
||||
var mcpAuthenticateResult = true
|
||||
@@ -123,6 +133,35 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun commandFiles(directory: String): List<CommandFileDto> {
|
||||
assertNotEdt("agentBehavior.commandFiles")
|
||||
commandFilesError?.let { throw it }
|
||||
commandCalls.add(directory)
|
||||
return commandFiles
|
||||
}
|
||||
|
||||
override suspend fun removeCommand(directory: String, location: String): Boolean {
|
||||
assertNotEdt("agentBehavior.removeCommand")
|
||||
commandRemovals.add(directory to location)
|
||||
if (removeCommandResult) commandFiles = commandFiles.filterNot { it.location == location }
|
||||
return removeCommandResult
|
||||
}
|
||||
|
||||
override suspend fun reloadCommands(directory: String): Boolean {
|
||||
assertNotEdt("agentBehavior.reloadCommands")
|
||||
commandReloads.add(directory)
|
||||
return reloadCommandResult
|
||||
}
|
||||
|
||||
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
|
||||
assertNotEdt("agentBehavior.saveCommands")
|
||||
for ((location, content) in edits) commandSaves.add(Triple(directory, location, content))
|
||||
if (saveCommandResult) commandFiles = commandFiles.map { command ->
|
||||
edits[command.location]?.let { command.copy(content = it) } ?: command
|
||||
}
|
||||
return saveCommandResult
|
||||
}
|
||||
|
||||
override suspend fun mcpStatus(directory: String): List<McpStatusDto> {
|
||||
assertNotEdt("agentBehavior.mcpStatus")
|
||||
mcpStatusError?.let { throw it }
|
||||
|
||||
@@ -1,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.
|
||||
|
||||
+9
@@ -2,6 +2,7 @@ package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
@@ -38,6 +39,14 @@ interface KiloAgentBehaviorRpcApi : RemoteApi<Unit> {
|
||||
|
||||
suspend fun commands(directory: String): List<CommandDto>
|
||||
|
||||
suspend fun commandFiles(directory: String): List<CommandFileDto>
|
||||
|
||||
suspend fun removeCommand(directory: String, location: String): Boolean
|
||||
|
||||
suspend fun reloadCommands(directory: String): Boolean
|
||||
|
||||
suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean
|
||||
|
||||
suspend fun mcpStatus(directory: String): List<McpStatusDto>
|
||||
|
||||
suspend fun mcpConfig(directory: String): Map<String, McpServerConfigDto>
|
||||
|
||||
@@ -6,7 +6,11 @@ import kotlinx.serialization.Serializable
|
||||
data class CommandDto(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val agent: String? = null,
|
||||
val model: String? = null,
|
||||
val variant: String? = null,
|
||||
val source: String? = null,
|
||||
val hints: List<String> = emptyList(),
|
||||
val template: String? = null,
|
||||
val subtask: Boolean? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CommandFileDto(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val agent: String? = null,
|
||||
val model: String? = null,
|
||||
val variant: String? = null,
|
||||
val source: String? = null,
|
||||
val builtin: Boolean = false,
|
||||
val location: String,
|
||||
val editable: Boolean = false,
|
||||
val content: String? = null,
|
||||
val subtask: Boolean? = null,
|
||||
val hints: List<String> = emptyList(),
|
||||
)
|
||||
@@ -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`.
|
||||
|
||||
@@ -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] },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -210,6 +210,16 @@
|
||||
"title": "Agent Manager: Next Tab",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.previousTerminal",
|
||||
"title": "Agent Manager: Previous Terminal",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.nextTerminal",
|
||||
"title": "Agent Manager: Next Terminal",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.search",
|
||||
"title": "Agent Manager: Search Worktrees and Sessions",
|
||||
@@ -514,6 +524,11 @@
|
||||
"key": "ctrl+shift+g",
|
||||
"mac": "cmd+shift+g"
|
||||
},
|
||||
{
|
||||
"command": "-workbench.actions.view.problems",
|
||||
"key": "ctrl+shift+m",
|
||||
"mac": "cmd+shift+m"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManagerOpen",
|
||||
"key": "ctrl+shift+m",
|
||||
@@ -549,6 +564,18 @@
|
||||
"mac": "cmd+alt+right",
|
||||
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.previousTerminal",
|
||||
"key": "ctrl+shift+[",
|
||||
"mac": "cmd+shift+[",
|
||||
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.nextTerminal",
|
||||
"key": "ctrl+shift+]",
|
||||
"mac": "cmd+shift+]",
|
||||
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.search",
|
||||
"key": "ctrl+f",
|
||||
|
||||
@@ -73,7 +73,7 @@ import { createProjectWiring } from "./project/wiring"
|
||||
import { ProjectScope } from "./project/scope"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
|
||||
|
||||
import { focusPanelPrompt } from "./focus-panel"
|
||||
export class AgentManagerProvider implements Disposable {
|
||||
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
|
||||
private panel: PanelContext | undefined
|
||||
@@ -356,22 +356,22 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (this.panel) {
|
||||
this.log("Panel already open, revealing")
|
||||
this.panel.reveal(preserveFocus)
|
||||
if (!preserveFocus) this.postToWebview({ type: "action", action: "focusInput" })
|
||||
if (!preserveFocus)
|
||||
focusPanelPrompt(this.panel, this.waitForPanelReady(this.panel), this.waitForPanelActive(this.panel))
|
||||
return
|
||||
}
|
||||
this.log("Opening Agent Manager panel")
|
||||
this.host.capture("Agent Manager Opened", { source: PLATFORM })
|
||||
|
||||
this.attachPanel(
|
||||
this.host.openPanel({
|
||||
onBeforeMessage: (msg) => this.onMessage(msg),
|
||||
worktreeDirectories: () => this.getWorktreeDirectories(),
|
||||
workspaceRoot: () => this.getRoot(),
|
||||
projectId: () => this.contexts.active()?.id,
|
||||
}),
|
||||
)
|
||||
const panel = this.host.openPanel({
|
||||
onBeforeMessage: (msg) => this.onMessage(msg),
|
||||
worktreeDirectories: () => this.getWorktreeDirectories(),
|
||||
workspaceRoot: () => this.getRoot(),
|
||||
projectId: () => this.contexts.active()?.id,
|
||||
})
|
||||
this.attachPanel(panel)
|
||||
if (!preserveFocus) focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
|
||||
}
|
||||
|
||||
public onPanelVisibilityChange(cb: (visible: boolean) => void): void {
|
||||
this.onVisibilityChange = cb
|
||||
}
|
||||
@@ -1693,11 +1693,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
* Used for the keyboard shortcut to switch back from terminal.
|
||||
*/
|
||||
public focusPanel(): void {
|
||||
if (!this.panel) return
|
||||
this.panel.reveal(false)
|
||||
this.postToWebview({ type: "action", action: "focusInput" })
|
||||
const panel = this.panel
|
||||
if (!panel) return
|
||||
panel.reveal(false)
|
||||
focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.panel?.active === true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PanelContext } from "./host"
|
||||
|
||||
export function focusPanelPrompt(panel: PanelContext, ready: Promise<boolean>, active: Promise<boolean>): void {
|
||||
void Promise.all([ready, active]).then(([ready, active]) => {
|
||||
if (!ready || !active) return
|
||||
panel.postMessage({ type: "action", action: "focusInput" })
|
||||
})
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export function buildKeybindingMap(
|
||||
if (!bindings.runScript) bindings.runScript = formatKeybinding(mac ? "cmd+e" : "ctrl+e", mac)
|
||||
if (!bindings.toggleDiff) bindings.toggleDiff = formatKeybinding(mac ? "cmd+d" : "ctrl+d", mac)
|
||||
if (!bindings.showShortcuts) bindings.showShortcuts = formatKeybinding(mac ? "cmd+shift+/" : "ctrl+shift+/", mac)
|
||||
if (!bindings.previousTerminal)
|
||||
bindings.previousTerminal = formatKeybinding(mac ? "cmd+shift+[" : "ctrl+shift+[", mac)
|
||||
if (!bindings.nextTerminal) bindings.nextTerminal = formatKeybinding(mac ? "cmd+shift+]" : "ctrl+shift+]", mac)
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
@@ -140,7 +140,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// The terminal intercepts all keystrokes unless the command is listed in
|
||||
// terminal.integrated.commandsToSkipShell, which only contains built-in
|
||||
// commands by default.
|
||||
const skip = ["kilo-code.new.agentManagerOpen", "kilo-code.new.agentManager.showTerminal"]
|
||||
const skip = [
|
||||
"kilo-code.new.agentManagerOpen",
|
||||
"kilo-code.new.agentManager.showTerminal",
|
||||
"kilo-code.new.agentManager.previousTerminal",
|
||||
"kilo-code.new.agentManager.nextTerminal",
|
||||
]
|
||||
if (process.platform === "darwin") skip.push("kilo-code.new.agentManager.runScript")
|
||||
ensureCommandsSkipShell(skip)
|
||||
|
||||
@@ -446,6 +451,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => {
|
||||
agentManagerProvider.postMessage({ type: "action", action: "tabNext" })
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.previousTerminal", () => {
|
||||
agentManagerProvider.postMessage({ type: "action", action: "terminalPrevious" })
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTerminal", () => {
|
||||
agentManagerProvider.postMessage({ type: "action", action: "terminalNext" })
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.search", () => {
|
||||
agentManagerProvider.postMessage({ type: "action", action: "search" })
|
||||
}),
|
||||
|
||||
@@ -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,31 @@ describe("Agent Manager Worktree Actions", () => {
|
||||
|
||||
expect(dialog).toMatchObject({ key: "ctrl+n", mac: "cmd+n" })
|
||||
expect(quick).toMatchObject({ key: "ctrl+shift+n", mac: "cmd+shift+n" })
|
||||
expect(source).toContain('newWorktree: isMac ? "⌘N" : "Ctrl+N"')
|
||||
expect(source).toContain('quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N"')
|
||||
const bindings = fs.readFileSync(KEYBIND_DEFAULTS_FILE, "utf-8")
|
||||
expect(bindings).toContain('newWorktree: isMac ? "⌘N" : "Ctrl+N"')
|
||||
expect(bindings).toContain('quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N"')
|
||||
})
|
||||
|
||||
it("reserves Cmd+Shift+M for the Agent Manager instead of Problems", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")) as {
|
||||
contributes: { keybindings: { command: string; key?: string; mac?: string }[] }
|
||||
}
|
||||
const removed = manifest.contributes.keybindings.find((item) => item.command === "-workbench.actions.view.problems")
|
||||
const manager = manifest.contributes.keybindings.find((item) => item.command === "kilo-code.new.agentManagerOpen")
|
||||
|
||||
expect(removed).toMatchObject({ key: "ctrl+shift+m", mac: "cmd+shift+m" })
|
||||
expect(manager).toMatchObject({ key: "ctrl+shift+m", mac: "cmd+shift+m" })
|
||||
})
|
||||
|
||||
it("creates side terminals only while a side terminal owns focus", () => {
|
||||
const source = fs.readFileSync(TSX_FILE, "utf-8")
|
||||
const start = source.indexOf('else if (msg.action === "newTerminal")')
|
||||
const end = source.indexOf('else if (msg.action === "cycleAgentMode"', start)
|
||||
const action = source.slice(start, end)
|
||||
|
||||
expect(action).toContain("if (terms.sideFocusedId()) termHandlers.addSide()")
|
||||
expect(action).not.toContain("terminalVisible()")
|
||||
expect(action).toContain("else termHandlers.requestNew()")
|
||||
})
|
||||
|
||||
it("forwards the quick-worktree command to immediate creation", () => {
|
||||
@@ -843,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
|
||||
@@ -851,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()")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ function scene(
|
||||
saved?: "vscode" | "agentManager"
|
||||
visible?: boolean
|
||||
focusedId?: string
|
||||
count?: number
|
||||
script?: boolean
|
||||
mac?: boolean
|
||||
} = {},
|
||||
) {
|
||||
@@ -33,6 +35,7 @@ function scene(
|
||||
requestSide: () => {
|
||||
calls.requestSide++
|
||||
visible = true
|
||||
focusedId ??= "terminal:side"
|
||||
},
|
||||
ensureSide: () => calls.ensureSide++,
|
||||
closeSide: (terminalId) => {
|
||||
@@ -43,6 +46,8 @@ function scene(
|
||||
},
|
||||
visible: () => visible,
|
||||
focusedId: () => focusedId,
|
||||
count: () => opts.count ?? 2,
|
||||
isScript: () => opts.script ?? false,
|
||||
hide: () => {
|
||||
calls.hide++
|
||||
visible = false
|
||||
@@ -60,7 +65,7 @@ function scene(
|
||||
}
|
||||
|
||||
describe("Agent Manager side terminal controller", () => {
|
||||
it("toggles the panel and hands focus to the chat only when the terminal had it", () => {
|
||||
it("toggles the panel, focusing a visible terminal before hiding it", () => {
|
||||
const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" })
|
||||
focused.ctl.toggle()
|
||||
expect(focused.calls.hide).toBe(1)
|
||||
@@ -68,7 +73,8 @@ describe("Agent Manager side terminal controller", () => {
|
||||
|
||||
const elsewhere = scene({ destination: "agentManager", visible: true })
|
||||
elsewhere.ctl.toggle()
|
||||
expect(elsewhere.calls.hide).toBe(1)
|
||||
expect(elsewhere.calls.requestSide).toBe(1)
|
||||
expect(elsewhere.calls.hide).toBe(0)
|
||||
expect(elsewhere.calls.refocus).toBe(0)
|
||||
|
||||
const hidden = scene({ destination: "agentManager", visible: false })
|
||||
@@ -93,18 +99,32 @@ describe("Agent Manager side terminal controller", () => {
|
||||
hidden.ctl.syncContext("wt-2", "wt-1")
|
||||
expect(hidden.calls.ensureSide).toBe(0)
|
||||
|
||||
const closed = scene({ visible: true })
|
||||
const closed = scene({ visible: true, focusedId: "terminal:side" })
|
||||
closed.ctl.syncContext("wt-2", "wt-1")
|
||||
closed.ctl.toggle()
|
||||
await Promise.resolve()
|
||||
expect(closed.calls.ensureSide).toBe(0)
|
||||
})
|
||||
|
||||
it("kills the focused terminal and refocuses the chat", () => {
|
||||
it("closes the focused terminal without stealing focus from its survivor", () => {
|
||||
const focused = scene({ focusedId: "terminal:two" })
|
||||
expect(focused.ctl.close()).toBe(true)
|
||||
expect(focused.calls.closed).toEqual(["terminal:two"])
|
||||
expect(focused.calls.refocus).toBe(1)
|
||||
expect(focused.calls.refocus).toBe(0)
|
||||
})
|
||||
|
||||
it("hides instead of killing the last or provider-owned terminal", () => {
|
||||
const last = scene({ focusedId: "terminal:last", count: 1 })
|
||||
expect(last.ctl.close()).toBe(true)
|
||||
expect(last.calls.closed).toEqual([])
|
||||
expect(last.calls.hide).toBe(1)
|
||||
expect(last.calls.refocus).toBe(1)
|
||||
|
||||
const script = scene({ focusedId: "script:run", script: true, count: 2 })
|
||||
expect(script.ctl.close()).toBe(true)
|
||||
expect(script.calls.closed).toEqual([])
|
||||
expect(script.calls.hide).toBe(1)
|
||||
expect(script.calls.refocus).toBe(1)
|
||||
})
|
||||
|
||||
it("does nothing on close without a focused terminal", () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ function scene(initial: string | null = LOCAL) {
|
||||
const events = {
|
||||
activated: [] as string[],
|
||||
selected: [] as string[],
|
||||
cleared: 0,
|
||||
saved: 0,
|
||||
shown: [] as string[],
|
||||
errors: 0,
|
||||
@@ -29,7 +30,7 @@ function scene(initial: string | null = LOCAL) {
|
||||
tabIds: tabs,
|
||||
selectReview: () => undefined,
|
||||
selectSessionTab: () => undefined,
|
||||
clearSession: () => undefined,
|
||||
clearSession: () => events.cleared++,
|
||||
resetOthers: () => undefined,
|
||||
isPendingId: () => false,
|
||||
findTab: () => undefined,
|
||||
@@ -401,6 +402,69 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("cycles side terminals in both directions and wraps", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
|
||||
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
|
||||
item.state.setSideActive(LOCAL, "terminal:one")
|
||||
|
||||
expect(item.handlers.cycle("next", "side")).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
|
||||
expect(item.state.focusRequest()?.id).toBe("terminal:two")
|
||||
expect(item.handlers.cycle("next", "side")).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
|
||||
expect(item.handlers.cycle("previous", "side")).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("cycles main terminal tabs independently from side terminals", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
|
||||
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "tab" })
|
||||
item.state.setActiveId("terminal:one")
|
||||
|
||||
expect(item.handlers.cycle("next", "tab")).toBe(true)
|
||||
expect(item.state.activeId()).toBe("terminal:two")
|
||||
expect(item.handlers.cycle("next", "tab")).toBe(true)
|
||||
expect(item.state.activeId()).toBe("terminal:one")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("starts terminal cycling at the boundary when no terminal is active", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
|
||||
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "tab" })
|
||||
item.state.setActiveId(undefined)
|
||||
|
||||
expect(item.handlers.cycle("next", "tab")).toBe(true)
|
||||
expect(item.state.activeId()).toBe("terminal:one")
|
||||
item.state.setActiveId(undefined)
|
||||
expect(item.handlers.cycle("previous", "tab")).toBe(true)
|
||||
expect(item.state.activeId()).toBe("terminal:two")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps the session open when its last main terminal closes", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
|
||||
item.state.setActiveId("terminal:one")
|
||||
item.state.setFocusedId("terminal:one")
|
||||
|
||||
expect(item.handlers.closeFocused()).toBe(true)
|
||||
expect(item.state.current()).toEqual([])
|
||||
expect(item.events.cleared).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("moves activation to the last remaining side terminal on close", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
@@ -410,6 +474,7 @@ describe("Agent Manager terminal state", () => {
|
||||
|
||||
expect(item.handlers.closeSide("terminal:two")).toBe(true)
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
|
||||
expect(item.state.focusRequest()?.id).toBe("terminal:one")
|
||||
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }])
|
||||
|
||||
expect(item.handlers.closeSide("terminal:one")).toBe(true)
|
||||
|
||||
@@ -39,6 +39,11 @@ describe("formatKeybinding", () => {
|
||||
it("formats plain key", () => {
|
||||
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
|
||||
})
|
||||
|
||||
it("formats bracket keys", () => {
|
||||
expect(formatKeybinding("cmd+shift+[", true)).toBe("⌘⇧[")
|
||||
expect(formatKeybinding("cmd+shift+]", true)).toBe("⌘⇧]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("windows/linux", () => {
|
||||
@@ -77,4 +82,11 @@ describe("buildKeybindingMap", () => {
|
||||
expect(buildKeybindingMap(bindings, true).search).toBe("⌘F")
|
||||
expect(buildKeybindingMap(bindings, false).search).toBe("Ctrl+F")
|
||||
})
|
||||
|
||||
it("provides terminal navigation fallbacks", () => {
|
||||
expect(buildKeybindingMap([], true).previousTerminal).toBe("⌘⇧[")
|
||||
expect(buildKeybindingMap([], true).nextTerminal).toBe("⌘⇧]")
|
||||
expect(buildKeybindingMap([], false).previousTerminal).toBe("Ctrl+Shift+[")
|
||||
expect(buildKeybindingMap([], false).nextTerminal).toBe("Ctrl+Shift+]")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
AgentManagerKeybindingsMessage,
|
||||
AgentManagerMultiVersionProgressMessage,
|
||||
AgentManagerSendInitialMessage,
|
||||
AgentManagerBranchesMessage,
|
||||
AgentManagerWorktreeDiffMessage,
|
||||
AgentManagerWorktreeDiffFileMessage,
|
||||
AgentManagerWorktreeDiffLoadingMessage,
|
||||
@@ -43,7 +42,6 @@ import type {
|
||||
SectionState,
|
||||
SessionInfo,
|
||||
SessionCreatedMessage,
|
||||
BranchInfo,
|
||||
TerminalDestination,
|
||||
TerminalFont,
|
||||
} from "../src/types/messages"
|
||||
@@ -78,6 +76,7 @@ import { ProviderShell } from "../src/context/provider-shell"
|
||||
import { ChatView } from "../src/components/chat"
|
||||
import HistoryView from "../src/components/history/HistoryView"
|
||||
import { NewWorktreeDialog } from "./NewWorktreeDialog"
|
||||
import { DefaultBaseBranchDialog } from "./DefaultBaseBranchDialog"
|
||||
import { createModeRouter } from "./mode-router"
|
||||
import { ProjectList } from "./ProjectList"
|
||||
import { SidebarBody } from "./SidebarBody"
|
||||
@@ -152,7 +151,6 @@ import type { ReviewComment } from "../diff-viewer/review-comments"
|
||||
import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations"
|
||||
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
|
||||
import { BranchSelect } from "../src/components/shared/BranchSelect"
|
||||
import { randomColor } from "./section-colors"
|
||||
import { createNewTaskDrafts } from "./new-task-drafts"
|
||||
import {
|
||||
@@ -199,37 +197,10 @@ interface SetupState {
|
||||
type SidebarSelection = typeof LOCAL | string | null
|
||||
type SidePanel = "diff" | "pr" | "terminal" | null
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
// Fallback keybindings before extension sends resolved ones
|
||||
const MAX_JUMP_INDEX = 9
|
||||
const 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+→",
|
||||
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()
|
||||
@@ -278,7 +249,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
|
||||
@@ -422,9 +392,16 @@ const AgentManagerContent: Component = () => {
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
type FocusOwner = "prompt" | { terminal: string }
|
||||
const focusMemory = new Map<string, FocusOwner>()
|
||||
let focusInputUntil = 0
|
||||
const focusPrompt = () => {
|
||||
focusInputUntil = Date.now() + 500
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
terms.setActiveId(undefined)
|
||||
terms.setFocusedId(undefined)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
const focusKey = () => {
|
||||
const context = terms.sideKey()
|
||||
const sessionID = session.currentSessionID() ?? activePendingId() ?? "new"
|
||||
@@ -456,6 +433,7 @@ const AgentManagerContent: Component = () => {
|
||||
return terminalVisible() ? false : true
|
||||
}
|
||||
const restoreFocus = () => {
|
||||
if (Date.now() < focusInputUntil) return
|
||||
const key = focusKey()
|
||||
const owner = focusMemory.get(key)
|
||||
if (owner && owner !== "prompt") {
|
||||
@@ -1192,6 +1170,8 @@ const AgentManagerContent: Component = () => {
|
||||
else if (msg.action === "sessionNext") projectNav.step("down")
|
||||
else if (msg.action === "tabPrevious") navigateTab("left")
|
||||
else if (msg.action === "tabNext") navigateTab("right")
|
||||
else if (msg.action === "terminalPrevious") cycleTerminal("previous")
|
||||
else if (msg.action === "terminalNext") cycleTerminal("next")
|
||||
else if (msg.action === "search") {
|
||||
if (!sidebarCollapsed()) sidebarSearchMenu?.open()
|
||||
else {
|
||||
@@ -1215,11 +1195,13 @@ const AgentManagerContent: Component = () => {
|
||||
else if (msg.action === "advancedWorktree") showNewWorktreeDialog()
|
||||
else if (msg.action === "closeWorktree") closeSelectedWorktree()
|
||||
else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts()
|
||||
else if (msg.action === "focusInput") requestChatFocus(true)
|
||||
else if (msg.action === "focusInput") focusPrompt()
|
||||
else if (msg.action === "focusSearch")
|
||||
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
|
||||
else if (msg.action === "newTerminal") termHandlers.requestNew()
|
||||
else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
|
||||
else if (msg.action === "newTerminal") {
|
||||
if (terms.sideFocusedId()) termHandlers.addSide()
|
||||
else termHandlers.requestNew()
|
||||
} else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
|
||||
if (!mode.dispatch(1)) cycleAgent(1)
|
||||
} else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) {
|
||||
if (!mode.dispatch(-1)) cycleAgent(-1)
|
||||
@@ -1230,7 +1212,6 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", handler)
|
||||
|
||||
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
|
||||
const preventDefaults = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey)) return
|
||||
@@ -1244,8 +1225,9 @@ const AgentManagerContent: Component = () => {
|
||||
if (["t", "w", "n", "d", "e", "f"].includes(e.key.toLowerCase()) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
}
|
||||
// Prevent defaults for shift variants (close worktree, advanced/new/open worktree, open PR)
|
||||
if (["w", "n", "o", "r"].includes(e.key.toLowerCase()) && e.shiftKey) {
|
||||
// Prevent browser defaults for shift variants (new terminal, close worktree,
|
||||
// advanced/new/open worktree, open PR, terminal cycling)
|
||||
if (["t", "m", "w", "n", "o", "r", "[", "]"].includes(e.key.toLowerCase()) && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
}
|
||||
// Prevent browser defaults for shortcuts help (Cmd/Ctrl+Shift+/)
|
||||
@@ -1759,99 +1741,15 @@ const AgentManagerContent: Component = () => {
|
||||
const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript)
|
||||
|
||||
const handleChangeDefaultBaseBranch = () => {
|
||||
const [search, setSearch] = createSignal("")
|
||||
const [branches, setBranches] = createSignal<BranchInfo[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [highlighted, setHighlighted] = createSignal(-1)
|
||||
|
||||
const unsub = vscode.onMessage((msg) => {
|
||||
if (msg.type === "agentManager.branches") {
|
||||
const ev = msg as AgentManagerBranchesMessage
|
||||
setBranches(ev.branches)
|
||||
if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
vscode.postMessage({ type: "agentManager.requestBranches" })
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const s = search().toLowerCase()
|
||||
if (!s) return branches()
|
||||
return branches().filter((b) => b.name.toLowerCase().includes(s))
|
||||
})
|
||||
|
||||
const selectBranch = (name: string | undefined) => {
|
||||
vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name })
|
||||
setDefaultBaseBranch(name)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const items = filtered()
|
||||
// offset by 1 for auto-detect option (-1 = auto-detect)
|
||||
const total = items.length + 1
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHighlighted((prev) => Math.min(prev + 1, total - 2))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHighlighted((prev) => Math.max(prev - 1, -1))
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const idx = highlighted()
|
||||
if (idx === -1) {
|
||||
selectBranch(undefined)
|
||||
} else {
|
||||
const branch = items[idx]
|
||||
if (branch) selectBranch(branch.name)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dialog.close()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show(() => {
|
||||
onCleanup(unsub)
|
||||
return (
|
||||
<Dialog title={t("agentManager.worktree.defaultBaseBranch")} fit>
|
||||
<div class="am-default-base-branch">
|
||||
<BranchSelect
|
||||
branches={filtered()}
|
||||
loading={loading()}
|
||||
search={search()}
|
||||
onSearch={(v) => {
|
||||
setSearch(v)
|
||||
setHighlighted(-1)
|
||||
}}
|
||||
onSelect={(b) => selectBranch(b.name)}
|
||||
onSearchKeyDown={handleKeyDown}
|
||||
selected={defaultBaseBranch()}
|
||||
highlighted={highlighted()}
|
||||
onHighlight={setHighlighted}
|
||||
searchPlaceholder={t("agentManager.dialog.searchBranches")}
|
||||
emptyLabel={t("agentManager.import.noMatchingBranches")}
|
||||
loadingLabel={t("agentManager.import.loadingBranches")}
|
||||
defaultLabel={t("agentManager.dialog.branchBadge.default")}
|
||||
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
|
||||
defaultName={defaultBaseBranch()}
|
||||
autoOption={{
|
||||
label: t("agentManager.worktree.defaultBaseBranchAuto"),
|
||||
hint: repoDetectedBranch(),
|
||||
active: !hasConfiguredBranch(),
|
||||
highlighted: highlighted() === -1,
|
||||
onSelect: () => selectBranch(undefined),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
dialog.show(() => (
|
||||
<DefaultBaseBranchDialog
|
||||
selected={defaultBaseBranch()}
|
||||
detected={repoDetectedBranch()}
|
||||
onSelect={setDefaultBaseBranch}
|
||||
onDetected={setRepoDetectedBranch}
|
||||
onClose={() => dialog.close()}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const handleShowKeyboardShortcuts = () => {
|
||||
@@ -2115,6 +2013,8 @@ const AgentManagerContent: Component = () => {
|
||||
handlers: termHandlers,
|
||||
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
|
||||
focusedId: () => terms.sideFocusedId(),
|
||||
count: () => terms.sidesForContext(terms.sideKey()).length,
|
||||
isScript: terms.isScript,
|
||||
hide: () => {
|
||||
cancelAmbientSetup()
|
||||
setSidePanel(null)
|
||||
@@ -2233,16 +2133,24 @@ const AgentManagerContent: Component = () => {
|
||||
})
|
||||
}
|
||||
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
|
||||
const cycleTerminal = (direction: "previous" | "next") => {
|
||||
const focused = terms.focusedId()
|
||||
const placement = terms.sideFocusedId() || (!focused && terminalVisible()) ? "side" : "tab"
|
||||
return termHandlers.cycle(direction, placement)
|
||||
}
|
||||
|
||||
// Close the currently active tab via keyboard shortcut.
|
||||
// If no tabs remain, fall through to close the selected worktree.
|
||||
const closeActiveTab = () => {
|
||||
// A focused side terminal owns Cmd+W while its panel is visible —
|
||||
// closing a chat tab out from under the user's cursor would be
|
||||
// surprising. Only that terminal dies; the panel keeps the rest.
|
||||
// A focused side terminal owns Cmd+W while its panel is visible.
|
||||
// Closing a chat tab out from under the user's cursor would be surprising.
|
||||
if (sidePanel() === "terminal" && terms.sideFocusedId()) {
|
||||
if (sideCtl.close()) return
|
||||
}
|
||||
if (termHandlers.closeFocused()) {
|
||||
tabFocus.restore()
|
||||
return
|
||||
}
|
||||
if (termHandlers.closeActive()) {
|
||||
tabFocus.restore()
|
||||
return
|
||||
@@ -2493,6 +2401,9 @@ const AgentManagerContent: Component = () => {
|
||||
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
|
||||
terminalDestination={sideCtl.destination}
|
||||
terminalDestinationActive={() => sidePanel() === "terminal"}
|
||||
terminalDestinationFocused={() =>
|
||||
sideCtl.destination() === "agentManager" && terms.sideFocusedId() !== undefined
|
||||
}
|
||||
terminalKeybind={() => kb().showTerminal ?? ""}
|
||||
onTerminalDestinationOpen={() => {
|
||||
cancelAmbientSetup()
|
||||
@@ -2561,7 +2472,7 @@ const AgentManagerContent: Component = () => {
|
||||
>
|
||||
<div class={`am-main-pane ${terms.activeId() ? "am-main-pane-terminal-active" : ""}`}>
|
||||
{/* Keep terminal tabs mounted so output streams across worktree switches. */}
|
||||
{renderTerminalLayer({ state: terms })}
|
||||
{renderTerminalLayer({ state: terms, onFocusPrompt: focusPrompt })}
|
||||
{/* Session-less context (e.g. a worktree mid-provisioning): the
|
||||
empty state lives in the main pane so the side terminal
|
||||
panel can render next to it. */}
|
||||
@@ -2719,6 +2630,9 @@ const AgentManagerContent: Component = () => {
|
||||
state={terms}
|
||||
contextKey={terms.sideKey}
|
||||
visible={() => sidePanel() === "terminal"}
|
||||
nextKeybind={kb().nextTerminal ?? ""}
|
||||
closeKeybind={kb().closeTab ?? ""}
|
||||
onFocusPrompt={focusPrompt}
|
||||
onSelect={(id) => termHandlers.selectSide(id)}
|
||||
onClose={(id) => {
|
||||
cancelAmbientSetup()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import { createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { BranchSelect } from "../src/components/shared/BranchSelect"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { useVSCode } from "../src/context/vscode"
|
||||
import type { AgentManagerBranchesMessage, BranchInfo } from "../src/types/messages"
|
||||
|
||||
interface Props {
|
||||
selected?: string
|
||||
detected?: string
|
||||
onSelect: (branch?: string) => void
|
||||
onDetected: (branch: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const DefaultBaseBranchDialog: Component<Props> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const [search, setSearch] = createSignal("")
|
||||
const [branches, setBranches] = createSignal<BranchInfo[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [highlighted, setHighlighted] = createSignal(-1)
|
||||
const filtered = createMemo(() => {
|
||||
const value = search().toLowerCase()
|
||||
return value ? branches().filter((branch) => branch.name.toLowerCase().includes(value)) : branches()
|
||||
})
|
||||
const select = (branch?: string) => {
|
||||
vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch })
|
||||
props.onSelect(branch)
|
||||
props.onClose()
|
||||
}
|
||||
const unsub = vscode.onMessage((message) => {
|
||||
if (message.type !== "agentManager.branches") return
|
||||
const event = message as AgentManagerBranchesMessage
|
||||
setBranches(event.branches)
|
||||
if (event.defaultBranch) props.onDetected(event.defaultBranch)
|
||||
setLoading(false)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
vscode.postMessage({ type: "agentManager.requestBranches" })
|
||||
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
const items = filtered()
|
||||
const total = items.length + 1
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setHighlighted((value) => Math.min(value + 1, total - 2))
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setHighlighted((value) => Math.max(value - 1, -1))
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const index = highlighted()
|
||||
if (index === -1) {
|
||||
select()
|
||||
return
|
||||
}
|
||||
const branch = items[index]
|
||||
if (branch) select(branch.name)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={t("agentManager.worktree.defaultBaseBranch")} fit>
|
||||
<div class="am-default-base-branch">
|
||||
<BranchSelect
|
||||
branches={filtered()}
|
||||
loading={loading()}
|
||||
search={search()}
|
||||
onSearch={(value) => {
|
||||
setSearch(value)
|
||||
setHighlighted(-1)
|
||||
}}
|
||||
onSelect={(branch) => select(branch.name)}
|
||||
onSearchKeyDown={keydown}
|
||||
selected={props.selected}
|
||||
highlighted={highlighted()}
|
||||
onHighlight={setHighlighted}
|
||||
searchPlaceholder={t("agentManager.dialog.searchBranches")}
|
||||
emptyLabel={t("agentManager.import.noMatchingBranches")}
|
||||
loadingLabel={t("agentManager.import.loadingBranches")}
|
||||
defaultLabel={t("agentManager.dialog.branchBadge.default")}
|
||||
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
|
||||
defaultName={props.selected}
|
||||
autoOption={{
|
||||
label: t("agentManager.worktree.defaultBaseBranchAuto"),
|
||||
hint: props.detected,
|
||||
active: !props.selected,
|
||||
highlighted: highlighted() === -1,
|
||||
onSelect: () => select(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export interface TabBarProps {
|
||||
onToggleReview: () => void
|
||||
terminalDestination: () => TerminalDestination
|
||||
terminalDestinationActive: () => boolean
|
||||
terminalDestinationFocused: () => boolean
|
||||
terminalKeybind: () => string
|
||||
onTerminalDestinationOpen: () => void
|
||||
onTerminalDestinationChoose: (destination: TerminalDestination) => void
|
||||
@@ -253,12 +254,13 @@ export const TabBar: Component<TabBarProps> = (props) => (
|
||||
</Tooltip>
|
||||
</Show>
|
||||
{/* Terminal destination split button: the primary action
|
||||
follows the user's setting (VS Code integrated terminal
|
||||
or the embedded side panel), the dropdown picks which.
|
||||
Cmd+Shift+T still creates an xterm tab via the `+` menu. */}
|
||||
follows the user's setting (VS Code integrated terminal
|
||||
or the embedded side panel), the dropdown picks which.
|
||||
Cmd+Shift+T creates a terminal in the active terminal container. */}
|
||||
<TerminalDestinationButton
|
||||
destination={props.terminalDestination}
|
||||
active={props.terminalDestinationActive}
|
||||
focused={props.terminalDestinationFocused}
|
||||
keybind={props.terminalKeybind}
|
||||
onOpen={props.onTerminalDestinationOpen}
|
||||
onChoose={props.onTerminalDestinationChoose}
|
||||
|
||||
@@ -4754,6 +4754,10 @@ body.vscode-high-contrast-light {
|
||||
outline: 1px solid var(--vscode-contrastActiveBorder, var(--vscode-focusBorder));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.am-terminal-host:focus-within {
|
||||
box-shadow: inset 0 0 0 1px var(--vscode-contrastActiveBorder, var(--vscode-focusBorder));
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu — restyle to match dropdown-menu visuals.
|
||||
@@ -4799,6 +4803,10 @@ body.vscode-high-contrast-light {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.am-tab-terminal-focused {
|
||||
background: var(--surface-base-hover);
|
||||
}
|
||||
|
||||
/*
|
||||
* Stacking layout for xterm terminal tabs.
|
||||
*
|
||||
@@ -5001,6 +5009,10 @@ body.vscode-high-contrast-light {
|
||||
background: var(--vscode-terminal-background, #1e1e1e);
|
||||
}
|
||||
|
||||
.am-terminal-host:focus-within {
|
||||
box-shadow: inset 0 0 0 1px var(--border-focus, var(--vscode-focusBorder));
|
||||
}
|
||||
|
||||
/* Third-party xterm classes — addressed by attribute selector so the
|
||||
agent-manager "am-* prefix" architecture test does not flag them.
|
||||
FitAddon subtracts padding from xterm itself, not its parent host. */
|
||||
|
||||
@@ -12,7 +12,7 @@ export function createChatFocus(deps: {
|
||||
review: () => boolean
|
||||
}) {
|
||||
const focus = (force: boolean) => {
|
||||
if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return
|
||||
if ((!force && (!document.hasFocus() || deps.term())) || deps.history() || deps.review()) return
|
||||
if (preservesTextFocus(document.activeElement)) return
|
||||
if (!force && document.activeElement?.matches('[role="tab"]')) return
|
||||
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "علامة التبويب التالية",
|
||||
"agentManager.shortcuts.newTab": "علامة تبويب جديدة",
|
||||
"agentManager.shortcuts.closeTab": "إغلاق علامة التبويب",
|
||||
"agentManager.shortcuts.toggleTerminal": "تبديل الطرفية",
|
||||
"agentManager.shortcuts.toggleTerminal": "التركيز على الطرفية / إخفاء الطرفية",
|
||||
"agentManager.shortcuts.runScript": "تشغيل السكربت",
|
||||
"agentManager.run.options": "خيارات التشغيل",
|
||||
"agentManager.run.configure": "تكوين سكربت التشغيل",
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Próxima aba",
|
||||
"agentManager.shortcuts.newTab": "Nova aba",
|
||||
"agentManager.shortcuts.closeTab": "Fechar aba",
|
||||
"agentManager.shortcuts.toggleTerminal": "Alternar terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Focar / ocultar terminal",
|
||||
"agentManager.shortcuts.runScript": "Executar script",
|
||||
"agentManager.run.options": "Opções de execução",
|
||||
"agentManager.run.configure": "Configurar script de execução",
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Sljedeća kartica",
|
||||
"agentManager.shortcuts.newTab": "Nova kartica",
|
||||
"agentManager.shortcuts.closeTab": "Zatvori karticu",
|
||||
"agentManager.shortcuts.toggleTerminal": "Prebaci terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Fokusiraj / sakrij terminal",
|
||||
"agentManager.shortcuts.runScript": "Pokreni skriptu",
|
||||
"agentManager.run.options": "Opcije pokretanja",
|
||||
"agentManager.run.configure": "Konfiguriši skriptu za pokretanje",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Næste fane",
|
||||
"agentManager.shortcuts.newTab": "Ny fane",
|
||||
"agentManager.shortcuts.closeTab": "Luk fane",
|
||||
"agentManager.shortcuts.toggleTerminal": "Skift terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Fokusér / skjul terminalen",
|
||||
"agentManager.shortcuts.runScript": "Kør script",
|
||||
"agentManager.run.options": "Kørselsindstillinger",
|
||||
"agentManager.run.configure": "Konfigurer kørselsscript",
|
||||
|
||||
@@ -103,7 +103,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Nächster Tab",
|
||||
"agentManager.shortcuts.newTab": "Neuer Tab",
|
||||
"agentManager.shortcuts.closeTab": "Tab schließen",
|
||||
"agentManager.shortcuts.toggleTerminal": "Terminal umschalten",
|
||||
"agentManager.shortcuts.toggleTerminal": "Terminal fokussieren / ausblenden",
|
||||
"agentManager.shortcuts.runScript": "Skript ausführen",
|
||||
"agentManager.run.options": "Ausführungsoptionen",
|
||||
"agentManager.run.configure": "Ausführungsskript konfigurieren",
|
||||
|
||||
@@ -105,7 +105,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Next tab",
|
||||
"agentManager.shortcuts.newTab": "New tab",
|
||||
"agentManager.shortcuts.closeTab": "Close tab",
|
||||
"agentManager.shortcuts.toggleTerminal": "Toggle terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Focus / hide terminal",
|
||||
"agentManager.shortcuts.runScript": "Run script",
|
||||
"agentManager.run.options": "Run options",
|
||||
"agentManager.run.configure": "Configure run script",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Siguiente pestaña",
|
||||
"agentManager.shortcuts.newTab": "Nueva pestaña",
|
||||
"agentManager.shortcuts.closeTab": "Cerrar pestaña",
|
||||
"agentManager.shortcuts.toggleTerminal": "Alternar terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Enfocar / ocultar la terminal",
|
||||
"agentManager.shortcuts.runScript": "Ejecutar script",
|
||||
"agentManager.run.options": "Opciones de ejecución",
|
||||
"agentManager.run.configure": "Configurar script de ejecución",
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "تب بعدی",
|
||||
"agentManager.shortcuts.newTab": "تب جدید",
|
||||
"agentManager.shortcuts.closeTab": "بستن تب",
|
||||
"agentManager.shortcuts.toggleTerminal": "نمایش/پنهان کردن ترمینال",
|
||||
"agentManager.shortcuts.toggleTerminal": "تمرکز روی ترمینال / پنهان کردن ترمینال",
|
||||
"agentManager.shortcuts.runScript": "اجرای اسکریپت",
|
||||
"agentManager.run.options": "گزینههای اجرا",
|
||||
"agentManager.run.configure": "پیکربندی اسکریپت اجرا",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Onglet suivant",
|
||||
"agentManager.shortcuts.newTab": "Nouvel onglet",
|
||||
"agentManager.shortcuts.closeTab": "Fermer l'onglet",
|
||||
"agentManager.shortcuts.toggleTerminal": "Basculer le terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Focaliser / masquer le terminal",
|
||||
"agentManager.shortcuts.runScript": "Exécuter le script",
|
||||
"agentManager.run.options": "Options d'exécution",
|
||||
"agentManager.run.configure": "Configurer le script d'exécution",
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Scheda successiva",
|
||||
"agentManager.shortcuts.newTab": "Nuova scheda",
|
||||
"agentManager.shortcuts.closeTab": "Chiudi scheda",
|
||||
"agentManager.shortcuts.toggleTerminal": "Mostra/nascondi terminale",
|
||||
"agentManager.shortcuts.toggleTerminal": "Metti a fuoco / nascondi il terminale",
|
||||
"agentManager.shortcuts.runScript": "Esegui script",
|
||||
"agentManager.run.options": "Opzioni di esecuzione",
|
||||
"agentManager.run.configure": "Configura script di esecuzione",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "次のタブ",
|
||||
"agentManager.shortcuts.newTab": "新しいタブ",
|
||||
"agentManager.shortcuts.closeTab": "タブを閉じる",
|
||||
"agentManager.shortcuts.toggleTerminal": "ターミナルの切り替え",
|
||||
"agentManager.shortcuts.toggleTerminal": "ターミナルにフォーカス / 非表示にする",
|
||||
"agentManager.shortcuts.runScript": "スクリプトを実行",
|
||||
"agentManager.run.options": "実行オプション",
|
||||
"agentManager.run.configure": "実行スクリプトを設定",
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "다음 탭",
|
||||
"agentManager.shortcuts.newTab": "새 탭",
|
||||
"agentManager.shortcuts.closeTab": "탭 닫기",
|
||||
"agentManager.shortcuts.toggleTerminal": "터미널 전환",
|
||||
"agentManager.shortcuts.toggleTerminal": "터미널에 포커스 / 터미널 숨기기",
|
||||
"agentManager.shortcuts.runScript": "스크립트 실행",
|
||||
"agentManager.run.options": "실행 옵션",
|
||||
"agentManager.run.configure": "실행 스크립트 구성",
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Volgend tabblad",
|
||||
"agentManager.shortcuts.newTab": "Nieuw tabblad",
|
||||
"agentManager.shortcuts.closeTab": "Tabblad sluiten",
|
||||
"agentManager.shortcuts.toggleTerminal": "Terminal in-/uitschakelen",
|
||||
"agentManager.shortcuts.toggleTerminal": "Terminal focussen / verbergen",
|
||||
"agentManager.shortcuts.runScript": "Script uitvoeren",
|
||||
"agentManager.run.options": "Uitvoeropties",
|
||||
"agentManager.run.configure": "Uitvoerscript configureren",
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Neste fane",
|
||||
"agentManager.shortcuts.newTab": "Ny fane",
|
||||
"agentManager.shortcuts.closeTab": "Lukk fane",
|
||||
"agentManager.shortcuts.toggleTerminal": "Veksle terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Fokuser / skjul terminalen",
|
||||
"agentManager.shortcuts.runScript": "Kjør skript",
|
||||
"agentManager.run.options": "Kjøringsalternativer",
|
||||
"agentManager.run.configure": "Konfigurer kjøreskript",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Następna karta",
|
||||
"agentManager.shortcuts.newTab": "Nowa karta",
|
||||
"agentManager.shortcuts.closeTab": "Zamknij kartę",
|
||||
"agentManager.shortcuts.toggleTerminal": "Przełącz terminal",
|
||||
"agentManager.shortcuts.toggleTerminal": "Przenieś fokus do terminala / ukryj terminal",
|
||||
"agentManager.shortcuts.runScript": "Uruchom skrypt",
|
||||
"agentManager.run.options": "Opcje uruchamiania",
|
||||
"agentManager.run.configure": "Konfiguruj skrypt uruchamiania",
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Следующая вкладка",
|
||||
"agentManager.shortcuts.newTab": "Новая вкладка",
|
||||
"agentManager.shortcuts.closeTab": "Закрыть вкладку",
|
||||
"agentManager.shortcuts.toggleTerminal": "Переключить терминал",
|
||||
"agentManager.shortcuts.toggleTerminal": "Перевести фокус на терминал / скрыть терминал",
|
||||
"agentManager.shortcuts.runScript": "Запустить скрипт",
|
||||
"agentManager.run.options": "Параметры запуска",
|
||||
"agentManager.run.configure": "Настроить скрипт запуска",
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "แท็บถัดไป",
|
||||
"agentManager.shortcuts.newTab": "แท็บใหม่",
|
||||
"agentManager.shortcuts.closeTab": "ปิดแท็บ",
|
||||
"agentManager.shortcuts.toggleTerminal": "สลับเทอร์มินัล",
|
||||
"agentManager.shortcuts.toggleTerminal": "โฟกัสเทอร์มินัล / ซ่อนเทอร์มินัล",
|
||||
"agentManager.shortcuts.runScript": "เรียกใช้สคริปต์",
|
||||
"agentManager.run.options": "ตัวเลือกการเรียกใช้",
|
||||
"agentManager.run.configure": "กำหนดค่าสคริปต์การเรียกใช้",
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Sonraki sekme",
|
||||
"agentManager.shortcuts.newTab": "Yeni sekme",
|
||||
"agentManager.shortcuts.closeTab": "Sekmeyi kapat",
|
||||
"agentManager.shortcuts.toggleTerminal": "Terminali aç/kapat",
|
||||
"agentManager.shortcuts.toggleTerminal": "Terminale odaklan / terminali gizle",
|
||||
"agentManager.shortcuts.runScript": "Betiği çalıştır",
|
||||
"agentManager.run.options": "Çalıştırma seçenekleri",
|
||||
"agentManager.run.configure": "Çalıştırma betiğini yapılandır",
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "Наступна вкладка",
|
||||
"agentManager.shortcuts.newTab": "Нова вкладка",
|
||||
"agentManager.shortcuts.closeTab": "Закрити вкладку",
|
||||
"agentManager.shortcuts.toggleTerminal": "Перемкнути термінал",
|
||||
"agentManager.shortcuts.toggleTerminal": "Перейти до термінала / приховати термінал",
|
||||
"agentManager.shortcuts.runScript": "Запустити скрипт",
|
||||
"agentManager.run.options": "Параметри запуску",
|
||||
"agentManager.run.configure": "Налаштувати скрипт запуску",
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "下一个标签页",
|
||||
"agentManager.shortcuts.newTab": "新建标签页",
|
||||
"agentManager.shortcuts.closeTab": "关闭标签页",
|
||||
"agentManager.shortcuts.toggleTerminal": "切换终端",
|
||||
"agentManager.shortcuts.toggleTerminal": "聚焦终端 / 隐藏终端",
|
||||
"agentManager.shortcuts.runScript": "运行脚本",
|
||||
"agentManager.run.options": "运行选项",
|
||||
"agentManager.run.configure": "配置运行脚本",
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ export const dict = {
|
||||
"agentManager.shortcuts.nextTab": "下一個分頁",
|
||||
"agentManager.shortcuts.newTab": "新建分頁",
|
||||
"agentManager.shortcuts.closeTab": "關閉分頁",
|
||||
"agentManager.shortcuts.toggleTerminal": "切換終端機",
|
||||
"agentManager.shortcuts.toggleTerminal": "聚焦終端機 / 隱藏終端機",
|
||||
"agentManager.shortcuts.runScript": "執行指令碼",
|
||||
"agentManager.run.options": "執行選項",
|
||||
"agentManager.run.configure": "設定執行指令碼",
|
||||
|
||||
@@ -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}`]),
|
||||
),
|
||||
}
|
||||
@@ -12,15 +12,16 @@ export function buildShortcutCategories(
|
||||
bindings: Record<string, string>,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): ShortcutCategory[] {
|
||||
const bind = (key: string) => bindings[key] ?? ""
|
||||
return [
|
||||
{
|
||||
title: t("agentManager.shortcuts.category.quickSwitch"),
|
||||
shortcuts: [
|
||||
{ label: t("agentManager.sidebarSearch.label"), binding: bindings.search ?? "" },
|
||||
{ label: t("agentManager.sidebarSearch.label"), binding: bind("search") },
|
||||
{
|
||||
label: t("agentManager.shortcuts.jumpToItem"),
|
||||
binding: (() => {
|
||||
const first = bindings.jumpTo1 ?? ""
|
||||
const first = bind("jumpTo1")
|
||||
const prefix = first.replace(/\d+$/, "")
|
||||
return prefix ? `${prefix}1-9` : ""
|
||||
})(),
|
||||
@@ -30,39 +31,48 @@ export function buildShortcutCategories(
|
||||
{
|
||||
title: t("agentManager.shortcuts.category.sidebar"),
|
||||
shortcuts: [
|
||||
{ label: t("agentManager.shortcuts.previousItem"), binding: bindings.previousSession ?? "" },
|
||||
{ label: t("agentManager.shortcuts.nextItem"), binding: bindings.nextSession ?? "" },
|
||||
{ label: t("agentManager.shortcuts.previousItem"), binding: bind("previousSession") },
|
||||
{ label: t("agentManager.shortcuts.nextItem"), binding: bind("nextSession") },
|
||||
{ label: t("agentManager.shortcuts.advancedWorktree"), binding: bindings.newWorktree ?? "" },
|
||||
{ label: t("agentManager.shortcuts.newWorktree"), binding: bindings.quickWorktree ?? "" },
|
||||
{ label: t("agentManager.shortcuts.deleteWorktree"), binding: bindings.closeWorktree ?? "" },
|
||||
{ label: t("agentManager.shortcuts.openWorktree"), binding: bindings.openWorktree ?? "" },
|
||||
{ label: t("agentManager.shortcuts.openPR"), binding: bindings.openPR ?? "" },
|
||||
{ label: t("agentManager.shortcuts.deleteWorktree"), binding: bind("closeWorktree") },
|
||||
{ label: t("agentManager.shortcuts.openWorktree"), binding: bind("openWorktree") },
|
||||
{ label: t("agentManager.shortcuts.openPR"), binding: bind("openPR") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("agentManager.shortcuts.category.tabs"),
|
||||
shortcuts: [
|
||||
{ label: t("agentManager.shortcuts.previousTab"), binding: bindings.previousTab ?? "" },
|
||||
{ label: t("agentManager.shortcuts.nextTab"), binding: bindings.nextTab ?? "" },
|
||||
{ label: t("agentManager.shortcuts.newTab"), binding: bindings.newTab ?? "" },
|
||||
{ label: t("agentManager.shortcuts.closeTab"), binding: bindings.closeTab ?? "" },
|
||||
{ label: t("agentManager.shortcuts.previousTab"), binding: bind("previousTab") },
|
||||
{ label: t("agentManager.shortcuts.nextTab"), binding: bind("nextTab") },
|
||||
{ label: t("agentManager.shortcuts.newTab"), binding: bind("newTab") },
|
||||
{ label: t("agentManager.shortcuts.closeTab"), binding: bind("closeTab") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("agentManager.shortcuts.category.terminal"),
|
||||
shortcuts: [
|
||||
{ label: t("agentManager.shortcuts.toggleTerminal"), binding: bindings.showTerminal ?? "" },
|
||||
{ label: t("agentManager.shortcuts.runScript"), binding: bindings.runScript ?? "" },
|
||||
{ label: t("agentManager.shortcuts.toggleDiff"), binding: bindings.toggleDiff ?? "" },
|
||||
{ label: t("agentManager.shortcuts.toggleTerminal"), binding: bind("showTerminal") },
|
||||
{ label: t("agentManager.terminal.add"), binding: bind("newTerminal") },
|
||||
{
|
||||
label: `${t("agentManager.shortcuts.previousTab")} (${t("agentManager.tab.terminal")})`,
|
||||
binding: bind("previousTerminal"),
|
||||
},
|
||||
{
|
||||
label: `${t("agentManager.shortcuts.nextTab")} (${t("agentManager.tab.terminal")})`,
|
||||
binding: bind("nextTerminal"),
|
||||
},
|
||||
{ label: t("agentManager.shortcuts.runScript"), binding: bind("runScript") },
|
||||
{ label: t("agentManager.shortcuts.toggleDiff"), binding: bind("toggleDiff") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("agentManager.shortcuts.category.global"),
|
||||
shortcuts: [
|
||||
{ label: t("agentManager.shortcuts.openAgentManager"), binding: bindings.agentManagerOpen ?? "" },
|
||||
{ label: t("agentManager.shortcuts.cycleAgentMode"), binding: bindings.cycleAgentMode ?? "" },
|
||||
{ label: t("agentManager.shortcuts.cyclePreviousAgentMode"), binding: bindings.cyclePreviousAgentMode ?? "" },
|
||||
{ label: t("agentManager.shortcuts.showShortcuts"), binding: bindings.showShortcuts ?? "" },
|
||||
{ label: t("agentManager.shortcuts.openAgentManager"), binding: bind("agentManagerOpen") },
|
||||
{ label: t("agentManager.shortcuts.cycleAgentMode"), binding: bind("cycleAgentMode") },
|
||||
{ label: t("agentManager.shortcuts.cyclePreviousAgentMode"), binding: bind("cyclePreviousAgentMode") },
|
||||
{ label: t("agentManager.shortcuts.showShortcuts"), binding: bind("showShortcuts") },
|
||||
].filter((s) => s.binding),
|
||||
},
|
||||
].filter((c) => c.shortcuts.length > 0)
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useLanguage } from "../../src/context/language"
|
||||
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
|
||||
import { useTabScroll } from "../../src/utils/tab-scroll"
|
||||
@@ -57,8 +57,11 @@ interface Props {
|
||||
onCloseOthers: (terminalId: string) => void
|
||||
/** Create a new side terminal for this context. */
|
||||
onStart: () => void
|
||||
nextKeybind: string
|
||||
closeKeybind: string
|
||||
/** Deliberately stop a running script terminal. */
|
||||
onStop: (terminalId: string) => void
|
||||
onFocusPrompt: () => void
|
||||
}
|
||||
|
||||
export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
@@ -170,7 +173,10 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
status={props.state.scriptStatus(term.id)}
|
||||
keybind={active() === term.id ? "" : props.nextKeybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={active() === term.id}
|
||||
focused={props.state.sideFocusedId() === term.id}
|
||||
role="tab"
|
||||
selected={active() === term.id}
|
||||
tabIndex={active() === term.id ? 0 : -1}
|
||||
@@ -226,7 +232,12 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })}
|
||||
{renderSideTerminalLayer({
|
||||
state: props.state,
|
||||
contextKey: props.contextKey,
|
||||
visible: props.visible,
|
||||
onFocusPrompt: props.onFocusPrompt,
|
||||
})}
|
||||
<Show when={props.visible() && sides().length === 0 && pending()}>
|
||||
<div class="am-side-terminal-state" role="status">
|
||||
<Spinner />
|
||||
|
||||
@@ -27,6 +27,7 @@ export const TerminalTabChrome: Component<{
|
||||
status?: ScriptTerminalStatus
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
focused?: boolean
|
||||
active: boolean
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
@@ -46,7 +47,9 @@ export const TerminalTabChrome: Component<{
|
||||
return "console"
|
||||
}
|
||||
return (
|
||||
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
|
||||
<div
|
||||
class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-terminal-focused" : ""}`}
|
||||
>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
@@ -126,6 +129,7 @@ export const SortableTerminalTab: Component<{
|
||||
status?: ScriptTerminalStatus
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
focused?: boolean
|
||||
active: boolean
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
@@ -148,6 +152,7 @@ export const SortableTerminalTab: Component<{
|
||||
status={props.status}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
focused={props.focused}
|
||||
active={props.active}
|
||||
role={props.role}
|
||||
selected={props.selected}
|
||||
|
||||
+7
-1
@@ -20,6 +20,8 @@ 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
|
||||
@@ -27,6 +29,10 @@ 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">
|
||||
@@ -39,7 +45,7 @@ export const TerminalDestinationButton: Component<Props> = (props) => {
|
||||
)
|
||||
return (
|
||||
<div class="am-split-button">
|
||||
<TooltipKeybind title={t("agentManager.tab.terminal")} keybind={props.keybind()} placement="bottom">
|
||||
<TooltipKeybind title={title()} keybind={props.keybind()} placement="bottom">
|
||||
<IconButton
|
||||
icon="console"
|
||||
size="small"
|
||||
|
||||
@@ -50,6 +50,9 @@ interface Props {
|
||||
* layer tracks this as `focusedId` so `Cmd+W` can target the
|
||||
* terminal that actually has the cursor. */
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
/** Handle the Agent Manager prompt shortcut locally because xterm's
|
||||
* textarea does not reliably forward custom commands to the workbench. */
|
||||
onFocusPrompt?: () => void
|
||||
/** Reports OSC window-title escape codes (`ESC ] 0/1/2 ; title BEL`)
|
||||
* sent by the shell or running programs — fish sets it to the active
|
||||
* command, oh-my-zsh to user@host:cwd, vim to the file name. The
|
||||
@@ -131,7 +134,7 @@ function isAgentManagerShortcut(e: KeyboardEvent): boolean {
|
||||
const key = e.key.toLowerCase()
|
||||
if (e.altKey && ["arrowleft", "arrowright", "arrowup", "arrowdown"].includes(key)) return true
|
||||
if (["t", "w", "n", "d", "e", "f"].includes(key)) return true
|
||||
if (e.shiftKey && ["w", "n", "o", "r", "m", "/", "?"].includes(key)) return true
|
||||
if (e.shiftKey && ["t", "w", "n", "o", "r", "m", "[", "]", "/", "?"].includes(key)) return true
|
||||
if (/^[1-9]$/.test(key)) return true
|
||||
if (key === "/") return true
|
||||
return false
|
||||
@@ -151,6 +154,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
const term = new Terminal({
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
cursorInactiveStyle: "outline",
|
||||
fontFamily: props.font.fontFamily,
|
||||
fontSize: props.font.fontSize,
|
||||
scrollback: 5000,
|
||||
@@ -172,9 +176,17 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Pass agent-manager hotkeys through to the parent key handler so
|
||||
// ⌘T / ⌘W / ⌘⌥← etc. still work while the terminal is focused.
|
||||
term.attachCustomKeyEventHandler((event) => !isAgentManagerShortcut(event))
|
||||
// Pass Agent Manager hotkeys through to the parent key handler so
|
||||
// ⌘T / ⌘⇧T / ⌘W / terminal cycling / ⌘⌥← still work while focused.
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
const prompt =
|
||||
(event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "m"
|
||||
if (prompt) {
|
||||
if (event.type === "keydown") props.onFocusPrompt?.()
|
||||
return false
|
||||
}
|
||||
return !isAgentManagerShortcut(event)
|
||||
})
|
||||
|
||||
// Track DOM focus so the state layer knows which terminal holds the
|
||||
// cursor (drives Cmd+W targeting). focusout is ignored when focus
|
||||
|
||||
@@ -51,6 +51,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
status={deps.terms.scriptStatus(deps.id)}
|
||||
keybind={isActive() ? "" : deps.keybind()}
|
||||
closeKeybind={deps.closeKeybind()}
|
||||
focused={deps.terms.focusedId() === deps.id}
|
||||
active={isActive()}
|
||||
role={deps.role}
|
||||
selected={deps.selected}
|
||||
@@ -93,7 +94,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
* exists; that boundary never flips under a live xterm, since removing
|
||||
* the last terminal disposes its instance first.
|
||||
*/
|
||||
export function renderTerminalLayer(props: { state: TerminalStateControls }): JSX.Element {
|
||||
export function renderTerminalLayer(props: { state: TerminalStateControls; onFocusPrompt: () => void }): JSX.Element {
|
||||
const layerActive = () => props.state.activeId() !== undefined
|
||||
const slotVisible = (termId: string, contextKey: string) =>
|
||||
props.state.activeId() === termId && props.state.currentKey() === contextKey
|
||||
@@ -113,6 +114,7 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onFocusPrompt={props.onFocusPrompt}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
</div>
|
||||
@@ -138,6 +140,7 @@ export function renderSideTerminalLayer(props: {
|
||||
state: TerminalStateControls
|
||||
contextKey: Accessor<string>
|
||||
visible: Accessor<boolean>
|
||||
onFocusPrompt: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div class={`am-side-terminal-layer ${props.visible() ? "am-side-terminal-layer-active" : ""}`}>
|
||||
@@ -159,6 +162,7 @@ export function renderSideTerminalLayer(props: {
|
||||
status={() => props.state.scriptStatus(term.id)}
|
||||
restartable={term.kind === undefined}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onFocusPrompt={props.onFocusPrompt}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* `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 to hide. Hiding never kills the terminal — only the
|
||||
* explicit close action (or `Cmd+W` while it holds focus) does.
|
||||
* 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.
|
||||
*
|
||||
* ## Destination state ownership
|
||||
*
|
||||
@@ -71,6 +72,10 @@ export interface SideTerminalDeps {
|
||||
visible: Accessor<boolean>
|
||||
/** Id of the side terminal holding DOM focus, if any. */
|
||||
focusedId: Accessor<string | undefined>
|
||||
/** Number of side terminals in the visible context. */
|
||||
count: Accessor<number>
|
||||
/** Whether the focused terminal is provider-owned Run/Setup output. */
|
||||
isScript: (terminalId: string) => boolean
|
||||
/** Leave terminal mode; the terminal stays alive in the background. */
|
||||
hide: () => void
|
||||
/** Move focus back to the chat composer. */
|
||||
@@ -106,9 +111,12 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
|
||||
const toggle = () => {
|
||||
if (deps.visible()) {
|
||||
const was = deps.focusedId() !== undefined
|
||||
if (!deps.focusedId()) {
|
||||
deps.handlers.requestSide()
|
||||
return
|
||||
}
|
||||
deps.hide()
|
||||
handoff(was)
|
||||
handoff(true)
|
||||
return
|
||||
}
|
||||
deps.handlers.requestSide()
|
||||
@@ -122,15 +130,18 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open
|
||||
* on the remaining terminals, or on the empty state when this was
|
||||
* the last one. */
|
||||
/** Leave the focused side terminal without killing its shell when it is
|
||||
* the last terminal or provider-owned script output. */
|
||||
const close = (): boolean => {
|
||||
const id = deps.focusedId()
|
||||
if (!id) return false
|
||||
const done = deps.handlers.closeSide(id)
|
||||
if (done) handoff(true)
|
||||
return done
|
||||
const only = deps.count() === 1
|
||||
if (only || deps.isScript(id)) {
|
||||
deps.hide()
|
||||
handoff(true)
|
||||
return true
|
||||
}
|
||||
return deps.handlers.closeSide(id)
|
||||
}
|
||||
|
||||
/** Toolbar button and `Cmd/Ctrl+/`: follow the user's destination. */
|
||||
|
||||
@@ -746,8 +746,6 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
const target = deps.findTab(nextId)
|
||||
if (target) deps.selectSessionTab(target.id, deps.isPendingId(target.id))
|
||||
}
|
||||
} else {
|
||||
deps.clearSession()
|
||||
}
|
||||
}
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
@@ -764,6 +762,14 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
// unmount its xterm while the backend PTY leaks (no close sent).
|
||||
const term = deps.state.sides().find((t) => t.id === terminalId)
|
||||
if (!term) return false
|
||||
const key = term.contextKey
|
||||
const active = deps.state.sideActiveFor(key) === terminalId
|
||||
const rest = active ? deps.state.sidesForContext(key).filter((item) => item.id !== terminalId) : []
|
||||
const survivor = rest[rest.length - 1]
|
||||
if (survivor) {
|
||||
deps.state.setSideActive(key, survivor.id)
|
||||
deps.state.requestFocus(survivor.id)
|
||||
}
|
||||
if (term.kind) {
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
return true
|
||||
@@ -820,6 +826,33 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Close the main terminal that actually owns DOM focus, not just the active tab. */
|
||||
const closeFocused = () => {
|
||||
const id = deps.state.focusedId()
|
||||
if (!id || !deps.state.current().some((term) => term.id === id)) return false
|
||||
closeTerminal(id)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Cycle terminals within one placement, wrapping at either end. */
|
||||
const cycle = (direction: "previous" | "next", placement: "side" | "tab") => {
|
||||
const key = deps.state.sideKey()
|
||||
const list = placement === "side" ? deps.state.sidesForContext(key) : deps.state.current()
|
||||
if (list.length === 0) return false
|
||||
const current = placement === "side" ? deps.state.sideActiveFor(key) : deps.state.activeId()
|
||||
const index = list.findIndex((term) => term.id === current)
|
||||
const start = index === -1 ? (direction === "next" ? -1 : list.length) : index
|
||||
const offset = direction === "next" ? 1 : -1
|
||||
const next = list[(start + offset + list.length) % list.length]!
|
||||
if (placement === "side") {
|
||||
deps.state.setSideActive(key, next.id)
|
||||
deps.state.requestFocus(next.id)
|
||||
return true
|
||||
}
|
||||
activate(next.id)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
closeTerminal,
|
||||
closeSide,
|
||||
@@ -834,6 +867,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
ensureSide,
|
||||
addSide,
|
||||
closeActive,
|
||||
closeFocused,
|
||||
cycle,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -74,14 +74,8 @@ import { errorIDs } from "./session-errors"
|
||||
import { PartStash } from "./part-stash"
|
||||
import { mergeParts, sameParts } from "./session-parts"
|
||||
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"
|
||||
@@ -670,13 +664,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()
|
||||
@@ -930,50 +929,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.
|
||||
|
||||
@@ -962,6 +962,9 @@ export const SideTerminalPanelEmpty: Story = {
|
||||
state={state}
|
||||
contextKey={() => LOCAL}
|
||||
visible={() => true}
|
||||
nextKeybind="⌘⇧]"
|
||||
closeKeybind="⌘W"
|
||||
onFocusPrompt={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onCloseOthers={() => undefined}
|
||||
@@ -1004,6 +1007,9 @@ export const SideTerminalPanelTabs: Story = {
|
||||
state={state}
|
||||
contextKey={() => LOCAL}
|
||||
visible={() => true}
|
||||
nextKeybind="⌘⇧]"
|
||||
closeKeybind="⌘W"
|
||||
onFocusPrompt={() => undefined}
|
||||
onSelect={(id) => state.setSideActive(LOCAL, id)}
|
||||
onClose={() => undefined}
|
||||
onCloseOthers={() => undefined}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFile, unlink } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Schema } from "effect"
|
||||
import { Command } from "@/command"
|
||||
import { configEntryNameFromPath } from "@/config/entry-name"
|
||||
import { WorkflowsMigrator } from "@/kilocode/workflows-migrator"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
variant: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.String),
|
||||
builtin: Schema.Boolean,
|
||||
location: Schema.String,
|
||||
editable: Schema.Boolean,
|
||||
content: Schema.optional(Schema.String),
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
hints: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "CommandFile" })
|
||||
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
type File = {
|
||||
name: string
|
||||
location: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const COMMAND_PREFIXES = ["command/", "commands/"]
|
||||
|
||||
async function files(dir: string) {
|
||||
const result: File[] = []
|
||||
for (const file of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, absolute: true, dot: true, symlink: true })) {
|
||||
result.push(await command(dir, file))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function command(dir: string, file: string): Promise<File> {
|
||||
const content = await readFile(file, "utf8")
|
||||
return {
|
||||
name: configEntryNameFromPath(path.relative(dir, file), COMMAND_PREFIXES),
|
||||
location: file,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function precedence(files: File[]) {
|
||||
const result = new Map<string, File>()
|
||||
for (const file of files) result.set(file.name, file)
|
||||
return result
|
||||
}
|
||||
|
||||
function description(cmd: Command.Info, file?: File) {
|
||||
if (cmd.description) return cmd.description
|
||||
if (file) return WorkflowsMigrator.extractDescription(file.content)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function literal(cmd: Command.Info) {
|
||||
return typeof cmd.template === "string" ? cmd.template : undefined
|
||||
}
|
||||
|
||||
export async function discover(input: { commands: readonly Command.Info[]; directories: readonly string[]; directory: string }) {
|
||||
const all = []
|
||||
for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) {
|
||||
all.push({ name: item.name, location: item.path, content: item.content })
|
||||
}
|
||||
for (const dir of input.directories) all.push(...(await files(dir)))
|
||||
const by = precedence(all)
|
||||
return input.commands
|
||||
.filter((cmd) => cmd.source !== "skill")
|
||||
.map((cmd): Info => {
|
||||
const file = by.get(cmd.name)
|
||||
if (file) {
|
||||
return {
|
||||
name: cmd.name,
|
||||
description: description(cmd, file),
|
||||
agent: cmd.agent,
|
||||
model: cmd.model,
|
||||
variant: cmd.variant,
|
||||
source: cmd.source,
|
||||
builtin: false,
|
||||
location: file.location,
|
||||
editable: true,
|
||||
content: file.content,
|
||||
subtask: cmd.subtask,
|
||||
hints: cmd.hints,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: cmd.name,
|
||||
description: description(cmd),
|
||||
agent: cmd.agent,
|
||||
model: cmd.model,
|
||||
variant: cmd.variant,
|
||||
source: cmd.source,
|
||||
builtin: true,
|
||||
location: "builtin",
|
||||
editable: false,
|
||||
content: literal(cmd),
|
||||
subtask: cmd.subtask,
|
||||
hints: cmd.hints,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function target(location: string, commands: readonly Info[]) {
|
||||
if (!path.isAbsolute(location)) throw new Error("command location must be absolute")
|
||||
const file = path.resolve(location)
|
||||
const command = commands.find((item) => item.editable && path.resolve(item.location) === file)
|
||||
if (!command) throw new Error("command not found in registry")
|
||||
if (!file.endsWith(".md")) throw new Error("command location must reference a markdown file")
|
||||
const cache = path.join(Global.Path.cache, "commands")
|
||||
const relative = path.relative(cache, file)
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
||||
throw new Error("remove cache-backed commands from configuration")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
export async function remove(location: string, commands: readonly Info[]) {
|
||||
await unlink(target(location, commands))
|
||||
}
|
||||
|
||||
export * as CommandFiles from "./command-files"
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/kilocode/notebook/protocol"
|
||||
import { ModelUsage } from "@/kilocode/session/model-usage"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { CommandFiles } from "@/kilocode/command-files"
|
||||
|
||||
const root = "/kilocode"
|
||||
|
||||
@@ -31,6 +32,10 @@ export const RemoveSkillPayload = Schema.Struct({
|
||||
location: Schema.String,
|
||||
})
|
||||
|
||||
export const RemoveCommandPayload = Schema.Struct({
|
||||
location: Schema.String,
|
||||
})
|
||||
|
||||
export const RemoveAgentPayload = Schema.Struct({
|
||||
name: Schema.String,
|
||||
})
|
||||
@@ -47,6 +52,8 @@ export const AgentManagerRejectPayload = Schema.Struct({ error: AgentManagerFail
|
||||
export const KilocodePaths = {
|
||||
heapSnapshot: `${root}/heap/snapshot`,
|
||||
agentRequirements: `${root}/agent/requirements`,
|
||||
commandFiles: `${root}/command/files`,
|
||||
removeCommand: `${root}/command/remove`,
|
||||
removeSkill: `${root}/skill/remove`,
|
||||
removeAgent: `${root}/agent/remove`,
|
||||
notebookList: `${root}/notebook`,
|
||||
@@ -83,6 +90,28 @@ export const KilocodeApi = HttpApi.make("kilocode")
|
||||
description: "Check whether the selected agent's requirements are available in the request directory.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("commandFiles", KilocodePaths.commandFiles, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(CommandFiles.Info), "Command files"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.commandFiles",
|
||||
summary: "List command files",
|
||||
description: "List commands with editable file locations for settings clients.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("removeCommand", KilocodePaths.removeCommand, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemoveCommandPayload,
|
||||
success: described(Schema.Boolean, "Command removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.removeCommand",
|
||||
summary: "Remove a command",
|
||||
description: "Remove a command by deleting its markdown file from disk and clearing it from cache.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemoveSkillPayload,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as KiloAgent from "@/kilocode/agent"
|
||||
import { CommandFiles } from "@/kilocode/command-files"
|
||||
import * as KiloSkill from "@/kilocode/skill-remove"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
|
||||
@@ -21,12 +23,14 @@ import {
|
||||
NotebookRejectPayload,
|
||||
NotebookReplyPayload,
|
||||
RemoveAgentPayload,
|
||||
RemoveCommandPayload,
|
||||
RemoveSkillPayload,
|
||||
} from "../groups/kilocode"
|
||||
|
||||
export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const commands = yield* Command.Service
|
||||
const skills = yield* Skill.Service
|
||||
const config = yield* Config.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
@@ -43,6 +47,34 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return yield* agents.requirementStatus(ctx.query.agent)
|
||||
})
|
||||
|
||||
const commandFiles = Effect.fn("KilocodeHttpApi.commandFiles")(function* () {
|
||||
const instance = yield* InstanceState.context
|
||||
const dirs = yield* config.directories()
|
||||
const items = yield* commands.list()
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
|
||||
catch: (err) => err,
|
||||
}).pipe(Effect.catch((err) => Effect.die(err)))
|
||||
})
|
||||
|
||||
const removeCommand = Effect.fn("KilocodeHttpApi.removeCommand")(function* (ctx: {
|
||||
payload: typeof RemoveCommandPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const dirs = yield* config.directories()
|
||||
const items = yield* commands.list()
|
||||
const entries = yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
|
||||
catch: (err) => err,
|
||||
}).pipe(Effect.catch((err) => Effect.die(err)))
|
||||
yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.remove(ctx.payload.location, entries),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
yield* store.dispose(instance)
|
||||
return true
|
||||
})
|
||||
|
||||
const removeSkill = Effect.fn("KilocodeHttpApi.removeSkill")(function* (ctx: {
|
||||
payload: typeof RemoveSkillPayload.Type
|
||||
}) {
|
||||
@@ -136,6 +168,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return handlers
|
||||
.handle("heapSnapshot", heapSnapshot)
|
||||
.handle("agentRequirements", agentRequirements)
|
||||
.handle("commandFiles", commandFiles)
|
||||
.handle("removeCommand", removeCommand)
|
||||
.handle("removeSkill", removeSkill)
|
||||
.handle("removeAgent", removeAgent)
|
||||
.handle("notebookList", notebookList)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { CommandFiles } from "../../src/kilocode/command-files"
|
||||
import type { Command } from "../../src/command"
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
roots.length = 0
|
||||
})
|
||||
|
||||
async function temp() {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-command-files-"))
|
||||
roots.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function cmd(input: Partial<Command.Info> & Pick<Command.Info, "name">): Command.Info {
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
source: input.source,
|
||||
template: input.template ?? "body",
|
||||
subtask: input.subtask,
|
||||
hints: input.hints ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
describe("CommandFiles", () => {
|
||||
test("discovers editable command files and read-only builtins", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "command", "review.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "---\ndescription: Review code\n---\n\nReview $ARGUMENTS")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [
|
||||
cmd({
|
||||
name: "review",
|
||||
source: "command",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
hints: ["$ARGUMENTS"],
|
||||
}),
|
||||
cmd({ name: "init", source: "command" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(items.map((item) => item.name)).toEqual(["review", "init"])
|
||||
expect(items[0]).toMatchObject({
|
||||
name: "review",
|
||||
editable: true,
|
||||
builtin: false,
|
||||
location: file,
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
})
|
||||
expect(items[0].content).toContain("Review $ARGUMENTS")
|
||||
expect(items[1]).toMatchObject({ name: "init", editable: false, builtin: true, location: "builtin" })
|
||||
})
|
||||
|
||||
test("maps legacy workflows to editable commands", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "workflows", "ship.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "# Ship\n\nRun release checks")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "ship", source: "command", description: "Workflow: ship" })],
|
||||
})
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
|
||||
expect(items[0].content).toBe("# Ship\n\nRun release checks")
|
||||
})
|
||||
|
||||
test("prefers command file attribution over same-named legacy workflow", async () => {
|
||||
const dir = await temp()
|
||||
const workflow = path.join(dir, ".kilo", "workflows", "ship.md")
|
||||
const file = path.join(dir, ".kilo", "command", "ship.md")
|
||||
await mkdir(path.dirname(workflow), { recursive: true })
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(workflow, "# Legacy Ship")
|
||||
await writeFile(file, "# Command Ship")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "ship", source: "command" })],
|
||||
})
|
||||
|
||||
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
|
||||
expect(items[0].content).toBe("# Command Ship")
|
||||
})
|
||||
|
||||
test("discovers symlinked command files", async () => {
|
||||
const dir = await temp()
|
||||
const real = path.join(dir, "linked", "review.md")
|
||||
const link = path.join(dir, ".kilo", "command", "review.md")
|
||||
await mkdir(path.dirname(real), { recursive: true })
|
||||
await mkdir(path.dirname(link), { recursive: true })
|
||||
await writeFile(real, "Review from symlink")
|
||||
await symlink(real, link)
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "review", source: "command" })],
|
||||
})
|
||||
|
||||
expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: link })
|
||||
expect(items[0].content).toBe("Review from symlink")
|
||||
})
|
||||
|
||||
test("remove only accepts known editable markdown files", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "command", "ok.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "OK")
|
||||
const entries = [
|
||||
{ name: "ok", location: file, editable: true, builtin: false, hints: [] },
|
||||
{ name: "init", location: "builtin", editable: false, builtin: true, hints: [] },
|
||||
]
|
||||
|
||||
await expect(CommandFiles.remove("builtin", entries)).rejects.toThrow("absolute")
|
||||
await expect(CommandFiles.remove(path.join(dir, "other.md"), entries)).rejects.toThrow("not found")
|
||||
await CommandFiles.remove(file, entries)
|
||||
await expect(CommandFiles.remove(file, entries)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { mkdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { KiloMemory } from "@kilocode/kilo-memory/effect"
|
||||
import { MemoryPaths } from "@kilocode/kilo-memory/effect/paths"
|
||||
import { array, check, object } from "../../server/httpapi-exercise/assertions"
|
||||
import { array, check, isRecord, object } from "../../server/httpapi-exercise/assertions"
|
||||
import { http, route } from "../../server/httpapi-exercise/dsl"
|
||||
import type { Scenario, ScenarioContext } from "../../server/httpapi-exercise/types"
|
||||
import { anacondaDesktopScenarios } from "../anaconda-desktop/httpapi-exercise-scenarios"
|
||||
@@ -37,6 +37,13 @@ const agent = async (dir: string) => {
|
||||
)
|
||||
}
|
||||
|
||||
const command = async (dir: string) => {
|
||||
await Bun.write(
|
||||
path.join(dir, ".kilo/command/httpapi-remove.md"),
|
||||
"---\ndescription: HTTP API command remove\nmodel: anthropic/claude-sonnet-4-6\nvariant: high\n---\nRun command.\n",
|
||||
)
|
||||
}
|
||||
|
||||
function memory(ctx: ScenarioContext) {
|
||||
const dir = directory(ctx)
|
||||
return MemoryPaths.root({ ctx: { directory: dir, worktree: dir } })
|
||||
@@ -544,6 +551,44 @@ export const kiloScenarios: Scenario[] = [
|
||||
array(body.mcps)
|
||||
array(body.vscode_extensions)
|
||||
}),
|
||||
http.protected
|
||||
.get("/kilocode/command/files", "kilocode.commandFiles")
|
||||
.inProject({ git: true, init: command })
|
||||
.json(200, (body, ctx) => {
|
||||
array(body)
|
||||
const item = body.find((item) => isRecord(item) && item.name === "httpapi-remove")
|
||||
object(item)
|
||||
check(item.description === "HTTP API command remove", "command file should include description")
|
||||
check(
|
||||
item.location === path.join(directory(ctx), ".kilo/command/httpapi-remove.md"),
|
||||
"command file should include location",
|
||||
)
|
||||
check(item.editable === true, "command file should be editable")
|
||||
check(item.builtin === false, "command file should not be builtin")
|
||||
check(item.model === "anthropic/claude-sonnet-4-6", "command file should include model metadata")
|
||||
check(item.variant === "high", "command file should include variant metadata")
|
||||
check(typeof item.content === "string" && item.content.includes("Run command."), "command file should include content")
|
||||
}),
|
||||
http.protected
|
||||
.post("/kilocode/command/remove", "kilocode.removeCommand")
|
||||
.inProject({ git: true, init: command })
|
||||
.mutating()
|
||||
.preserveDatabase()
|
||||
.at((ctx) => ({
|
||||
path: "/kilocode/command/remove",
|
||||
headers: ctx.headers(),
|
||||
body: { location: path.join(directory(ctx), ".kilo/command/httpapi-remove.md") },
|
||||
}))
|
||||
.jsonEffect(200, (body, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
check(body === true, "command removal should return true")
|
||||
const location = path.join(directory(ctx), ".kilo/command/httpapi-remove.md")
|
||||
check(
|
||||
!(yield* Effect.promise(() => Bun.file(location).exists())),
|
||||
"removed command should not remain on disk",
|
||||
)
|
||||
}),
|
||||
),
|
||||
http.protected
|
||||
.post("/kilocode/skill/remove", "kilocode.removeSkill")
|
||||
.inProject({ git: true, init: skill })
|
||||
|
||||
@@ -144,7 +144,8 @@ describe("test runner cleanup", () => {
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
const code = await deadline(proc.exited, 15_000)
|
||||
const limit = process.platform === "win32" ? 30_000 : 15_000
|
||||
const code = await deadline(proc.exited, limit)
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
expect(code, output[1] || output[0]).not.toBe(0)
|
||||
expect(output[0]).toContain("TIME")
|
||||
@@ -167,7 +168,7 @@ describe("test runner cleanup", () => {
|
||||
await proc.exited
|
||||
await fs.rm(file, { force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, 45_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"bounds inherited output after the test process exits",
|
||||
|
||||
@@ -183,6 +183,8 @@ import type {
|
||||
KilocodeAgentManagerReplyResponses,
|
||||
KilocodeAgentRequirementsErrors,
|
||||
KilocodeAgentRequirementsResponses,
|
||||
KilocodeCommandFilesErrors,
|
||||
KilocodeCommandFilesResponses,
|
||||
KilocodeHeapSnapshotErrors,
|
||||
KilocodeHeapSnapshotResponses,
|
||||
KilocodeNotebookListErrors,
|
||||
@@ -193,6 +195,8 @@ import type {
|
||||
KilocodeNotebookReplyResponses,
|
||||
KilocodeRemoveAgentErrors,
|
||||
KilocodeRemoveAgentResponses,
|
||||
KilocodeRemoveCommandErrors,
|
||||
KilocodeRemoveCommandResponses,
|
||||
KilocodeRemoveSkillErrors,
|
||||
KilocodeRemoveSkillResponses,
|
||||
KilocodeSessionImportMessageErrors,
|
||||
@@ -8097,6 +8101,81 @@ export class Kilocode extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List command files
|
||||
*
|
||||
* List commands with editable file locations for settings clients.
|
||||
*/
|
||||
public commandFiles<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<
|
||||
KilocodeCommandFilesResponses,
|
||||
KilocodeCommandFilesErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/command/files",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a command
|
||||
*
|
||||
* Remove a command by deleting its markdown file from disk and clearing it from cache.
|
||||
*/
|
||||
public removeCommand<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
location?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "location" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
KilocodeRemoveCommandResponses,
|
||||
KilocodeRemoveCommandErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/command/remove",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a skill
|
||||
*
|
||||
|
||||
@@ -4169,6 +4169,21 @@ export type AgentRequirementResult = {
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandFile = {
|
||||
name: string
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
variant?: string
|
||||
source?: string
|
||||
builtin: boolean
|
||||
location: string
|
||||
editable: boolean
|
||||
content?: string
|
||||
subtask?: boolean
|
||||
hints: Array<string>
|
||||
}
|
||||
|
||||
export type NotebookOutput = {
|
||||
mime: string
|
||||
text?: string
|
||||
@@ -16492,6 +16507,64 @@ export type KilocodeAgentRequirementsResponses = {
|
||||
export type KilocodeAgentRequirementsResponse =
|
||||
KilocodeAgentRequirementsResponses[keyof KilocodeAgentRequirementsResponses]
|
||||
|
||||
export type KilocodeCommandFilesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/command/files"
|
||||
}
|
||||
|
||||
export type KilocodeCommandFilesErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type KilocodeCommandFilesError = KilocodeCommandFilesErrors[keyof KilocodeCommandFilesErrors]
|
||||
|
||||
export type KilocodeCommandFilesResponses = {
|
||||
/**
|
||||
* Command files
|
||||
*/
|
||||
200: Array<CommandFile>
|
||||
}
|
||||
|
||||
export type KilocodeCommandFilesResponse = KilocodeCommandFilesResponses[keyof KilocodeCommandFilesResponses]
|
||||
|
||||
export type KilocodeRemoveCommandData = {
|
||||
body?: {
|
||||
location: string
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/command/remove"
|
||||
}
|
||||
|
||||
export type KilocodeRemoveCommandErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
}
|
||||
|
||||
export type KilocodeRemoveCommandError = KilocodeRemoveCommandErrors[keyof KilocodeRemoveCommandErrors]
|
||||
|
||||
export type KilocodeRemoveCommandResponses = {
|
||||
/**
|
||||
* Command removed
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type KilocodeRemoveCommandResponse = KilocodeRemoveCommandResponses[keyof KilocodeRemoveCommandResponses]
|
||||
|
||||
export type KilocodeRemoveSkillData = {
|
||||
body?: {
|
||||
location: string
|
||||
|
||||
@@ -14953,6 +14953,142 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/command/files": {
|
||||
"get": {
|
||||
"tags": ["kilocode"],
|
||||
"operationId": "kilocode.commandFiles",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Command files",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CommandFile"
|
||||
},
|
||||
"description": "Command files"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List commands with editable file locations for settings clients.",
|
||||
"summary": "List command files",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.commandFiles({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/command/remove": {
|
||||
"post": {
|
||||
"tags": ["kilocode"],
|
||||
"operationId": "kilocode.removeCommand",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Command removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"description": "Command removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Remove a command by deleting its markdown file from disk and clearing it from cache.",
|
||||
"summary": "Remove a command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.removeCommand({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/skill/remove": {
|
||||
"post": {
|
||||
"tags": ["kilocode"],
|
||||
@@ -38395,6 +38531,52 @@
|
||||
"required": ["agent", "directory", "enabled", "state", "skills", "mcps", "vscode_extensions"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CommandFile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"builtin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"editable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["name", "builtin", "location", "editable", "hints"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NotebookOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user